@mauribadnights/clooks 0.2.0 → 0.2.2
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.md +61 -4
- package/dist/auth.d.ts +4 -0
- package/dist/auth.js +27 -0
- package/dist/cli.js +9 -4
- package/dist/doctor.js +54 -0
- package/dist/handlers.d.ts +5 -0
- package/dist/handlers.js +18 -0
- package/dist/index.d.ts +3 -1
- package/dist/index.js +8 -1
- package/dist/manifest.d.ts +1 -1
- package/dist/manifest.js +9 -5
- package/dist/metrics.d.ts +8 -1
- package/dist/metrics.js +32 -6
- package/dist/migrate.js +6 -2
- package/dist/server.d.ts +8 -2
- package/dist/server.js +53 -10
- package/dist/types.d.ts +5 -0
- package/dist/watcher.d.ts +18 -0
- package/dist/watcher.js +120 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -97,6 +97,7 @@ settings:
|
|
|
97
97
|
**Handler types:**
|
|
98
98
|
- `script` -- runs a shell command, pipes hook JSON to stdin, reads JSON from stdout.
|
|
99
99
|
- `inline` -- imports a JS module and calls its default export. Faster; no subprocess overhead.
|
|
100
|
+
- `llm` -- calls Anthropic Messages API. Supports prompt templates, batching, and cost tracking. *(v0.2+)*
|
|
100
101
|
|
|
101
102
|
## Observability
|
|
102
103
|
|
|
@@ -171,11 +172,54 @@ handlers:
|
|
|
171
172
|
batchGroup: analysis # batched with code-review into one API call
|
|
172
173
|
```
|
|
173
174
|
|
|
174
|
-
|
|
175
|
+
**Setup:**
|
|
176
|
+
|
|
177
|
+
```bash
|
|
178
|
+
npm install @anthropic-ai/sdk # peer dependency, only needed for llm handlers
|
|
179
|
+
export ANTHROPIC_API_KEY=sk-... # or set in manifest: settings.anthropicApiKey
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
**Prompt template variables:**
|
|
183
|
+
|
|
184
|
+
| Variable | Source | Description |
|
|
185
|
+
|----------|--------|-------------|
|
|
186
|
+
| `$TRANSCRIPT` | Pre-fetched transcript file | Last 50KB of session transcript |
|
|
187
|
+
| `$GIT_STATUS` | `git status --porcelain` | Current working tree status |
|
|
188
|
+
| `$GIT_DIFF` | `git diff --stat` | Changed files summary (max 20KB) |
|
|
189
|
+
| `$ARGUMENTS` | `hook_input.tool_input` | JSON-stringified tool arguments |
|
|
190
|
+
| `$TOOL_NAME` | `hook_input.tool_name` | Name of the tool being called |
|
|
191
|
+
| `$PROMPT` | `hook_input.prompt` | User's prompt (UserPromptSubmit only) |
|
|
192
|
+
| `$CWD` | `hook_input.cwd` | Current working directory |
|
|
193
|
+
|
|
194
|
+
**LLM handler options:**
|
|
195
|
+
|
|
196
|
+
| Field | Type | Default | Description |
|
|
197
|
+
|-------|------|---------|-------------|
|
|
198
|
+
| `model` | string | required | `claude-haiku-4-5`, `claude-sonnet-4-6`, or `claude-opus-4-6` |
|
|
199
|
+
| `prompt` | string | required | Prompt template with `$VARIABLE` interpolation |
|
|
200
|
+
| `batchGroup` | string | optional | Group ID -- handlers with same group make one API call |
|
|
201
|
+
| `maxTokens` | number | `1024` | Maximum output tokens |
|
|
202
|
+
| `temperature` | number | `1.0` | Sampling temperature |
|
|
203
|
+
| `filter` | string | optional | Keyword filter (see Filtering) |
|
|
204
|
+
| `timeout` | number | `30000` | Timeout in milliseconds |
|
|
205
|
+
|
|
206
|
+
**How batching works:**
|
|
207
|
+
|
|
208
|
+
When multiple LLM handlers share a `batchGroup` on the same event, clooks combines their prompts into a single multi-task API call and splits the structured response back to each handler. This means 3 Haiku calls become 1, saving ~2/3 of the input token cost and eliminating 2 round-trips.
|
|
175
209
|
|
|
176
210
|
### Intelligent Filtering
|
|
177
211
|
|
|
178
|
-
Skip handlers based on keywords.
|
|
212
|
+
Skip handlers based on keywords. The `filter` field works on **all handler types** -- script, inline, and llm.
|
|
213
|
+
|
|
214
|
+
**Filter syntax:**
|
|
215
|
+
|
|
216
|
+
```
|
|
217
|
+
filter: "word1|word2" # run if input contains word1 OR word2
|
|
218
|
+
filter: "!word" # run unless input contains word
|
|
219
|
+
filter: "word1|!word2" # run if word1 present AND word2 absent
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
Matching is case-insensitive against the full JSON-serialized hook input.
|
|
179
223
|
|
|
180
224
|
```yaml
|
|
181
225
|
handlers:
|
|
@@ -204,9 +248,19 @@ handlers:
|
|
|
204
248
|
prompt: "Summarize this session:\n$TRANSCRIPT\n\nGit changes:\n$GIT_DIFF"
|
|
205
249
|
```
|
|
206
250
|
|
|
251
|
+
**Available prefetch keys:**
|
|
252
|
+
|
|
253
|
+
| Key | Source | Max size | Description |
|
|
254
|
+
|-----|--------|----------|-------------|
|
|
255
|
+
| `transcript` | `transcript_path` file | 50KB (tail) | Session conversation history |
|
|
256
|
+
| `git_status` | `git status --porcelain` | unbounded | Working tree status |
|
|
257
|
+
| `git_diff` | `git diff --stat` | 20KB | Changed files summary |
|
|
258
|
+
|
|
259
|
+
Pre-fetched data is cached for the duration of a single event dispatch. Errors on individual keys are silently caught -- a failed `git_status` won't prevent `transcript` from loading.
|
|
260
|
+
|
|
207
261
|
### Cost Tracking
|
|
208
262
|
|
|
209
|
-
Track LLM token usage and costs per handler and model.
|
|
263
|
+
Track LLM token usage and costs per handler and model.
|
|
210
264
|
|
|
211
265
|
```
|
|
212
266
|
$ clooks costs
|
|
@@ -222,7 +276,10 @@ LLM Cost Summary
|
|
|
222
276
|
security-check $0.0053 (12 calls, avg 178 tokens)
|
|
223
277
|
```
|
|
224
278
|
|
|
225
|
-
|
|
279
|
+
- Costs are persisted to `~/.clooks/costs.jsonl`
|
|
280
|
+
- Built-in pricing (per million tokens): Haiku ($0.80 / $4.00), Sonnet ($3.00 / $15.00), Opus ($15.00 / $75.00)
|
|
281
|
+
- Batching savings are estimated based on shared input tokens
|
|
282
|
+
- Cost data also appears in `clooks stats` when LLM handlers have been used
|
|
226
283
|
|
|
227
284
|
## Roadmap
|
|
228
285
|
|
package/dist/auth.d.ts
ADDED
package/dist/auth.js
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// clooks auth — token-based request authentication
|
|
3
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
4
|
+
exports.generateAuthToken = generateAuthToken;
|
|
5
|
+
exports.validateAuth = validateAuth;
|
|
6
|
+
const crypto_1 = require("crypto");
|
|
7
|
+
/** Generate a random auth token (32 hex chars). */
|
|
8
|
+
function generateAuthToken() {
|
|
9
|
+
return (0, crypto_1.randomBytes)(16).toString('hex');
|
|
10
|
+
}
|
|
11
|
+
/** Validate an auth token from request headers. */
|
|
12
|
+
function validateAuth(authHeader, expectedToken) {
|
|
13
|
+
if (!expectedToken)
|
|
14
|
+
return true; // No token configured = no auth required
|
|
15
|
+
if (!authHeader)
|
|
16
|
+
return false;
|
|
17
|
+
// Support "Bearer <token>" format
|
|
18
|
+
const token = authHeader.startsWith('Bearer ')
|
|
19
|
+
? authHeader.slice(7)
|
|
20
|
+
: authHeader;
|
|
21
|
+
// Constant-time comparison to prevent timing attacks
|
|
22
|
+
if (token.length !== expectedToken.length)
|
|
23
|
+
return false;
|
|
24
|
+
const bufA = Buffer.from(token);
|
|
25
|
+
const bufB = Buffer.from(expectedToken);
|
|
26
|
+
return (0, crypto_1.timingSafeEqual)(bufA, bufB);
|
|
27
|
+
}
|
package/dist/cli.js
CHANGED
|
@@ -8,19 +8,22 @@ const metrics_js_1 = require("./metrics.js");
|
|
|
8
8
|
const server_js_1 = require("./server.js");
|
|
9
9
|
const migrate_js_1 = require("./migrate.js");
|
|
10
10
|
const doctor_js_1 = require("./doctor.js");
|
|
11
|
+
const auth_js_1 = require("./auth.js");
|
|
11
12
|
const constants_js_1 = require("./constants.js");
|
|
12
13
|
const fs_1 = require("fs");
|
|
13
14
|
const program = new commander_1.Command();
|
|
14
15
|
program
|
|
15
16
|
.name('clooks')
|
|
16
17
|
.description('Persistent hook runtime for Claude Code')
|
|
17
|
-
.version('0.2.
|
|
18
|
+
.version('0.2.2');
|
|
18
19
|
// --- start ---
|
|
19
20
|
program
|
|
20
21
|
.command('start')
|
|
21
22
|
.description('Start the clooks daemon')
|
|
22
23
|
.option('-f, --foreground', 'Run in foreground (default: background/detached)')
|
|
24
|
+
.option('--no-watch', 'Disable file watching for manifest changes')
|
|
23
25
|
.action(async (opts) => {
|
|
26
|
+
const noWatch = opts.watch === false;
|
|
24
27
|
if (!opts.foreground) {
|
|
25
28
|
// Background mode: check if already running, then spawn detached
|
|
26
29
|
if ((0, server_js_1.isDaemonRunning)()) {
|
|
@@ -32,7 +35,7 @@ program
|
|
|
32
35
|
(0, fs_1.mkdirSync)(constants_js_1.CONFIG_DIR, { recursive: true });
|
|
33
36
|
}
|
|
34
37
|
console.log('Starting clooks daemon in background...');
|
|
35
|
-
(0, server_js_1.startDaemonBackground)();
|
|
38
|
+
(0, server_js_1.startDaemonBackground)({ noWatch });
|
|
36
39
|
// Give it a moment to start
|
|
37
40
|
await new Promise((r) => setTimeout(r, 500));
|
|
38
41
|
if ((0, server_js_1.isDaemonRunning)()) {
|
|
@@ -51,7 +54,7 @@ program
|
|
|
51
54
|
const port = manifest.settings?.port ?? constants_js_1.DEFAULT_PORT;
|
|
52
55
|
const handlerCount = Object.values(manifest.handlers)
|
|
53
56
|
.reduce((sum, arr) => sum + (arr?.length ?? 0), 0);
|
|
54
|
-
await (0, server_js_1.startDaemon)(manifest, metrics);
|
|
57
|
+
await (0, server_js_1.startDaemon)(manifest, metrics, { noWatch });
|
|
55
58
|
console.log(`clooks daemon running on 127.0.0.1:${port} (${handlerCount} handler${handlerCount !== 1 ? 's' : ''})`);
|
|
56
59
|
}
|
|
57
60
|
catch (err) {
|
|
@@ -206,8 +209,10 @@ program
|
|
|
206
209
|
if (!(0, fs_1.existsSync)(constants_js_1.CONFIG_DIR)) {
|
|
207
210
|
(0, fs_1.mkdirSync)(constants_js_1.CONFIG_DIR, { recursive: true });
|
|
208
211
|
}
|
|
209
|
-
const
|
|
212
|
+
const token = (0, auth_js_1.generateAuthToken)();
|
|
213
|
+
const path = (0, manifest_js_1.createDefaultManifest)(token);
|
|
210
214
|
console.log(`Created: ${path}`);
|
|
215
|
+
console.log(`Auth token: ${token}`);
|
|
211
216
|
console.log('Edit this file to configure your hook handlers.');
|
|
212
217
|
});
|
|
213
218
|
program.parse();
|
package/dist/doctor.js
CHANGED
|
@@ -29,6 +29,8 @@ async function runDoctor() {
|
|
|
29
29
|
results.push(checkSettingsHooks());
|
|
30
30
|
// 7. No stale PID file
|
|
31
31
|
results.push(checkStalePid());
|
|
32
|
+
// 8. Auth token consistency (if configured)
|
|
33
|
+
results.push(checkAuthToken());
|
|
32
34
|
return results;
|
|
33
35
|
}
|
|
34
36
|
function checkConfigDir() {
|
|
@@ -166,3 +168,55 @@ function checkStalePid() {
|
|
|
166
168
|
return { check: 'Stale PID', status: 'error', message: `Stale PID file: process ${pid} is dead. Remove ${constants_js_1.PID_FILE} or run "clooks start".` };
|
|
167
169
|
}
|
|
168
170
|
}
|
|
171
|
+
function checkAuthToken() {
|
|
172
|
+
try {
|
|
173
|
+
const manifest = (0, manifest_js_1.loadManifest)();
|
|
174
|
+
const authToken = manifest.settings?.authToken;
|
|
175
|
+
if (!authToken) {
|
|
176
|
+
return { check: 'Auth token', status: 'ok', message: 'No auth token configured (open access)' };
|
|
177
|
+
}
|
|
178
|
+
// Check that settings.json hooks include matching Authorization header
|
|
179
|
+
const candidates = [
|
|
180
|
+
(0, path_1.join)((0, os_1.homedir)(), '.claude', 'settings.local.json'),
|
|
181
|
+
(0, path_1.join)((0, os_1.homedir)(), '.claude', 'settings.json'),
|
|
182
|
+
];
|
|
183
|
+
for (const path of candidates) {
|
|
184
|
+
if (!(0, fs_1.existsSync)(path))
|
|
185
|
+
continue;
|
|
186
|
+
try {
|
|
187
|
+
const raw = (0, fs_1.readFileSync)(path, 'utf-8');
|
|
188
|
+
const settings = JSON.parse(raw);
|
|
189
|
+
if (!settings.hooks)
|
|
190
|
+
continue;
|
|
191
|
+
const expectedHeader = `Bearer ${authToken}`;
|
|
192
|
+
const httpHooks = [];
|
|
193
|
+
for (const ruleGroups of Object.values(settings.hooks)) {
|
|
194
|
+
for (const rule of ruleGroups) {
|
|
195
|
+
if (!Array.isArray(rule.hooks))
|
|
196
|
+
continue;
|
|
197
|
+
for (const hook of rule.hooks) {
|
|
198
|
+
if (hook.type === 'http' && hook.url?.includes(`localhost:${constants_js_1.DEFAULT_PORT}`)) {
|
|
199
|
+
httpHooks.push(hook);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
if (httpHooks.length === 0) {
|
|
205
|
+
return { check: 'Auth token', status: 'warn', message: 'Auth token set but no HTTP hooks found in settings.json' };
|
|
206
|
+
}
|
|
207
|
+
const missingAuth = httpHooks.filter(h => h.headers?.['Authorization'] !== expectedHeader);
|
|
208
|
+
if (missingAuth.length > 0) {
|
|
209
|
+
return { check: 'Auth token', status: 'error', message: `Auth token set but ${missingAuth.length} HTTP hook(s) missing matching Authorization header. Run "clooks migrate".` };
|
|
210
|
+
}
|
|
211
|
+
return { check: 'Auth token', status: 'ok', message: 'Auth token matches settings.json hook headers' };
|
|
212
|
+
}
|
|
213
|
+
catch {
|
|
214
|
+
continue;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
return { check: 'Auth token', status: 'warn', message: 'Auth token set but could not verify settings.json headers' };
|
|
218
|
+
}
|
|
219
|
+
catch {
|
|
220
|
+
return { check: 'Auth token', status: 'ok', message: 'Could not load manifest for auth check' };
|
|
221
|
+
}
|
|
222
|
+
}
|
package/dist/handlers.d.ts
CHANGED
|
@@ -3,6 +3,11 @@ import type { HandlerConfig, HandlerResult, HandlerState, HookEvent, HookInput,
|
|
|
3
3
|
export declare function resetHandlerStates(): void;
|
|
4
4
|
/** Get a copy of the handler states map */
|
|
5
5
|
export declare function getHandlerStates(): Map<string, HandlerState>;
|
|
6
|
+
/**
|
|
7
|
+
* Reset handler states for handlers that have sessionIsolation: true.
|
|
8
|
+
* Called on SessionStart events.
|
|
9
|
+
*/
|
|
10
|
+
export declare function resetSessionIsolatedHandlers(handlers: HandlerConfig[]): void;
|
|
6
11
|
/**
|
|
7
12
|
* Execute all handlers for an event in parallel.
|
|
8
13
|
* Returns merged results array.
|
package/dist/handlers.js
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
4
4
|
exports.resetHandlerStates = resetHandlerStates;
|
|
5
5
|
exports.getHandlerStates = getHandlerStates;
|
|
6
|
+
exports.resetSessionIsolatedHandlers = resetSessionIsolatedHandlers;
|
|
6
7
|
exports.executeHandlers = executeHandlers;
|
|
7
8
|
exports.executeScriptHandler = executeScriptHandler;
|
|
8
9
|
exports.executeInlineHandler = executeInlineHandler;
|
|
@@ -30,6 +31,23 @@ function resetHandlerStates() {
|
|
|
30
31
|
function getHandlerStates() {
|
|
31
32
|
return new Map(handlerStates);
|
|
32
33
|
}
|
|
34
|
+
/**
|
|
35
|
+
* Reset handler states for handlers that have sessionIsolation: true.
|
|
36
|
+
* Called on SessionStart events.
|
|
37
|
+
*/
|
|
38
|
+
function resetSessionIsolatedHandlers(handlers) {
|
|
39
|
+
for (const handler of handlers) {
|
|
40
|
+
if (handler.sessionIsolation) {
|
|
41
|
+
const state = handlerStates.get(handler.id);
|
|
42
|
+
if (state) {
|
|
43
|
+
state.consecutiveFailures = 0;
|
|
44
|
+
state.disabled = false;
|
|
45
|
+
state.totalFires = 0;
|
|
46
|
+
state.totalErrors = 0;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
33
51
|
/**
|
|
34
52
|
* Execute all handlers for an event in parallel.
|
|
35
53
|
* Returns merged results array.
|
package/dist/index.d.ts
CHANGED
|
@@ -4,7 +4,9 @@ export { MetricsCollector } from './metrics.js';
|
|
|
4
4
|
export { migrate, restore, getSettingsPath } from './migrate.js';
|
|
5
5
|
export type { MigratePathOptions } from './migrate.js';
|
|
6
6
|
export { runDoctor } from './doctor.js';
|
|
7
|
-
export { executeHandlers } from './handlers.js';
|
|
7
|
+
export { executeHandlers, resetSessionIsolatedHandlers } from './handlers.js';
|
|
8
|
+
export { startWatcher, stopWatcher } from './watcher.js';
|
|
9
|
+
export { generateAuthToken, validateAuth } from './auth.js';
|
|
8
10
|
export { evaluateFilter } from './filter.js';
|
|
9
11
|
export { executeLLMHandler, executeLLMHandlersBatched, calculateCost, resetClient } from './llm.js';
|
|
10
12
|
export { prefetchContext, renderPromptTemplate } from './prefetch.js';
|
package/dist/index.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
// clooks — public API exports
|
|
3
3
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
4
|
-
exports.LLM_PRICING = exports.DEFAULT_LLM_MAX_TOKENS = exports.DEFAULT_LLM_TIMEOUT = exports.COSTS_FILE = exports.LOG_FILE = exports.METRICS_FILE = 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.executeHandlers = exports.runDoctor = exports.getSettingsPath = exports.restore = exports.migrate = exports.MetricsCollector = exports.createDefaultManifest = exports.validateManifest = exports.loadManifest = exports.isDaemonRunning = exports.stopDaemon = exports.startDaemon = exports.createServer = void 0;
|
|
4
|
+
exports.LLM_PRICING = exports.DEFAULT_LLM_MAX_TOKENS = exports.DEFAULT_LLM_TIMEOUT = exports.COSTS_FILE = exports.LOG_FILE = exports.METRICS_FILE = 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.validateAuth = exports.generateAuthToken = exports.stopWatcher = exports.startWatcher = exports.resetSessionIsolatedHandlers = exports.executeHandlers = exports.runDoctor = exports.getSettingsPath = exports.restore = exports.migrate = exports.MetricsCollector = exports.createDefaultManifest = exports.validateManifest = exports.loadManifest = exports.isDaemonRunning = exports.stopDaemon = exports.startDaemon = exports.createServer = void 0;
|
|
5
5
|
var server_js_1 = require("./server.js");
|
|
6
6
|
Object.defineProperty(exports, "createServer", { enumerable: true, get: function () { return server_js_1.createServer; } });
|
|
7
7
|
Object.defineProperty(exports, "startDaemon", { enumerable: true, get: function () { return server_js_1.startDaemon; } });
|
|
@@ -21,6 +21,13 @@ var doctor_js_1 = require("./doctor.js");
|
|
|
21
21
|
Object.defineProperty(exports, "runDoctor", { enumerable: true, get: function () { return doctor_js_1.runDoctor; } });
|
|
22
22
|
var handlers_js_1 = require("./handlers.js");
|
|
23
23
|
Object.defineProperty(exports, "executeHandlers", { enumerable: true, get: function () { return handlers_js_1.executeHandlers; } });
|
|
24
|
+
Object.defineProperty(exports, "resetSessionIsolatedHandlers", { enumerable: true, get: function () { return handlers_js_1.resetSessionIsolatedHandlers; } });
|
|
25
|
+
var watcher_js_1 = require("./watcher.js");
|
|
26
|
+
Object.defineProperty(exports, "startWatcher", { enumerable: true, get: function () { return watcher_js_1.startWatcher; } });
|
|
27
|
+
Object.defineProperty(exports, "stopWatcher", { enumerable: true, get: function () { return watcher_js_1.stopWatcher; } });
|
|
28
|
+
var auth_js_1 = require("./auth.js");
|
|
29
|
+
Object.defineProperty(exports, "generateAuthToken", { enumerable: true, get: function () { return auth_js_1.generateAuthToken; } });
|
|
30
|
+
Object.defineProperty(exports, "validateAuth", { enumerable: true, get: function () { return auth_js_1.validateAuth; } });
|
|
24
31
|
var filter_js_1 = require("./filter.js");
|
|
25
32
|
Object.defineProperty(exports, "evaluateFilter", { enumerable: true, get: function () { return filter_js_1.evaluateFilter; } });
|
|
26
33
|
var llm_js_1 = require("./llm.js");
|
package/dist/manifest.d.ts
CHANGED
|
@@ -12,4 +12,4 @@ export declare function validateManifest(manifest: Manifest): void;
|
|
|
12
12
|
/**
|
|
13
13
|
* Create a default commented example manifest.yaml in CONFIG_DIR.
|
|
14
14
|
*/
|
|
15
|
-
export declare function createDefaultManifest(): string;
|
|
15
|
+
export declare function createDefaultManifest(authToken?: string): string;
|
package/dist/manifest.js
CHANGED
|
@@ -102,10 +102,17 @@ function validateManifest(manifest) {
|
|
|
102
102
|
/**
|
|
103
103
|
* Create a default commented example manifest.yaml in CONFIG_DIR.
|
|
104
104
|
*/
|
|
105
|
-
function createDefaultManifest() {
|
|
105
|
+
function createDefaultManifest(authToken) {
|
|
106
106
|
if (!(0, fs_1.existsSync)(constants_js_1.CONFIG_DIR)) {
|
|
107
107
|
(0, fs_1.mkdirSync)(constants_js_1.CONFIG_DIR, { recursive: true });
|
|
108
108
|
}
|
|
109
|
+
const settings = {
|
|
110
|
+
port: 7890,
|
|
111
|
+
logLevel: 'info',
|
|
112
|
+
};
|
|
113
|
+
if (authToken) {
|
|
114
|
+
settings.authToken = authToken;
|
|
115
|
+
}
|
|
109
116
|
const example = {
|
|
110
117
|
handlers: {
|
|
111
118
|
PreToolUse: [
|
|
@@ -118,10 +125,7 @@ function createDefaultManifest() {
|
|
|
118
125
|
},
|
|
119
126
|
],
|
|
120
127
|
},
|
|
121
|
-
settings
|
|
122
|
-
port: 7890,
|
|
123
|
-
logLevel: 'info',
|
|
124
|
-
},
|
|
128
|
+
settings,
|
|
125
129
|
};
|
|
126
130
|
const yamlStr = '# clooks manifest — define your hook handlers here\n' +
|
|
127
131
|
'# Docs: https://github.com/mauribadnights/clooks\n' +
|
package/dist/metrics.d.ts
CHANGED
|
@@ -8,8 +8,15 @@ interface AggregatedStats {
|
|
|
8
8
|
maxDuration: number;
|
|
9
9
|
}
|
|
10
10
|
export declare class MetricsCollector {
|
|
11
|
+
private static readonly MAX_ENTRIES;
|
|
11
12
|
private entries;
|
|
12
|
-
|
|
13
|
+
private ringIndex;
|
|
14
|
+
private totalRecorded;
|
|
15
|
+
private static readonly METRICS_MAX_BYTES;
|
|
16
|
+
private static readonly COSTS_MAX_BYTES;
|
|
17
|
+
/** Rotate a log file if it exceeds maxBytes. Keeps one backup (.1). */
|
|
18
|
+
private rotateIfNeeded;
|
|
19
|
+
/** Record a metric entry in memory (ring buffer) and append to disk. */
|
|
13
20
|
record(entry: MetricEntry): void;
|
|
14
21
|
/** Get aggregated stats per event type. */
|
|
15
22
|
getStats(): AggregatedStats[];
|
package/dist/metrics.js
CHANGED
|
@@ -6,15 +6,43 @@ const fs_1 = require("fs");
|
|
|
6
6
|
const path_1 = require("path");
|
|
7
7
|
const constants_js_1 = require("./constants.js");
|
|
8
8
|
class MetricsCollector {
|
|
9
|
+
static MAX_ENTRIES = 1000;
|
|
9
10
|
entries = [];
|
|
10
|
-
|
|
11
|
+
ringIndex = 0;
|
|
12
|
+
totalRecorded = 0;
|
|
13
|
+
static METRICS_MAX_BYTES = 5 * 1024 * 1024; // 5MB
|
|
14
|
+
static COSTS_MAX_BYTES = 1 * 1024 * 1024; // 1MB
|
|
15
|
+
/** Rotate a log file if it exceeds maxBytes. Keeps one backup (.1). */
|
|
16
|
+
rotateIfNeeded(filePath, maxBytes) {
|
|
17
|
+
try {
|
|
18
|
+
if (!(0, fs_1.existsSync)(filePath))
|
|
19
|
+
return;
|
|
20
|
+
const stat = (0, fs_1.statSync)(filePath);
|
|
21
|
+
if (stat.size >= maxBytes) {
|
|
22
|
+
(0, fs_1.renameSync)(filePath, filePath + '.1');
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
// Non-critical — rotation failure is not fatal
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
/** Record a metric entry in memory (ring buffer) and append to disk. */
|
|
11
30
|
record(entry) {
|
|
12
|
-
|
|
31
|
+
// Ring buffer: overwrite oldest when full
|
|
32
|
+
if (this.entries.length < MetricsCollector.MAX_ENTRIES) {
|
|
33
|
+
this.entries.push(entry);
|
|
34
|
+
}
|
|
35
|
+
else {
|
|
36
|
+
this.entries[this.ringIndex] = entry;
|
|
37
|
+
this.ringIndex = (this.ringIndex + 1) % MetricsCollector.MAX_ENTRIES;
|
|
38
|
+
}
|
|
39
|
+
this.totalRecorded++;
|
|
13
40
|
try {
|
|
14
41
|
const dir = (0, path_1.dirname)(constants_js_1.METRICS_FILE);
|
|
15
42
|
if (!(0, fs_1.existsSync)(dir)) {
|
|
16
43
|
(0, fs_1.mkdirSync)(dir, { recursive: true });
|
|
17
44
|
}
|
|
45
|
+
this.rotateIfNeeded(constants_js_1.METRICS_FILE, MetricsCollector.METRICS_MAX_BYTES);
|
|
18
46
|
(0, fs_1.appendFileSync)(constants_js_1.METRICS_FILE, JSON.stringify(entry) + '\n', 'utf-8');
|
|
19
47
|
}
|
|
20
48
|
catch {
|
|
@@ -46,10 +74,7 @@ class MetricsCollector {
|
|
|
46
74
|
}
|
|
47
75
|
/** Get stats for a specific session. */
|
|
48
76
|
getSessionStats(sessionId) {
|
|
49
|
-
const all = this.loadAll().filter((e) =>
|
|
50
|
-
// MetricEntry doesn't have session_id, but we stored it in the entry if available
|
|
51
|
-
return e.session_id === sessionId;
|
|
52
|
-
});
|
|
77
|
+
const all = this.loadAll().filter((e) => e.session_id === sessionId);
|
|
53
78
|
const byEvent = new Map();
|
|
54
79
|
for (const entry of all) {
|
|
55
80
|
const existing = byEvent.get(entry.event) ?? [];
|
|
@@ -108,6 +133,7 @@ class MetricsCollector {
|
|
|
108
133
|
if (!(0, fs_1.existsSync)(dir)) {
|
|
109
134
|
(0, fs_1.mkdirSync)(dir, { recursive: true });
|
|
110
135
|
}
|
|
136
|
+
this.rotateIfNeeded(constants_js_1.COSTS_FILE, MetricsCollector.COSTS_MAX_BYTES);
|
|
111
137
|
(0, fs_1.appendFileSync)(constants_js_1.COSTS_FILE, JSON.stringify(entry) + '\n', 'utf-8');
|
|
112
138
|
}
|
|
113
139
|
catch {
|
package/dist/migrate.js
CHANGED
|
@@ -129,10 +129,14 @@ function migrate(options) {
|
|
|
129
129
|
}
|
|
130
130
|
// Add HTTP hook
|
|
131
131
|
if (hadHandlers > 0) {
|
|
132
|
-
|
|
132
|
+
const httpHook = {
|
|
133
133
|
type: 'http',
|
|
134
134
|
url: `http://localhost:${constants_js_1.DEFAULT_PORT}/hooks/${eventName}`,
|
|
135
|
-
}
|
|
135
|
+
};
|
|
136
|
+
if (manifest.settings?.authToken) {
|
|
137
|
+
httpHook.headers = { Authorization: `Bearer ${manifest.settings.authToken}` };
|
|
138
|
+
}
|
|
139
|
+
hookEntries.push(httpHook);
|
|
136
140
|
}
|
|
137
141
|
if (hookEntries.length > 0) {
|
|
138
142
|
// Wrap in a single rule group (no matcher — clooks handles dispatch)
|
package/dist/server.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { type Server } from 'http';
|
|
2
|
+
import type { FSWatcher } from 'fs';
|
|
2
3
|
import { MetricsCollector } from './metrics.js';
|
|
3
4
|
import type { Manifest } from './types.js';
|
|
4
5
|
export interface ServerContext {
|
|
@@ -6,6 +7,7 @@ export interface ServerContext {
|
|
|
6
7
|
metrics: MetricsCollector;
|
|
7
8
|
startTime: number;
|
|
8
9
|
manifest: Manifest;
|
|
10
|
+
watcher?: FSWatcher;
|
|
9
11
|
}
|
|
10
12
|
/**
|
|
11
13
|
* Create the HTTP server for hook handling.
|
|
@@ -14,7 +16,9 @@ export declare function createServer(manifest: Manifest, metrics: MetricsCollect
|
|
|
14
16
|
/**
|
|
15
17
|
* Start the daemon: bind the server and write PID file.
|
|
16
18
|
*/
|
|
17
|
-
export declare function startDaemon(manifest: Manifest, metrics: MetricsCollector
|
|
19
|
+
export declare function startDaemon(manifest: Manifest, metrics: MetricsCollector, options?: {
|
|
20
|
+
noWatch?: boolean;
|
|
21
|
+
}): Promise<ServerContext>;
|
|
18
22
|
/**
|
|
19
23
|
* Stop a running daemon by reading PID file and sending SIGTERM.
|
|
20
24
|
*/
|
|
@@ -26,4 +30,6 @@ export declare function isDaemonRunning(): boolean;
|
|
|
26
30
|
/**
|
|
27
31
|
* Start daemon as a detached background process.
|
|
28
32
|
*/
|
|
29
|
-
export declare function startDaemonBackground(
|
|
33
|
+
export declare function startDaemonBackground(options?: {
|
|
34
|
+
noWatch?: boolean;
|
|
35
|
+
}): void;
|
package/dist/server.js
CHANGED
|
@@ -11,7 +11,10 @@ const fs_1 = require("fs");
|
|
|
11
11
|
const child_process_1 = require("child_process");
|
|
12
12
|
const handlers_js_1 = require("./handlers.js");
|
|
13
13
|
const prefetch_js_1 = require("./prefetch.js");
|
|
14
|
+
const watcher_js_1 = require("./watcher.js");
|
|
15
|
+
const auth_js_1 = require("./auth.js");
|
|
14
16
|
const constants_js_1 = require("./constants.js");
|
|
17
|
+
const manifest_js_1 = require("./manifest.js");
|
|
15
18
|
function log(msg) {
|
|
16
19
|
const line = `[${new Date().toISOString()}] ${msg}\n`;
|
|
17
20
|
try {
|
|
@@ -79,21 +82,32 @@ function sendJson(res, status, data) {
|
|
|
79
82
|
*/
|
|
80
83
|
function createServer(manifest, metrics) {
|
|
81
84
|
const startTime = Date.now();
|
|
85
|
+
const ctx = { server: null, metrics, startTime, manifest };
|
|
86
|
+
const authToken = manifest.settings?.authToken ?? '';
|
|
82
87
|
const server = (0, http_1.createServer)(async (req, res) => {
|
|
83
88
|
const url = req.url ?? '/';
|
|
84
89
|
const method = req.method ?? 'GET';
|
|
85
|
-
// Health check endpoint
|
|
90
|
+
// Health check endpoint — no auth required for monitoring
|
|
86
91
|
if (method === 'GET' && url === '/health') {
|
|
87
|
-
const handlerCount = Object.values(manifest.handlers)
|
|
92
|
+
const handlerCount = Object.values(ctx.manifest.handlers)
|
|
88
93
|
.reduce((sum, arr) => sum + (arr?.length ?? 0), 0);
|
|
89
94
|
sendJson(res, 200, {
|
|
90
95
|
status: 'ok',
|
|
91
96
|
uptime: Math.floor((Date.now() - startTime) / 1000),
|
|
92
97
|
handlers_loaded: handlerCount,
|
|
93
|
-
port: manifest.settings?.port ?? constants_js_1.DEFAULT_PORT,
|
|
98
|
+
port: ctx.manifest.settings?.port ?? constants_js_1.DEFAULT_PORT,
|
|
94
99
|
});
|
|
95
100
|
return;
|
|
96
101
|
}
|
|
102
|
+
// Auth check for all POST requests
|
|
103
|
+
if (method === 'POST' && authToken) {
|
|
104
|
+
const authHeader = req.headers['authorization'];
|
|
105
|
+
if (!(0, auth_js_1.validateAuth)(authHeader, authToken)) {
|
|
106
|
+
log(`Auth failure from ${req.socket.remoteAddress}`);
|
|
107
|
+
sendJson(res, 401, { error: 'Unauthorized' });
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
97
111
|
// Hook endpoint: POST /hooks/:eventName
|
|
98
112
|
const hookMatch = url.match(/^\/hooks\/([A-Za-z]+)$/);
|
|
99
113
|
if (method === 'POST' && hookMatch) {
|
|
@@ -103,7 +117,14 @@ function createServer(manifest, metrics) {
|
|
|
103
117
|
return;
|
|
104
118
|
}
|
|
105
119
|
const event = eventName;
|
|
106
|
-
|
|
120
|
+
// On SessionStart, reset session-isolated handlers across ALL events
|
|
121
|
+
if (event === 'SessionStart') {
|
|
122
|
+
const allHandlers = Object.values(ctx.manifest.handlers)
|
|
123
|
+
.flat()
|
|
124
|
+
.filter((h) => h != null);
|
|
125
|
+
(0, handlers_js_1.resetSessionIsolatedHandlers)(allHandlers);
|
|
126
|
+
}
|
|
127
|
+
const handlers = ctx.manifest.handlers[event] ?? [];
|
|
107
128
|
if (handlers.length === 0) {
|
|
108
129
|
sendJson(res, 200, {});
|
|
109
130
|
return;
|
|
@@ -122,8 +143,8 @@ function createServer(manifest, metrics) {
|
|
|
122
143
|
try {
|
|
123
144
|
// Pre-fetch shared context if configured
|
|
124
145
|
let context;
|
|
125
|
-
if (manifest.prefetch && manifest.prefetch.length > 0) {
|
|
126
|
-
context = await (0, prefetch_js_1.prefetchContext)(manifest.prefetch, input);
|
|
146
|
+
if (ctx.manifest.prefetch && ctx.manifest.prefetch.length > 0) {
|
|
147
|
+
context = await (0, prefetch_js_1.prefetchContext)(ctx.manifest.prefetch, input);
|
|
127
148
|
}
|
|
128
149
|
const results = await (0, handlers_js_1.executeHandlers)(event, input, handlers, context);
|
|
129
150
|
// Record metrics and costs
|
|
@@ -138,6 +159,7 @@ function createServer(manifest, metrics) {
|
|
|
138
159
|
filtered: result.filtered,
|
|
139
160
|
usage: result.usage,
|
|
140
161
|
cost_usd: result.cost_usd,
|
|
162
|
+
session_id: input.session_id,
|
|
141
163
|
});
|
|
142
164
|
// Track cost for LLM handlers
|
|
143
165
|
if (result.usage && result.cost_usd !== undefined && result.cost_usd > 0) {
|
|
@@ -170,12 +192,13 @@ function createServer(manifest, metrics) {
|
|
|
170
192
|
// 404 for everything else
|
|
171
193
|
sendJson(res, 404, { error: 'Not found' });
|
|
172
194
|
});
|
|
173
|
-
|
|
195
|
+
ctx.server = server;
|
|
196
|
+
return ctx;
|
|
174
197
|
}
|
|
175
198
|
/**
|
|
176
199
|
* Start the daemon: bind the server and write PID file.
|
|
177
200
|
*/
|
|
178
|
-
function startDaemon(manifest, metrics) {
|
|
201
|
+
function startDaemon(manifest, metrics, options) {
|
|
179
202
|
return new Promise((resolve, reject) => {
|
|
180
203
|
const ctx = createServer(manifest, metrics);
|
|
181
204
|
const port = manifest.settings?.port ?? constants_js_1.DEFAULT_PORT;
|
|
@@ -195,12 +218,28 @@ function startDaemon(manifest, metrics) {
|
|
|
195
218
|
(0, fs_1.mkdirSync)(constants_js_1.CONFIG_DIR, { recursive: true });
|
|
196
219
|
}
|
|
197
220
|
(0, fs_1.writeFileSync)(constants_js_1.PID_FILE, String(process.pid), 'utf-8');
|
|
221
|
+
// Start file watcher unless disabled
|
|
222
|
+
if (!options?.noWatch) {
|
|
223
|
+
ctx.watcher = (0, watcher_js_1.startWatcher)(constants_js_1.MANIFEST_PATH, () => {
|
|
224
|
+
try {
|
|
225
|
+
const newManifest = (0, manifest_js_1.loadManifest)();
|
|
226
|
+
ctx.manifest = newManifest;
|
|
227
|
+
log('Manifest reloaded successfully');
|
|
228
|
+
}
|
|
229
|
+
catch (err) {
|
|
230
|
+
log(`Manifest reload failed (keeping previous config): ${err instanceof Error ? err.message : err}`);
|
|
231
|
+
}
|
|
232
|
+
}, (err) => {
|
|
233
|
+
log(`Watcher error: ${err.message}`);
|
|
234
|
+
}) ?? undefined;
|
|
235
|
+
}
|
|
198
236
|
log(`Daemon started on 127.0.0.1:${port} (pid ${process.pid})`);
|
|
199
237
|
resolve(ctx);
|
|
200
238
|
});
|
|
201
239
|
// Graceful shutdown
|
|
202
240
|
const shutdown = () => {
|
|
203
241
|
log('Shutting down...');
|
|
242
|
+
(0, watcher_js_1.stopWatcher)(ctx.watcher ?? null);
|
|
204
243
|
ctx.server.close(() => {
|
|
205
244
|
try {
|
|
206
245
|
if ((0, fs_1.existsSync)(constants_js_1.PID_FILE))
|
|
@@ -283,8 +322,12 @@ function isDaemonRunning() {
|
|
|
283
322
|
/**
|
|
284
323
|
* Start daemon as a detached background process.
|
|
285
324
|
*/
|
|
286
|
-
function startDaemonBackground() {
|
|
287
|
-
const
|
|
325
|
+
function startDaemonBackground(options) {
|
|
326
|
+
const args = [process.argv[1], 'start', '--foreground'];
|
|
327
|
+
if (options?.noWatch) {
|
|
328
|
+
args.push('--no-watch');
|
|
329
|
+
}
|
|
330
|
+
const child = (0, child_process_1.spawn)(process.execPath, args, {
|
|
288
331
|
detached: true,
|
|
289
332
|
stdio: 'ignore',
|
|
290
333
|
});
|
package/dist/types.d.ts
CHANGED
|
@@ -30,6 +30,7 @@ export interface LLMHandlerConfig {
|
|
|
30
30
|
filter?: string;
|
|
31
31
|
timeout?: number;
|
|
32
32
|
enabled?: boolean;
|
|
33
|
+
sessionIsolation?: boolean;
|
|
33
34
|
}
|
|
34
35
|
/** Script handler config */
|
|
35
36
|
export interface ScriptHandlerConfig {
|
|
@@ -39,6 +40,7 @@ export interface ScriptHandlerConfig {
|
|
|
39
40
|
filter?: string;
|
|
40
41
|
timeout?: number;
|
|
41
42
|
enabled?: boolean;
|
|
43
|
+
sessionIsolation?: boolean;
|
|
42
44
|
}
|
|
43
45
|
/** Inline handler config */
|
|
44
46
|
export interface InlineHandlerConfig {
|
|
@@ -48,6 +50,7 @@ export interface InlineHandlerConfig {
|
|
|
48
50
|
filter?: string;
|
|
49
51
|
timeout?: number;
|
|
50
52
|
enabled?: boolean;
|
|
53
|
+
sessionIsolation?: boolean;
|
|
51
54
|
}
|
|
52
55
|
/** Union of all handler configs */
|
|
53
56
|
export type HandlerConfig = ScriptHandlerConfig | InlineHandlerConfig | LLMHandlerConfig;
|
|
@@ -67,6 +70,7 @@ export interface Manifest {
|
|
|
67
70
|
port?: number;
|
|
68
71
|
logLevel?: 'debug' | 'info' | 'warn' | 'error';
|
|
69
72
|
anthropicApiKey?: string;
|
|
73
|
+
authToken?: string;
|
|
70
74
|
};
|
|
71
75
|
}
|
|
72
76
|
/** Token usage from API response */
|
|
@@ -95,6 +99,7 @@ export interface MetricEntry {
|
|
|
95
99
|
filtered?: boolean;
|
|
96
100
|
usage?: TokenUsage;
|
|
97
101
|
cost_usd?: number;
|
|
102
|
+
session_id?: string;
|
|
98
103
|
}
|
|
99
104
|
/** Extended handler result with cost info */
|
|
100
105
|
export interface HandlerResult {
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { type FSWatcher } from 'fs';
|
|
2
|
+
type ReloadCallback = () => void;
|
|
3
|
+
type ErrorCallback = (err: Error) => void;
|
|
4
|
+
export interface WatcherOptions {
|
|
5
|
+
onReload: ReloadCallback;
|
|
6
|
+
onError?: ErrorCallback;
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* Watch manifest.yaml for changes.
|
|
10
|
+
* Calls onReload when changes detected (debounced).
|
|
11
|
+
* If the manifest file doesn't exist, watches the config directory for its creation.
|
|
12
|
+
*/
|
|
13
|
+
export declare function startWatcher(manifestPath: string, onReload: ReloadCallback, onError?: ErrorCallback): FSWatcher | null;
|
|
14
|
+
/**
|
|
15
|
+
* Stop watching for file changes.
|
|
16
|
+
*/
|
|
17
|
+
export declare function stopWatcher(watcher: FSWatcher | null): void;
|
|
18
|
+
export {};
|
package/dist/watcher.js
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// clooks file watcher — watch manifest.yaml for changes and hot-reload
|
|
3
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
4
|
+
exports.startWatcher = startWatcher;
|
|
5
|
+
exports.stopWatcher = stopWatcher;
|
|
6
|
+
const fs_1 = require("fs");
|
|
7
|
+
const path_1 = require("path");
|
|
8
|
+
const DEBOUNCE_MS = 500;
|
|
9
|
+
/**
|
|
10
|
+
* Watch manifest.yaml for changes.
|
|
11
|
+
* Calls onReload when changes detected (debounced).
|
|
12
|
+
* If the manifest file doesn't exist, watches the config directory for its creation.
|
|
13
|
+
*/
|
|
14
|
+
function startWatcher(manifestPath, onReload, onError) {
|
|
15
|
+
let lastChange = 0;
|
|
16
|
+
// If manifest exists, watch it directly
|
|
17
|
+
if ((0, fs_1.existsSync)(manifestPath)) {
|
|
18
|
+
return watchFile(manifestPath, onReload, onError);
|
|
19
|
+
}
|
|
20
|
+
// Manifest doesn't exist — watch the config directory for its creation
|
|
21
|
+
const configDir = (0, path_1.dirname)(manifestPath);
|
|
22
|
+
if (!(0, fs_1.existsSync)(configDir)) {
|
|
23
|
+
try {
|
|
24
|
+
(0, fs_1.mkdirSync)(configDir, { recursive: true });
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
// Can't create config dir — give up
|
|
28
|
+
if (onError)
|
|
29
|
+
onError(new Error(`Cannot create config directory: ${configDir}`));
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
const baseName = manifestPath.split('/').pop() ?? manifestPath.split('\\').pop() ?? '';
|
|
34
|
+
let dirWatcher = null;
|
|
35
|
+
try {
|
|
36
|
+
dirWatcher = (0, fs_1.watch)(configDir, (eventType, filename) => {
|
|
37
|
+
if (filename !== baseName)
|
|
38
|
+
return;
|
|
39
|
+
if (!(0, fs_1.existsSync)(manifestPath))
|
|
40
|
+
return;
|
|
41
|
+
const now = Date.now();
|
|
42
|
+
if (now - lastChange < DEBOUNCE_MS)
|
|
43
|
+
return;
|
|
44
|
+
lastChange = now;
|
|
45
|
+
// Manifest appeared — close directory watcher, start file watcher
|
|
46
|
+
try {
|
|
47
|
+
dirWatcher?.close();
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
// ignore
|
|
51
|
+
}
|
|
52
|
+
// Switch to watching the file directly
|
|
53
|
+
const fileWatcher = watchFile(manifestPath, onReload, onError);
|
|
54
|
+
if (fileWatcher) {
|
|
55
|
+
// Copy the ref so stopWatcher can close it (caller still holds the dir watcher ref)
|
|
56
|
+
// We can't replace the caller's reference, but the dir watcher is closed.
|
|
57
|
+
// The onReload fires so the caller picks up the new manifest.
|
|
58
|
+
}
|
|
59
|
+
try {
|
|
60
|
+
onReload();
|
|
61
|
+
}
|
|
62
|
+
catch (err) {
|
|
63
|
+
if (onError)
|
|
64
|
+
onError(err instanceof Error ? err : new Error(String(err)));
|
|
65
|
+
}
|
|
66
|
+
});
|
|
67
|
+
dirWatcher.on('error', (err) => {
|
|
68
|
+
if (onError)
|
|
69
|
+
onError(err);
|
|
70
|
+
});
|
|
71
|
+
return dirWatcher;
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
if (onError)
|
|
75
|
+
onError(new Error(`Failed to watch config directory: ${configDir}`));
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
/** Watch an existing file directly. */
|
|
80
|
+
function watchFile(filePath, onReload, onError) {
|
|
81
|
+
let lastChange = 0;
|
|
82
|
+
try {
|
|
83
|
+
const watcher = (0, fs_1.watch)(filePath, (eventType) => {
|
|
84
|
+
if (eventType !== 'change' && eventType !== 'rename')
|
|
85
|
+
return;
|
|
86
|
+
const now = Date.now();
|
|
87
|
+
if (now - lastChange < DEBOUNCE_MS)
|
|
88
|
+
return;
|
|
89
|
+
lastChange = now;
|
|
90
|
+
try {
|
|
91
|
+
onReload();
|
|
92
|
+
}
|
|
93
|
+
catch (err) {
|
|
94
|
+
if (onError)
|
|
95
|
+
onError(err instanceof Error ? err : new Error(String(err)));
|
|
96
|
+
}
|
|
97
|
+
});
|
|
98
|
+
watcher.on('error', (err) => {
|
|
99
|
+
if (onError)
|
|
100
|
+
onError(err);
|
|
101
|
+
});
|
|
102
|
+
return watcher;
|
|
103
|
+
}
|
|
104
|
+
catch {
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Stop watching for file changes.
|
|
110
|
+
*/
|
|
111
|
+
function stopWatcher(watcher) {
|
|
112
|
+
if (watcher) {
|
|
113
|
+
try {
|
|
114
|
+
watcher.close();
|
|
115
|
+
}
|
|
116
|
+
catch {
|
|
117
|
+
// Ignore close errors
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
package/package.json
CHANGED