@mauribadnights/clooks 0.1.0 → 0.2.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/README.md CHANGED
@@ -1,50 +1,61 @@
1
1
  # clooks
2
2
 
3
- Persistent hook runtime for Claude Code eliminate cold starts, get observability.
3
+ **Persistent hook runtime for Claude Code.** Eliminate cold starts. Get observability.
4
4
 
5
- ## The Problem
5
+ [![npm version](https://img.shields.io/npm/v/@mauribadnights/clooks.svg)](https://www.npmjs.com/package/@mauribadnights/clooks)
6
+ [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](https://opensource.org/licenses/MIT)
6
7
 
7
- Claude Code spawns a fresh process for every hook invocation. Power users with multiple hooks (safety guards, context injectors, custom scripts) accumulate **100+ process spawns per session**. Each Node.js cold start costs 50-100ms. That's 6-11 seconds of pure overhead per session — and you get zero visibility into what your hooks are doing.
8
+ ## Performance
8
9
 
9
- ## How clooks Fixes It
10
+ | Metric | Without clooks | With clooks | Improvement |
11
+ |--------|---------------|-------------|-------------|
12
+ | Single hook invocation | ~34.6ms | ~0.31ms | **112x faster** |
13
+ | Full session (120 invocations) | ~3,986ms | ~23ms | **99% time saved** |
14
+ | 5 parallel handlers | ~424ms | ~96ms | **4.4x faster** |
10
15
 
11
- One persistent HTTP server handles all your hooks. Claude Code's [built-in HTTP hook support](https://docs.anthropic.com/en/docs/claude-code/hooks) POSTs to `localhost:7890` instead of spawning processes. **One process instead of hundreds.**
16
+ > Benchmarked on Apple Silicon (M-series), Node v24.4.1. Run `npm run bench` to reproduce.
12
17
 
13
- ```
14
- ┌─────────────┐ POST /hooks/PreToolUse ┌──────────────────┐
15
- │ │ ──────────────────────────────► │ │
16
- │ Claude Code │ POST /hooks/Stop │ clooks daemon │
17
- │ │ ──────────────────────────────► │ (persistent) │
18
- │ │ POST /hooks/... │ │
19
- │ │ ──────────────────────────────► │ ┌────────────┐ │
20
- │ │ │ │ handler A │ │
21
- │ │ ◄────────────── JSON ───────── │ │ handler B │ │
22
- │ │ │ │ handler C │ │
23
- └─────────────┘ │ └────────────┘ │
24
- │ metrics.jsonl │
25
- └──────────────────┘
26
- ```
18
+ ## The Problem
19
+
20
+ Claude Code spawns a fresh process for every hook invocation. Each Node.js cold start costs 30-40ms. Power users with multiple hooks accumulate 100+ process spawns per session -- that is 4-6 seconds of pure overhead, with zero visibility into what your hooks are doing or how they fail.
27
21
 
28
22
  ## Quick Start
29
23
 
30
24
  ```bash
31
- npm install -g clooks
25
+ npm install -g @mauribadnights/clooks
26
+
27
+ # Option A: Migrate existing hooks automatically
28
+ clooks migrate # converts command hooks to HTTP hooks + manifest
29
+ clooks start # starts the daemon
30
+
31
+ # Option B: Start fresh
32
+ clooks init # creates ~/.clooks/manifest.yaml
33
+ clooks start
34
+ ```
32
35
 
33
- # If you have existing hooks in settings.json:
34
- clooks migrate # converts command hooks → HTTP hooks + manifest
36
+ That is it. Claude Code will now POST to your daemon instead of spawning processes.
35
37
 
36
- # Or start fresh:
37
- clooks init # creates ~/.clooks/manifest.yaml
38
+ ## How It Works
38
39
 
39
- clooks start # starts the daemon
40
40
  ```
41
+ Claude Code clooks daemon (localhost:7890)
42
+ | |
43
+ |-- SessionStart ------> POST /hooks/SessionStart ------> [handler1, handler2]
44
+ |-- UserPromptSubmit --> POST /hooks/UserPromptSubmit --> [handler3]
45
+ |-- PreToolUse (x50) --> POST /hooks/PreToolUse --------> [handler4, handler5]
46
+ |-- Stop --------------> POST /hooks/Stop ---------------> [handler6]
47
+ | |
48
+ |<-------------- JSON responses ---------|
49
+ ```
50
+
51
+ One persistent process. Zero cold starts. Full observability.
41
52
 
42
- That's it. Claude Code will now POST to your daemon instead of spawning processes.
53
+ After `clooks migrate`, your `settings.json` is rewritten so that `SessionStart` runs a single command hook (`clooks ensure-running`) and all other hooks become HTTP POSTs. The daemon loads handlers from `~/.clooks/manifest.yaml` and dispatches them in parallel per event. Handlers that fail 3 times consecutively are auto-disabled to prevent cascading failures.
43
54
 
44
55
  ## Commands
45
56
 
46
57
  | Command | Description |
47
- |---|---|
58
+ |---------|-------------|
48
59
  | `clooks start` | Start the daemon (background by default, `--foreground` for debug) |
49
60
  | `clooks stop` | Stop the daemon |
50
61
  | `clooks status` | Show daemon status, uptime, and handler count |
@@ -53,7 +64,7 @@ That's it. Claude Code will now POST to your daemon instead of spawning processe
53
64
  | `clooks restore` | Restore original `settings.json` from backup |
54
65
  | `clooks doctor` | Run diagnostic health checks |
55
66
  | `clooks init` | Create default config directory and example manifest |
56
- | `clooks ensure-running` | Start daemon if not running (used by SessionStart hook) |
67
+ | `clooks ensure-running` | Start daemon if not already running (used by SessionStart hook) |
57
68
 
58
69
  ## Manifest Format
59
70
 
@@ -63,13 +74,13 @@ Handlers are defined in `~/.clooks/manifest.yaml`:
63
74
  handlers:
64
75
  PreToolUse:
65
76
  - id: safety-guard
66
- type: script
77
+ type: script # runs a shell command
67
78
  command: node ~/hooks/guard.js
68
79
  timeout: 3000
69
80
  enabled: true
70
81
 
71
82
  - id: context-injector
72
- type: inline
83
+ type: inline # imports a JS module directly (no subprocess)
73
84
  module: ~/hooks/context.js
74
85
  timeout: 2000
75
86
 
@@ -84,10 +95,12 @@ settings:
84
95
  ```
85
96
 
86
97
  **Handler types:**
87
- - `script` runs a shell command, pipes hook JSON to stdin, reads JSON from stdout
88
- - `inline` imports a JS module and calls its default export (faster, no subprocess)
98
+ - `script` -- runs a shell command, pipes hook JSON to stdin, reads JSON from stdout.
99
+ - `inline` -- imports a JS module and calls its default export. Faster; no subprocess overhead.
100
+
101
+ ## Observability
89
102
 
90
- ## Stats
103
+ ### Execution Metrics
91
104
 
92
105
  ```
93
106
  $ clooks stats
@@ -101,53 +114,130 @@ UserPromptSubmit 12 1 1.8 0.9 4.2
101
114
  Total fires: 71 | Total errors: 1 | Spawns saved: ~71
102
115
  ```
103
116
 
104
- ## How It Works with Claude Code
105
-
106
- After `clooks migrate`, your `settings.json` looks like this:
107
-
108
- ```json
109
- {
110
- "hooks": {
111
- "SessionStart": [{ "hooks": [
112
- { "type": "command", "command": "clooks ensure-running" }
113
- ]}],
114
- "PreToolUse": [{ "hooks": [
115
- { "type": "http", "url": "http://localhost:7890/hooks/PreToolUse" }
116
- ]}]
117
- }
118
- }
119
- ```
120
-
121
- The `SessionStart` command hook ensures the daemon is running (fast no-op if already up). All other hooks are HTTP POSTs — no process spawning, no cold starts.
122
-
123
- Handlers that fail 3 times consecutively are auto-disabled to prevent cascading failures.
124
-
125
- ## Configuration
117
+ ### Diagnostics
126
118
 
127
- | Item | Default |
128
- |---|---|
129
- | Port | `7890` |
130
- | Config directory | `~/.clooks/` |
131
- | Manifest | `~/.clooks/manifest.yaml` |
132
- | Metrics | `~/.clooks/metrics.jsonl` |
133
- | Daemon log | `~/.clooks/daemon.log` |
134
- | PID file | `~/.clooks/daemon.pid` |
119
+ ```
120
+ $ clooks doctor
121
+
122
+ [pass] Daemon is running (PID 44721, uptime 2h 13m)
123
+ [pass] Port 7890 is responding
124
+ [pass] Manifest loaded: 4 handlers across 3 events
125
+ [pass] settings.json has HTTP hooks pointing to clooks
126
+ [pass] No handlers in circuit-breaker state
127
+ [warn] 1 handler error in last 24h (session-logger on Stop)
128
+ ```
135
129
 
136
130
  ## Comparison
137
131
 
138
- | | Without clooks | With clooks |
132
+ | | Without clooks | With clooks |
139
133
  |---|---|---|
140
134
  | **Process model** | New process per hook invocation | One persistent HTTP server |
141
- | **Cold start** | 50-100ms per invocation | 0ms (already running) |
142
- | **State** | Stateless each invocation starts fresh | Persistent share state across invocations |
135
+ | **Cold start overhead** | 30-40ms per invocation | 0ms (already running) |
136
+ | **State management** | Stateless -- each invocation starts fresh | Persistent -- share state across invocations |
143
137
  | **Observability** | None | Metrics, stats, logs, doctor diagnostics |
144
- | **Failure handling** | Silent | Auto-disable after 3 consecutive failures |
138
+ | **Error handling** | Silent failures | Auto-disable after 3 consecutive failures |
145
139
 
146
- ## License
140
+ ## Configuration Reference
147
141
 
148
- MIT
142
+ | Option | Default | Description |
143
+ |--------|---------|-------------|
144
+ | Port | `7890` | HTTP server port |
145
+ | Config directory | `~/.clooks/` | Root configuration directory |
146
+ | Manifest | `~/.clooks/manifest.yaml` | Handler definitions |
147
+ | Metrics | `~/.clooks/metrics.jsonl` | Execution metrics log |
148
+ | Daemon log | `~/.clooks/daemon.log` | Server output log |
149
+ | PID file | `~/.clooks/daemon.pid` | Process ID file |
150
+
151
+ ## v0.2 Features
152
+
153
+ ### LLM Handlers
154
+
155
+ Call the Anthropic Messages API directly from your manifest. Handlers with the same `batchGroup` are combined into a single API call, saving tokens and latency.
156
+
157
+ ```yaml
158
+ handlers:
159
+ PreToolUse:
160
+ - id: code-review
161
+ type: llm
162
+ model: claude-haiku-4-5
163
+ prompt: "Review this tool call for $TOOL_NAME with args: $ARGUMENTS"
164
+ batchGroup: analysis
165
+ timeout: 15000
166
+
167
+ - id: security-check
168
+ type: llm
169
+ model: claude-haiku-4-5
170
+ prompt: "Check for security issues in $TOOL_NAME call: $ARGUMENTS"
171
+ batchGroup: analysis # batched with code-review into one API call
172
+ ```
173
+
174
+ Requires `@anthropic-ai/sdk` as a peer dependency and `ANTHROPIC_API_KEY` env var.
175
+
176
+ ### Intelligent Filtering
177
+
178
+ Skip handlers based on keywords. Supports OR (`|`) and NOT (`!`) operators. Matching is case-insensitive against the full hook input JSON.
179
+
180
+ ```yaml
181
+ handlers:
182
+ PreToolUse:
183
+ - id: bash-guard
184
+ type: script
185
+ command: node ~/hooks/guard.js
186
+ filter: "Bash|Execute|!Read" # runs for Bash/Execute, never for Read
187
+ ```
188
+
189
+ ### Shared Context Pre-fetch
190
+
191
+ Fetch transcript, git status, or git diff once per hook event and share across all handlers. Avoids redundant I/O when multiple handlers need the same data. Use `$VARIABLE` interpolation in LLM prompts.
192
+
193
+ ```yaml
194
+ prefetch:
195
+ - transcript
196
+ - git_status
197
+ - git_diff
198
+
199
+ handlers:
200
+ Stop:
201
+ - id: session-summary
202
+ type: llm
203
+ model: claude-haiku-4-5
204
+ prompt: "Summarize this session:\n$TRANSCRIPT\n\nGit changes:\n$GIT_DIFF"
205
+ ```
206
+
207
+ ### Cost Tracking
208
+
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.
210
+
211
+ ```
212
+ $ clooks costs
213
+
214
+ LLM Cost Summary
215
+ Total: $0.0142 (4,280 tokens)
216
+
217
+ By Model:
218
+ claude-haiku-4-5 $0.0142 (4,280 tokens)
219
+
220
+ By Handler:
221
+ code-review $0.0089 (12 calls, avg 178 tokens)
222
+ security-check $0.0053 (12 calls, avg 178 tokens)
223
+ ```
224
+
225
+ Cost data also appears in `clooks stats` when LLM handlers have been used.
149
226
 
150
227
  ## Roadmap
151
228
 
152
- - **v0.2:** Matcher support in manifest, LLM call batching, token cost tracking
153
229
  - **v0.3:** Plugin ecosystem, dependency resolution between handlers
230
+ - **v0.4:** Visual dashboard for hook management and metrics
231
+
232
+ ## Contributing
233
+
234
+ Issues and pull requests are welcome. Run the test suite before submitting:
235
+
236
+ ```bash
237
+ npm test
238
+ npm run bench
239
+ ```
240
+
241
+ ## License
242
+
243
+ MIT
package/dist/cli.js CHANGED
@@ -14,7 +14,7 @@ const program = new commander_1.Command();
14
14
  program
15
15
  .name('clooks')
16
16
  .description('Persistent hook runtime for Claude Code')
17
- .version('0.1.0');
17
+ .version('0.2.0');
18
18
  // --- start ---
19
19
  program
20
20
  .command('start')
@@ -113,6 +113,20 @@ program
113
113
  .action(() => {
114
114
  const metrics = new metrics_js_1.MetricsCollector();
115
115
  console.log(metrics.formatStatsTable());
116
+ // Append cost summary if LLM data exists
117
+ const costStats = metrics.getCostStats();
118
+ if (costStats.totalCost > 0) {
119
+ console.log('');
120
+ console.log(metrics.formatCostTable());
121
+ }
122
+ });
123
+ // --- costs ---
124
+ program
125
+ .command('costs')
126
+ .description('Show LLM cost breakdown')
127
+ .action(() => {
128
+ const metrics = new metrics_js_1.MetricsCollector();
129
+ console.log(metrics.formatCostTable());
116
130
  });
117
131
  // --- migrate ---
118
132
  program
@@ -7,4 +7,12 @@ export declare const LOG_FILE: string;
7
7
  export declare const SETTINGS_BACKUP: string;
8
8
  export declare const MAX_CONSECUTIVE_FAILURES = 3;
9
9
  export declare const DEFAULT_HANDLER_TIMEOUT = 5000;
10
+ export declare const COSTS_FILE: string;
11
+ export declare const DEFAULT_LLM_TIMEOUT = 30000;
12
+ export declare const DEFAULT_LLM_MAX_TOKENS = 1024;
13
+ /** Pricing per million tokens (USD) — as of March 2026 */
14
+ export declare const LLM_PRICING: Record<string, {
15
+ input: number;
16
+ output: number;
17
+ }>;
10
18
  export declare const HOOK_EVENTS: string[];
package/dist/constants.js CHANGED
@@ -1,7 +1,7 @@
1
1
  "use strict";
2
2
  // clooks constants
3
3
  Object.defineProperty(exports, "__esModule", { value: true });
4
- exports.HOOK_EVENTS = exports.DEFAULT_HANDLER_TIMEOUT = exports.MAX_CONSECUTIVE_FAILURES = exports.SETTINGS_BACKUP = exports.LOG_FILE = exports.METRICS_FILE = exports.PID_FILE = exports.MANIFEST_PATH = exports.CONFIG_DIR = exports.DEFAULT_PORT = void 0;
4
+ exports.HOOK_EVENTS = exports.LLM_PRICING = exports.DEFAULT_LLM_MAX_TOKENS = exports.DEFAULT_LLM_TIMEOUT = exports.COSTS_FILE = exports.DEFAULT_HANDLER_TIMEOUT = exports.MAX_CONSECUTIVE_FAILURES = exports.SETTINGS_BACKUP = exports.LOG_FILE = exports.METRICS_FILE = exports.PID_FILE = exports.MANIFEST_PATH = exports.CONFIG_DIR = exports.DEFAULT_PORT = void 0;
5
5
  const os_1 = require("os");
6
6
  const path_1 = require("path");
7
7
  exports.DEFAULT_PORT = 7890;
@@ -13,6 +13,15 @@ exports.LOG_FILE = (0, path_1.join)(exports.CONFIG_DIR, 'daemon.log');
13
13
  exports.SETTINGS_BACKUP = (0, path_1.join)(exports.CONFIG_DIR, 'settings.backup.json');
14
14
  exports.MAX_CONSECUTIVE_FAILURES = 3;
15
15
  exports.DEFAULT_HANDLER_TIMEOUT = 5000; // ms
16
+ exports.COSTS_FILE = (0, path_1.join)(exports.CONFIG_DIR, 'costs.jsonl');
17
+ exports.DEFAULT_LLM_TIMEOUT = 30000; // ms
18
+ exports.DEFAULT_LLM_MAX_TOKENS = 1024;
19
+ /** Pricing per million tokens (USD) — as of March 2026 */
20
+ exports.LLM_PRICING = {
21
+ 'claude-haiku-4-5': { input: 0.80, output: 4.00 },
22
+ 'claude-sonnet-4-6': { input: 3.00, output: 15.00 },
23
+ 'claude-opus-4-6': { input: 15.00, output: 75.00 },
24
+ };
16
25
  exports.HOOK_EVENTS = [
17
26
  'SessionStart',
18
27
  'UserPromptSubmit',
package/dist/doctor.js CHANGED
@@ -93,7 +93,9 @@ function checkHandlerCommands() {
93
93
  const manifest = (0, manifest_js_1.loadManifest)();
94
94
  for (const [_event, handlers] of Object.entries(manifest.handlers)) {
95
95
  for (const handler of handlers) {
96
- if (handler.type !== 'script' || !handler.command)
96
+ if (handler.type !== 'script')
97
+ continue;
98
+ if (!handler.command)
97
99
  continue;
98
100
  // Extract the base command (first word)
99
101
  const baseCmd = handler.command.split(/\s+/)[0];
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Evaluate a keyword filter against input text.
3
+ *
4
+ * Filter syntax:
5
+ * "word1|word2|word3" — match if ANY keyword found (OR)
6
+ * "!word" — exclude if keyword found (NOT)
7
+ * Mixed: "word1|word2|!word3" — match word1 OR word2, but NOT if word3 present
8
+ *
9
+ * Returns true if handler should execute, false if filtered out.
10
+ */
11
+ export declare function evaluateFilter(filter: string, input: string): boolean;
package/dist/filter.js ADDED
@@ -0,0 +1,42 @@
1
+ "use strict";
2
+ // clooks filter engine — keyword-based handler filtering
3
+ Object.defineProperty(exports, "__esModule", { value: true });
4
+ exports.evaluateFilter = evaluateFilter;
5
+ /**
6
+ * Evaluate a keyword filter against input text.
7
+ *
8
+ * Filter syntax:
9
+ * "word1|word2|word3" — match if ANY keyword found (OR)
10
+ * "!word" — exclude if keyword found (NOT)
11
+ * Mixed: "word1|word2|!word3" — match word1 OR word2, but NOT if word3 present
12
+ *
13
+ * Returns true if handler should execute, false if filtered out.
14
+ */
15
+ function evaluateFilter(filter, input) {
16
+ const terms = filter.split('|').map((t) => t.trim()).filter(Boolean);
17
+ if (terms.length === 0)
18
+ return true;
19
+ const positive = [];
20
+ const negative = [];
21
+ for (const term of terms) {
22
+ if (term.startsWith('!')) {
23
+ negative.push(term.slice(1));
24
+ }
25
+ else {
26
+ positive.push(term);
27
+ }
28
+ }
29
+ const lowerInput = input.toLowerCase();
30
+ // If ANY negative term is found → blocked
31
+ for (const neg of negative) {
32
+ if (lowerInput.includes(neg.toLowerCase())) {
33
+ return false;
34
+ }
35
+ }
36
+ // If there are positive terms, at least ONE must match
37
+ if (positive.length > 0) {
38
+ return positive.some((pos) => lowerInput.includes(pos.toLowerCase()));
39
+ }
40
+ // Only negative terms and none matched → allow
41
+ return true;
42
+ }
@@ -1,4 +1,4 @@
1
- import type { HandlerConfig, HandlerResult, HandlerState, HookEvent, HookInput } from './types.js';
1
+ import type { HandlerConfig, HandlerResult, HandlerState, HookEvent, HookInput, PrefetchContext } from './types.js';
2
2
  /** Reset all handler states (useful for testing) */
3
3
  export declare function resetHandlerStates(): void;
4
4
  /** Get a copy of the handler states map */
@@ -6,8 +6,9 @@ export declare function getHandlerStates(): Map<string, HandlerState>;
6
6
  /**
7
7
  * Execute all handlers for an event in parallel.
8
8
  * Returns merged results array.
9
+ * Optionally accepts pre-fetched context for LLM prompt rendering.
9
10
  */
10
- export declare function executeHandlers(_event: HookEvent, input: HookInput, handlers: HandlerConfig[]): Promise<HandlerResult[]>;
11
+ export declare function executeHandlers(_event: HookEvent, input: HookInput, handlers: HandlerConfig[], context?: PrefetchContext): Promise<HandlerResult[]>;
11
12
  /**
12
13
  * Execute a script handler: spawn a child process, pipe input JSON to stdin,
13
14
  * read stdout as JSON response.
package/dist/handlers.js CHANGED
@@ -10,6 +10,8 @@ const child_process_1 = require("child_process");
10
10
  const url_1 = require("url");
11
11
  const path_1 = require("path");
12
12
  const constants_js_1 = require("./constants.js");
13
+ const filter_js_1 = require("./filter.js");
14
+ const llm_js_1 = require("./llm.js");
13
15
  /** Runtime state per handler ID */
14
16
  const handlerStates = new Map();
15
17
  function getState(id) {
@@ -31,50 +33,76 @@ function getHandlerStates() {
31
33
  /**
32
34
  * Execute all handlers for an event in parallel.
33
35
  * Returns merged results array.
36
+ * Optionally accepts pre-fetched context for LLM prompt rendering.
34
37
  */
35
- async function executeHandlers(_event, input, handlers) {
36
- const promises = handlers.map(async (handler) => {
38
+ async function executeHandlers(_event, input, handlers, context) {
39
+ // Separate LLM handlers from script/inline, applying shared pre-checks
40
+ const llmHandlers = [];
41
+ const otherPromises = [];
42
+ const skippedResults = [];
43
+ for (const handler of handlers) {
37
44
  // Skip disabled handlers (both manifest-disabled and auto-disabled)
38
45
  if (handler.enabled === false) {
39
- return { id: handler.id, ok: true, output: undefined, duration_ms: 0 };
46
+ skippedResults.push({ id: handler.id, ok: true, output: undefined, duration_ms: 0 });
47
+ continue;
40
48
  }
41
49
  const state = getState(handler.id);
42
50
  if (state.disabled) {
43
- return {
51
+ skippedResults.push({
44
52
  id: handler.id,
45
53
  ok: false,
46
54
  error: `Auto-disabled after ${constants_js_1.MAX_CONSECUTIVE_FAILURES} consecutive failures`,
47
55
  duration_ms: 0,
48
- };
56
+ });
57
+ continue;
49
58
  }
50
- state.totalFires++;
51
- const start = performance.now();
52
- let result;
53
- try {
54
- if (handler.type === 'script') {
55
- result = await executeScriptHandler(handler, input);
56
- }
57
- else if (handler.type === 'inline') {
58
- result = await executeInlineHandler(handler, input);
59
- }
60
- else {
61
- result = {
59
+ // Evaluate keyword filter before execution
60
+ if (handler.filter) {
61
+ const inputStr = JSON.stringify(input);
62
+ if (!(0, filter_js_1.evaluateFilter)(handler.filter, inputStr)) {
63
+ skippedResults.push({
62
64
  id: handler.id,
63
- ok: false,
64
- error: `Unknown handler type: ${handler.type}`,
65
+ ok: true,
66
+ output: undefined,
65
67
  duration_ms: 0,
66
- };
68
+ filtered: true,
69
+ });
70
+ continue;
67
71
  }
68
72
  }
73
+ state.totalFires++;
74
+ if (handler.type === 'llm') {
75
+ llmHandlers.push(handler);
76
+ }
77
+ else {
78
+ // Execute script/inline handlers in parallel
79
+ otherPromises.push(executeOtherHandler(handler, input));
80
+ }
81
+ }
82
+ // Execute script/inline handlers in parallel
83
+ const otherResults = otherPromises.length > 0
84
+ ? await Promise.all(otherPromises)
85
+ : [];
86
+ // Execute LLM handlers with batching (graceful — never crashes)
87
+ let llmResults = [];
88
+ if (llmHandlers.length > 0) {
89
+ try {
90
+ llmResults = await (0, llm_js_1.executeLLMHandlersBatched)(llmHandlers, input, context ?? {});
91
+ }
69
92
  catch (err) {
70
- result = {
71
- id: handler.id,
93
+ // Graceful degradation: if LLM execution entirely fails, return error results
94
+ const errorMsg = err instanceof Error ? err.message : String(err);
95
+ llmResults = llmHandlers.map(h => ({
96
+ id: h.id,
72
97
  ok: false,
73
- error: err instanceof Error ? err.message : String(err),
74
- duration_ms: performance.now() - start,
75
- };
98
+ error: `LLM execution failed: ${errorMsg}`,
99
+ duration_ms: 0,
100
+ }));
76
101
  }
77
- // Update failure tracking
102
+ }
103
+ // Update failure tracking for all executed results
104
+ for (const result of [...otherResults, ...llmResults]) {
105
+ const state = getState(result.id);
78
106
  if (result.ok) {
79
107
  state.consecutiveFailures = 0;
80
108
  }
@@ -85,19 +113,49 @@ async function executeHandlers(_event, input, handlers) {
85
113
  state.disabled = true;
86
114
  }
87
115
  }
88
- return result;
89
- });
90
- return Promise.all(promises);
116
+ }
117
+ return [...skippedResults, ...otherResults, ...llmResults];
118
+ }
119
+ /**
120
+ * Execute a single script or inline handler with error handling.
121
+ */
122
+ async function executeOtherHandler(handler, input) {
123
+ const start = performance.now();
124
+ try {
125
+ if (handler.type === 'script') {
126
+ return await executeScriptHandler(handler, input);
127
+ }
128
+ else if (handler.type === 'inline') {
129
+ return await executeInlineHandler(handler, input);
130
+ }
131
+ else {
132
+ return {
133
+ id: handler.id,
134
+ ok: false,
135
+ error: `Unknown handler type: ${handler.type}`,
136
+ duration_ms: 0,
137
+ };
138
+ }
139
+ }
140
+ catch (err) {
141
+ return {
142
+ id: handler.id,
143
+ ok: false,
144
+ error: err instanceof Error ? err.message : String(err),
145
+ duration_ms: performance.now() - start,
146
+ };
147
+ }
91
148
  }
92
149
  /**
93
150
  * Execute a script handler: spawn a child process, pipe input JSON to stdin,
94
151
  * read stdout as JSON response.
95
152
  */
96
153
  function executeScriptHandler(handler, input) {
97
- const timeout = handler.timeout ?? constants_js_1.DEFAULT_HANDLER_TIMEOUT;
154
+ const h = handler;
155
+ const timeout = h.timeout ?? constants_js_1.DEFAULT_HANDLER_TIMEOUT;
98
156
  return new Promise((resolve) => {
99
157
  const start = performance.now();
100
- const child = (0, child_process_1.spawn)('sh', ['-c', handler.command], {
158
+ const child = (0, child_process_1.spawn)('sh', ['-c', h.command], {
101
159
  stdio: ['pipe', 'pipe', 'pipe'],
102
160
  timeout,
103
161
  });
@@ -155,17 +213,18 @@ function executeScriptHandler(handler, input) {
155
213
  * Execute an inline handler: dynamically import a JS module and call its default export.
156
214
  */
157
215
  async function executeInlineHandler(handler, input) {
158
- const timeout = handler.timeout ?? constants_js_1.DEFAULT_HANDLER_TIMEOUT;
216
+ const h = handler;
217
+ const timeout = h.timeout ?? constants_js_1.DEFAULT_HANDLER_TIMEOUT;
159
218
  const start = performance.now();
160
219
  try {
161
- const modulePath = (0, path_1.resolve)(handler.module);
220
+ const modulePath = (0, path_1.resolve)(h.module);
162
221
  const moduleUrl = (0, url_1.pathToFileURL)(modulePath).href;
163
222
  const mod = await import(moduleUrl);
164
223
  if (typeof mod.default !== 'function') {
165
224
  return {
166
- id: handler.id,
225
+ id: h.id,
167
226
  ok: false,
168
- error: `Module "${handler.module}" does not export a default function`,
227
+ error: `Module "${h.module}" does not export a default function`,
169
228
  duration_ms: performance.now() - start,
170
229
  };
171
230
  }
package/dist/index.d.ts CHANGED
@@ -5,5 +5,8 @@ export { migrate, restore, getSettingsPath } from './migrate.js';
5
5
  export type { MigratePathOptions } from './migrate.js';
6
6
  export { runDoctor } from './doctor.js';
7
7
  export { executeHandlers } from './handlers.js';
8
- export { DEFAULT_PORT, CONFIG_DIR, MANIFEST_PATH, PID_FILE, METRICS_FILE, LOG_FILE } from './constants.js';
9
- export type { HookEvent, HookInput, HandlerType, HandlerConfig, Manifest, HandlerResult, MetricEntry, HandlerState, DiagnosticResult, } from './types.js';
8
+ export { evaluateFilter } from './filter.js';
9
+ export { executeLLMHandler, executeLLMHandlersBatched, calculateCost, resetClient } from './llm.js';
10
+ export { prefetchContext, renderPromptTemplate } from './prefetch.js';
11
+ export { DEFAULT_PORT, CONFIG_DIR, MANIFEST_PATH, PID_FILE, METRICS_FILE, LOG_FILE, COSTS_FILE, DEFAULT_LLM_TIMEOUT, DEFAULT_LLM_MAX_TOKENS, LLM_PRICING } from './constants.js';
12
+ export type { HookEvent, HookInput, HandlerType, HandlerConfig, ScriptHandlerConfig, InlineHandlerConfig, LLMHandlerConfig, LLMModel, Manifest, HandlerResult, MetricEntry, HandlerState, DiagnosticResult, PrefetchKey, PrefetchContext, TokenUsage, CostEntry, } from './types.js';