@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 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
- Requires `@anthropic-ai/sdk` as a peer dependency and `ANTHROPIC_API_KEY` env var.
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. Supports OR (`|`) and NOT (`!`) operators. Matching is case-insensitive against the full hook input JSON.
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. Pricing is built-in for Haiku 4.5, Sonnet 4.6, and Opus 4.6.
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
- Cost data also appears in `clooks stats` when LLM handlers have been used.
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
@@ -0,0 +1,4 @@
1
+ /** Generate a random auth token (32 hex chars). */
2
+ export declare function generateAuthToken(): string;
3
+ /** Validate an auth token from request headers. */
4
+ export declare function validateAuth(authHeader: string | undefined, expectedToken: string): boolean;
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.0');
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 path = (0, manifest_js_1.createDefaultManifest)();
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
+ }
@@ -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");
@@ -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
- /** Record a metric entry in memory and append to disk. */
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
- /** Record a metric entry in memory and append to disk. */
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
- this.entries.push(entry);
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
- hookEntries.push({
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): Promise<ServerContext>;
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(): void;
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
- const handlers = manifest.handlers[event] ?? [];
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
- return { server, metrics, startTime, manifest };
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 child = (0, child_process_1.spawn)(process.execPath, [process.argv[1], 'start', '--foreground'], {
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 {};
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mauribadnights/clooks",
3
- "version": "0.2.0",
3
+ "version": "0.2.2",
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"