toolfunnel 0.4.0 → 0.6.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 +102 -213
- package/bin/toolfunnel.js +372 -147
- package/docs/KNOWN_BUGS.md +99 -225
- package/docs/MANUAL.pdf +0 -0
- package/docs/design.md +70 -0
- package/docs/packaging.md +115 -115
- package/hooks/hooks.manifest.json +27 -27
- package/hooks/scripts/example-audit.js +21 -21
- package/hooks/scripts/example-deny-dangerous.js +31 -31
- package/mcp/expose.example.json +30 -30
- package/mcp/servers/mock-modern/server.js +147 -0
- package/mcp/servers/mock-upstream/server.js +192 -12
- package/package.json +1 -1
- package/src/auth/config.js +174 -174
- package/src/auth/install.js +181 -181
- package/src/auth/resource-server.js +344 -344
- package/src/core/config-home.js +134 -134
- package/src/core/events.js +105 -105
- package/src/core/hook-engine.js +275 -275
- package/src/core/hook-loader.js +791 -791
- package/src/core/hook-runner.js +346 -346
- package/src/core/logger.js +171 -171
- package/src/core/matcher.js +51 -51
- package/src/core/metrics.js +75 -75
- package/src/core/requires.js +138 -138
- package/src/core/server-config.js +98 -77
- package/src/core/tool-registry.js +180 -180
- package/src/extend/add-hook.md +152 -142
- package/src/extend/add-mcp.md +122 -121
- package/src/extend/configure.md +87 -0
- package/src/extend/create-tool.md +168 -152
- package/src/extend/howto.js +102 -98
- package/src/extend/package.md +187 -187
- package/src/extend/wrap.md +99 -0
- package/src/mcp/aggregator.js +1246 -807
- package/src/mcp/expose-store.js +460 -430
- package/src/mcp/gated-run.js +201 -194
- package/src/mcp/http-transport.js +1311 -981
- package/src/mcp/mcp-client.js +907 -530
- package/src/mcp/modern.js +372 -0
- package/src/mcp/protocol.js +468 -466
- package/src/mcp/server.js +2688 -1322
- package/src/packages/packager.js +715 -715
- package/src/tools/drift.js +138 -138
- package/src/tools/registry.js +513 -511
- package/src/tools/tool-state.js +170 -104
- package/src/ui/public/app.js +1174 -1055
- package/src/ui/public/index.html +286 -249
- package/src/ui/public/styles.css +535 -535
- package/src/ui/server.js +1348 -1190
- package/tools/scripts/base64.js +139 -139
- package/tools/scripts/danger.js +61 -61
- package/tools/scripts/echo.js +39 -39
- package/tools/scripts/hash.js +101 -101
- package/tools/scripts/json.js +109 -109
- package/tools/scripts/text-stats.js +131 -131
- package/tools/scripts/tf-env.js +43 -43
- package/tools/scripts/tf-hook-add.js +151 -151
- package/tools/scripts/tf-hook-set.js +112 -112
- package/tools/scripts/tf-list.js +192 -192
- package/tools/scripts/tf-log.js +86 -86
- package/tools/scripts/tf-mcp-add.js +117 -117
- package/tools/scripts/tf-mcp-set.js +121 -102
- package/tools/scripts/tf-pack.js +237 -237
- package/tools/scripts/tf-tool-add.js +134 -129
- package/tools/scripts/tf-tool-set.js +142 -142
- package/tools/scripts/tf-wrap.js +103 -0
- package/tools/scripts/uuid.js +102 -102
- package/tools/tools.register.json +227 -216
- package/docs/admin-ui.md +0 -404
- package/docs/configuration.md +0 -402
- package/docs/hooks-and-gating.md +0 -428
- package/docs/logging.md +0 -223
- package/docs/management.md +0 -429
- package/docs/oauth.md +0 -103
- package/docs/releasing.md +0 -31
- package/docs/setup.md +0 -352
- package/docs/tools-and-upstreams.md +0 -469
package/src/core/hook-engine.js
CHANGED
|
@@ -1,275 +1,275 @@
|
|
|
1
|
-
'use strict';
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* hook-engine.js
|
|
5
|
-
*
|
|
6
|
-
* Contract: HOOK_ENGINE.md §6.
|
|
7
|
-
*
|
|
8
|
-
* class HookEngine {
|
|
9
|
-
* constructor(loader) // loader provides enabled hook specs
|
|
10
|
-
* async fire(event, ctx, extra)
|
|
11
|
-
* injected: string, // all non-null inject fragments joined by "\n"
|
|
12
|
-
* blocked: boolean, // any hook blocked
|
|
13
|
-
* reason: string|null, // first block reason (in manifest order)
|
|
14
|
-
* stopLoop: boolean, // any hook said continue:false
|
|
15
|
-
* results: HookResult[] // per §4, in stable (manifest) order
|
|
16
|
-
* }
|
|
17
|
-
* }
|
|
18
|
-
*
|
|
19
|
-
* Behaviour:
|
|
20
|
-
* - Select hooks where spec.event === event AND spec.enabled AND matches(spec.matcher, toolName).
|
|
21
|
-
* Tool-less events (SessionStart, UserPromptSubmit, Stop, PreCompact) ignore the matcher
|
|
22
|
-
* matcher.js handles that, but we also derive toolName only from the built payload so the
|
|
23
|
-
* matcher gets exactly what the contract says it should see.
|
|
24
|
-
* - Build the stdin payload once via events.buildPayload(event, ctx, extra).
|
|
25
|
-
* - Run selected hooks CONCURRENTLY (they are independent) with a global concurrency cap
|
|
26
|
-
* (default 8) so a big event cannot fork-bomb, but return results in MANIFEST ORDER.
|
|
27
|
-
* - injected = every non-null inject joined by "\n", in order.
|
|
28
|
-
* - blocked/reason = the FIRST blocker in order.
|
|
29
|
-
* - stopLoop = true if ANY hook returned continue:false.
|
|
30
|
-
*
|
|
31
|
-
* The runner never throws (§4), and this engine defends against a misbehaving runner too:
|
|
32
|
-
* any rejection is converted into a synthetic error result so fire() never rejects.
|
|
33
|
-
*
|
|
34
|
-
* CommonJS only. Zero host imports (must run headless under `node --test`).
|
|
35
|
-
*/
|
|
36
|
-
|
|
37
|
-
const path = require('node:path');
|
|
38
|
-
|
|
39
|
-
// Sibling core modules (authored to the same contract in this build pass).
|
|
40
|
-
const matcher = require('./matcher');
|
|
41
|
-
const events = require('./events');
|
|
42
|
-
const hookRunner = require('./hook-runner');
|
|
43
|
-
|
|
44
|
-
const DEFAULT_CONCURRENCY = 8;
|
|
45
|
-
|
|
46
|
-
/**
|
|
47
|
-
* Resolve the matcher function from matcher.js regardless of whether it is the default or
|
|
48
|
-
* a named export, so we interlock with the contract's `matches(matcher, toolName)`.
|
|
49
|
-
* @returns {(m: string|undefined, toolName: string) => boolean}
|
|
50
|
-
*/
|
|
51
|
-
function getMatchesFn() {
|
|
52
|
-
if (typeof matcher === 'function') return matcher;
|
|
53
|
-
if (matcher && typeof matcher.matches === 'function') return matcher.matches;
|
|
54
|
-
// Defensive fallback: if matcher.js is unavailable, fire everything (never crash).
|
|
55
|
-
return () => true;
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
/**
|
|
59
|
-
* Resolve buildPayload from events.js (named export per contract).
|
|
60
|
-
* @returns {(event: string, ctx: object, extra: object) => object}
|
|
61
|
-
*/
|
|
62
|
-
function getBuildPayloadFn() {
|
|
63
|
-
if (events && typeof events.buildPayload === 'function') return events.buildPayload;
|
|
64
|
-
// Defensive fallback: assemble a minimal common payload.
|
|
65
|
-
return (event, ctx, extra) =>
|
|
66
|
-
Object.assign(
|
|
67
|
-
{
|
|
68
|
-
session_id: (ctx && ctx.session_id) || '',
|
|
69
|
-
transcript_path: (ctx && ctx.transcript_path) || '',
|
|
70
|
-
cwd: (ctx && ctx.cwd) || process.cwd(),
|
|
71
|
-
hook_event_name: event,
|
|
72
|
-
},
|
|
73
|
-
extra || {}
|
|
74
|
-
);
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
/**
|
|
78
|
-
* Resolve runHook from hook-runner.js (named export per contract).
|
|
79
|
-
* @returns {(spec: object, payload: object, opts: object) => Promise<object>}
|
|
80
|
-
*/
|
|
81
|
-
function getRunHookFn() {
|
|
82
|
-
if (hookRunner && typeof hookRunner.runHook === 'function') return hookRunner.runHook;
|
|
83
|
-
if (typeof hookRunner === 'function') return hookRunner;
|
|
84
|
-
throw new Error('hook-engine: hook-runner.runHook is unavailable');
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
/**
|
|
88
|
-
* Build a synthetic "failed" result matching the §4 shape, used when the runner rejects
|
|
89
|
-
* unexpectedly (it is contractually not supposed to, but we never let the loop throw).
|
|
90
|
-
* @param {object} spec
|
|
91
|
-
* @param {string} event
|
|
92
|
-
* @param {Error} err
|
|
93
|
-
* @param {number} durationMs
|
|
94
|
-
* @returns {object}
|
|
95
|
-
*/
|
|
96
|
-
function syntheticFailure(spec, event, err, durationMs) {
|
|
97
|
-
return {
|
|
98
|
-
id: spec && spec.id,
|
|
99
|
-
event,
|
|
100
|
-
exitCode: -1,
|
|
101
|
-
timedOut: false,
|
|
102
|
-
stdout: '',
|
|
103
|
-
stderr: (err && err.message) || String(err),
|
|
104
|
-
blocked: false,
|
|
105
|
-
stopLoop: false,
|
|
106
|
-
reason: null,
|
|
107
|
-
inject: null,
|
|
108
|
-
durationMs: durationMs || 0,
|
|
109
|
-
};
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
/**
|
|
113
|
-
* Run an array of async task factories with a concurrency cap, preserving input order in
|
|
114
|
-
* the returned results array. Each task is a `() => Promise<T>`; the returned slot holds
|
|
115
|
-
* the resolved value (tasks here are written to never reject).
|
|
116
|
-
*
|
|
117
|
-
* @template T
|
|
118
|
-
* @param {Array<() => Promise<T>>} taskFactories
|
|
119
|
-
* @param {number} cap maximum simultaneous tasks
|
|
120
|
-
* @returns {Promise<T[]>} results in the same order as taskFactories
|
|
121
|
-
*/
|
|
122
|
-
async function runWithConcurrency(taskFactories, cap) {
|
|
123
|
-
const total = taskFactories.length;
|
|
124
|
-
const results = new Array(total);
|
|
125
|
-
if (total === 0) return results;
|
|
126
|
-
|
|
127
|
-
const limit = Math.max(1, Math.min(cap || DEFAULT_CONCURRENCY, total));
|
|
128
|
-
let nextIndex = 0;
|
|
129
|
-
|
|
130
|
-
// Each worker pulls the next index until the queue drains.
|
|
131
|
-
async function worker() {
|
|
132
|
-
// eslint-disable-next-line no-constant-condition
|
|
133
|
-
while (true) {
|
|
134
|
-
const i = nextIndex++;
|
|
135
|
-
if (i >= total) return;
|
|
136
|
-
results[i] = await taskFactories[i]();
|
|
137
|
-
}
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
const workers = [];
|
|
141
|
-
for (let w = 0; w < limit; w++) workers.push(worker());
|
|
142
|
-
await Promise.all(workers);
|
|
143
|
-
return results;
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
class HookEngine {
|
|
147
|
-
/**
|
|
148
|
-
* @param {object} loader a HookLoader (or any object exposing enabledHooksFor(event)).
|
|
149
|
-
* @param {object} [options]
|
|
150
|
-
* @param {number} [options.concurrency=8] global concurrency cap per fire().
|
|
151
|
-
* @param {string} [options.cwd] working dir passed to the runner.
|
|
152
|
-
* @param {object} [options.env] base env merged into the runner's child env.
|
|
153
|
-
*/
|
|
154
|
-
constructor(loader, options) {
|
|
155
|
-
if (!loader || typeof loader.enabledHooksFor !== 'function') {
|
|
156
|
-
throw new TypeError('HookEngine: loader must expose enabledHooksFor(event)');
|
|
157
|
-
}
|
|
158
|
-
this.loader = loader;
|
|
159
|
-
const opts = options || {};
|
|
160
|
-
this.concurrency =
|
|
161
|
-
Number.isInteger(opts.concurrency) && opts.concurrency > 0
|
|
162
|
-
? opts.concurrency
|
|
163
|
-
: DEFAULT_CONCURRENCY;
|
|
164
|
-
this.cwd = opts.cwd || (loader.hooksDir ? path.dirname(loader.hooksDir) : process.cwd());
|
|
165
|
-
this.env = opts.env || null;
|
|
166
|
-
|
|
167
|
-
// Resolve contract functions once.
|
|
168
|
-
this._matches = getMatchesFn();
|
|
169
|
-
this._buildPayload = getBuildPayloadFn();
|
|
170
|
-
this._runHook = getRunHookFn();
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
/**
|
|
174
|
-
* Fire a lifecycle event through its matching, enabled hooks.
|
|
175
|
-
*
|
|
176
|
-
* @param {string} event one of EVENTS (e.g. "PreToolUse")
|
|
177
|
-
* @param {object} [ctx] common context: { session_id, transcript_path, cwd, ... }
|
|
178
|
-
* @param {object} [extra] event-specific fields (tool_name, tool_input, prompt, source,
|
|
179
|
-
* @returns {Promise<{injected:string, blocked:boolean, reason:(string|null),
|
|
180
|
-
* stopLoop:boolean, results:object[]}>}
|
|
181
|
-
*/
|
|
182
|
-
async fire(event, ctx, extra) {
|
|
183
|
-
const context = ctx || {};
|
|
184
|
-
const extraFields = extra || {};
|
|
185
|
-
|
|
186
|
-
// Build the stdin payload once (frozen field names per §2).
|
|
187
|
-
let payload;
|
|
188
|
-
try {
|
|
189
|
-
payload = this._buildPayload(event, context, extraFields);
|
|
190
|
-
} catch (err) {
|
|
191
|
-
// If payload assembly fails, fall back to a minimal valid payload so hooks still run.
|
|
192
|
-
payload = Object.assign(
|
|
193
|
-
{
|
|
194
|
-
session_id: context.session_id || '',
|
|
195
|
-
transcript_path: context.transcript_path || '',
|
|
196
|
-
cwd: context.cwd || this.cwd,
|
|
197
|
-
hook_event_name: event,
|
|
198
|
-
},
|
|
199
|
-
extraFields
|
|
200
|
-
);
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
// The toolName for matcher scoping comes from the payload (tool-bearing events only).
|
|
204
|
-
// For tool-less events this is undefined and matcher.js treats it as always-fire.
|
|
205
|
-
const toolName = payload && payload.tool_name;
|
|
206
|
-
|
|
207
|
-
// Select enabled hooks for this event whose matcher matches, in MANIFEST ORDER.
|
|
208
|
-
const candidates = this.loader.enabledHooksFor(event) || [];
|
|
209
|
-
const selected = candidates.filter((spec) => {
|
|
210
|
-
try {
|
|
211
|
-
return this._matches(spec && spec.matcher, toolName);
|
|
212
|
-
} catch (_) {
|
|
213
|
-
// A broken matcher string should not crash the engine; skip that hook.
|
|
214
|
-
return false;
|
|
215
|
-
}
|
|
216
|
-
});
|
|
217
|
-
|
|
218
|
-
// Resolve the child cwd/env once for the runner.
|
|
219
|
-
const runCwd = (payload && payload.cwd) || this.cwd;
|
|
220
|
-
const runOpts = { cwd: runCwd };
|
|
221
|
-
if (this.env) runOpts.env = this.env;
|
|
222
|
-
|
|
223
|
-
// Build task factories (one per selected hook)
|
|
224
|
-
const factories = selected.map((spec) => async () => {
|
|
225
|
-
const started = Date.now();
|
|
226
|
-
try {
|
|
227
|
-
const res = await this._runHook(spec, payload, runOpts);
|
|
228
|
-
// Defensive: ensure the result carries id/event even if the runner omitted them.
|
|
229
|
-
if (res && typeof res === 'object') {
|
|
230
|
-
if (res.id === undefined) res.id = spec.id;
|
|
231
|
-
if (res.event === undefined) res.event = event;
|
|
232
|
-
return res;
|
|
233
|
-
}
|
|
234
|
-
return syntheticFailure(spec, event, new Error('runner returned no result'), Date.now() - started);
|
|
235
|
-
} catch (err) {
|
|
236
|
-
return syntheticFailure(spec, event, err, Date.now() - started);
|
|
237
|
-
}
|
|
238
|
-
});
|
|
239
|
-
|
|
240
|
-
// Run concurrently with the cap; results come back in manifest order.
|
|
241
|
-
const results = await runWithConcurrency(factories, this.concurrency);
|
|
242
|
-
|
|
243
|
-
// Aggregate per §6.
|
|
244
|
-
const injectFragments = [];
|
|
245
|
-
let blocked = false;
|
|
246
|
-
let reason = null;
|
|
247
|
-
let stopLoop = false;
|
|
248
|
-
|
|
249
|
-
for (const r of results) {
|
|
250
|
-
if (!r) continue;
|
|
251
|
-
if (r.inject != null) injectFragments.push(r.inject);
|
|
252
|
-
if (r.blocked && !blocked) {
|
|
253
|
-
// First blocker in order owns the reason.
|
|
254
|
-
blocked = true;
|
|
255
|
-
reason = r.reason != null ? r.reason : null;
|
|
256
|
-
}
|
|
257
|
-
if (r.stopLoop) stopLoop = true;
|
|
258
|
-
}
|
|
259
|
-
|
|
260
|
-
return {
|
|
261
|
-
injected: injectFragments.join('\n'),
|
|
262
|
-
blocked,
|
|
263
|
-
reason,
|
|
264
|
-
stopLoop,
|
|
265
|
-
results,
|
|
266
|
-
};
|
|
267
|
-
}
|
|
268
|
-
}
|
|
269
|
-
|
|
270
|
-
module.exports = {
|
|
271
|
-
HookEngine,
|
|
272
|
-
DEFAULT_CONCURRENCY,
|
|
273
|
-
// Exported for unit tests / reuse:
|
|
274
|
-
runWithConcurrency,
|
|
275
|
-
};
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* hook-engine.js - fire a lifecycle event through its matching, enabled hooks.
|
|
5
|
+
*
|
|
6
|
+
* Contract: HOOK_ENGINE.md §6.
|
|
7
|
+
*
|
|
8
|
+
* class HookEngine {
|
|
9
|
+
* constructor(loader) // loader provides enabled hook specs
|
|
10
|
+
* async fire(event, ctx, extra) -> {
|
|
11
|
+
* injected: string, // all non-null inject fragments joined by "\n"
|
|
12
|
+
* blocked: boolean, // any hook blocked
|
|
13
|
+
* reason: string|null, // first block reason (in manifest order)
|
|
14
|
+
* stopLoop: boolean, // any hook said continue:false
|
|
15
|
+
* results: HookResult[] // per §4, in stable (manifest) order
|
|
16
|
+
* }
|
|
17
|
+
* }
|
|
18
|
+
*
|
|
19
|
+
* Behaviour:
|
|
20
|
+
* - Select hooks where spec.event === event AND spec.enabled AND matches(spec.matcher, toolName).
|
|
21
|
+
* Tool-less events (SessionStart, UserPromptSubmit, Stop, PreCompact) ignore the matcher -
|
|
22
|
+
* matcher.js handles that, but we also derive toolName only from the built payload so the
|
|
23
|
+
* matcher gets exactly what the contract says it should see.
|
|
24
|
+
* - Build the stdin payload once via events.buildPayload(event, ctx, extra).
|
|
25
|
+
* - Run selected hooks CONCURRENTLY (they are independent) with a global concurrency cap
|
|
26
|
+
* (default 8) so a big event cannot fork-bomb, but return results in MANIFEST ORDER.
|
|
27
|
+
* - injected = every non-null inject joined by "\n", in order.
|
|
28
|
+
* - blocked/reason = the FIRST blocker in order.
|
|
29
|
+
* - stopLoop = true if ANY hook returned continue:false.
|
|
30
|
+
*
|
|
31
|
+
* The runner never throws (§4), and this engine defends against a misbehaving runner too:
|
|
32
|
+
* any rejection is converted into a synthetic error result so fire() never rejects.
|
|
33
|
+
*
|
|
34
|
+
* CommonJS only. Zero host imports (must run headless under `node --test`).
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
const path = require('node:path');
|
|
38
|
+
|
|
39
|
+
// Sibling core modules (authored to the same contract in this build pass).
|
|
40
|
+
const matcher = require('./matcher');
|
|
41
|
+
const events = require('./events');
|
|
42
|
+
const hookRunner = require('./hook-runner');
|
|
43
|
+
|
|
44
|
+
const DEFAULT_CONCURRENCY = 8;
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Resolve the matcher function from matcher.js regardless of whether it is the default or
|
|
48
|
+
* a named export, so we interlock with the contract's `matches(matcher, toolName)`.
|
|
49
|
+
* @returns {(m: string|undefined, toolName: string) => boolean}
|
|
50
|
+
*/
|
|
51
|
+
function getMatchesFn() {
|
|
52
|
+
if (typeof matcher === 'function') return matcher;
|
|
53
|
+
if (matcher && typeof matcher.matches === 'function') return matcher.matches;
|
|
54
|
+
// Defensive fallback: if matcher.js is unavailable, fire everything (never crash).
|
|
55
|
+
return () => true;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Resolve buildPayload from events.js (named export per contract).
|
|
60
|
+
* @returns {(event: string, ctx: object, extra: object) => object}
|
|
61
|
+
*/
|
|
62
|
+
function getBuildPayloadFn() {
|
|
63
|
+
if (events && typeof events.buildPayload === 'function') return events.buildPayload;
|
|
64
|
+
// Defensive fallback: assemble a minimal common payload.
|
|
65
|
+
return (event, ctx, extra) =>
|
|
66
|
+
Object.assign(
|
|
67
|
+
{
|
|
68
|
+
session_id: (ctx && ctx.session_id) || '',
|
|
69
|
+
transcript_path: (ctx && ctx.transcript_path) || '',
|
|
70
|
+
cwd: (ctx && ctx.cwd) || process.cwd(),
|
|
71
|
+
hook_event_name: event,
|
|
72
|
+
},
|
|
73
|
+
extra || {}
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Resolve runHook from hook-runner.js (named export per contract).
|
|
79
|
+
* @returns {(spec: object, payload: object, opts: object) => Promise<object>}
|
|
80
|
+
*/
|
|
81
|
+
function getRunHookFn() {
|
|
82
|
+
if (hookRunner && typeof hookRunner.runHook === 'function') return hookRunner.runHook;
|
|
83
|
+
if (typeof hookRunner === 'function') return hookRunner;
|
|
84
|
+
throw new Error('hook-engine: hook-runner.runHook is unavailable');
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Build a synthetic "failed" result matching the §4 shape, used when the runner rejects
|
|
89
|
+
* unexpectedly (it is contractually not supposed to, but we never let the loop throw).
|
|
90
|
+
* @param {object} spec
|
|
91
|
+
* @param {string} event
|
|
92
|
+
* @param {Error} err
|
|
93
|
+
* @param {number} durationMs
|
|
94
|
+
* @returns {object}
|
|
95
|
+
*/
|
|
96
|
+
function syntheticFailure(spec, event, err, durationMs) {
|
|
97
|
+
return {
|
|
98
|
+
id: spec && spec.id,
|
|
99
|
+
event,
|
|
100
|
+
exitCode: -1,
|
|
101
|
+
timedOut: false,
|
|
102
|
+
stdout: '',
|
|
103
|
+
stderr: (err && err.message) || String(err),
|
|
104
|
+
blocked: false,
|
|
105
|
+
stopLoop: false,
|
|
106
|
+
reason: null,
|
|
107
|
+
inject: null,
|
|
108
|
+
durationMs: durationMs || 0,
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Run an array of async task factories with a concurrency cap, preserving input order in
|
|
114
|
+
* the returned results array. Each task is a `() => Promise<T>`; the returned slot holds
|
|
115
|
+
* the resolved value (tasks here are written to never reject).
|
|
116
|
+
*
|
|
117
|
+
* @template T
|
|
118
|
+
* @param {Array<() => Promise<T>>} taskFactories
|
|
119
|
+
* @param {number} cap maximum simultaneous tasks
|
|
120
|
+
* @returns {Promise<T[]>} results in the same order as taskFactories
|
|
121
|
+
*/
|
|
122
|
+
async function runWithConcurrency(taskFactories, cap) {
|
|
123
|
+
const total = taskFactories.length;
|
|
124
|
+
const results = new Array(total);
|
|
125
|
+
if (total === 0) return results;
|
|
126
|
+
|
|
127
|
+
const limit = Math.max(1, Math.min(cap || DEFAULT_CONCURRENCY, total));
|
|
128
|
+
let nextIndex = 0;
|
|
129
|
+
|
|
130
|
+
// Each worker pulls the next index until the queue drains.
|
|
131
|
+
async function worker() {
|
|
132
|
+
// eslint-disable-next-line no-constant-condition
|
|
133
|
+
while (true) {
|
|
134
|
+
const i = nextIndex++;
|
|
135
|
+
if (i >= total) return;
|
|
136
|
+
results[i] = await taskFactories[i]();
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const workers = [];
|
|
141
|
+
for (let w = 0; w < limit; w++) workers.push(worker());
|
|
142
|
+
await Promise.all(workers);
|
|
143
|
+
return results;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
class HookEngine {
|
|
147
|
+
/**
|
|
148
|
+
* @param {object} loader a HookLoader (or any object exposing enabledHooksFor(event)).
|
|
149
|
+
* @param {object} [options]
|
|
150
|
+
* @param {number} [options.concurrency=8] global concurrency cap per fire().
|
|
151
|
+
* @param {string} [options.cwd] working dir passed to the runner.
|
|
152
|
+
* @param {object} [options.env] base env merged into the runner's child env.
|
|
153
|
+
*/
|
|
154
|
+
constructor(loader, options) {
|
|
155
|
+
if (!loader || typeof loader.enabledHooksFor !== 'function') {
|
|
156
|
+
throw new TypeError('HookEngine: loader must expose enabledHooksFor(event)');
|
|
157
|
+
}
|
|
158
|
+
this.loader = loader;
|
|
159
|
+
const opts = options || {};
|
|
160
|
+
this.concurrency =
|
|
161
|
+
Number.isInteger(opts.concurrency) && opts.concurrency > 0
|
|
162
|
+
? opts.concurrency
|
|
163
|
+
: DEFAULT_CONCURRENCY;
|
|
164
|
+
this.cwd = opts.cwd || (loader.hooksDir ? path.dirname(loader.hooksDir) : process.cwd());
|
|
165
|
+
this.env = opts.env || null;
|
|
166
|
+
|
|
167
|
+
// Resolve contract functions once.
|
|
168
|
+
this._matches = getMatchesFn();
|
|
169
|
+
this._buildPayload = getBuildPayloadFn();
|
|
170
|
+
this._runHook = getRunHookFn();
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Fire a lifecycle event through its matching, enabled hooks.
|
|
175
|
+
*
|
|
176
|
+
* @param {string} event one of EVENTS (e.g. "PreToolUse")
|
|
177
|
+
* @param {object} [ctx] common context: { session_id, transcript_path, cwd, ... }
|
|
178
|
+
* @param {object} [extra] event-specific fields (tool_name, tool_input, prompt, source, ...)
|
|
179
|
+
* @returns {Promise<{injected:string, blocked:boolean, reason:(string|null),
|
|
180
|
+
* stopLoop:boolean, results:object[]}>}
|
|
181
|
+
*/
|
|
182
|
+
async fire(event, ctx, extra) {
|
|
183
|
+
const context = ctx || {};
|
|
184
|
+
const extraFields = extra || {};
|
|
185
|
+
|
|
186
|
+
// Build the stdin payload once (frozen field names per §2).
|
|
187
|
+
let payload;
|
|
188
|
+
try {
|
|
189
|
+
payload = this._buildPayload(event, context, extraFields);
|
|
190
|
+
} catch (err) {
|
|
191
|
+
// If payload assembly fails, fall back to a minimal valid payload so hooks still run.
|
|
192
|
+
payload = Object.assign(
|
|
193
|
+
{
|
|
194
|
+
session_id: context.session_id || '',
|
|
195
|
+
transcript_path: context.transcript_path || '',
|
|
196
|
+
cwd: context.cwd || this.cwd,
|
|
197
|
+
hook_event_name: event,
|
|
198
|
+
},
|
|
199
|
+
extraFields
|
|
200
|
+
);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// The toolName for matcher scoping comes from the payload (tool-bearing events only).
|
|
204
|
+
// For tool-less events this is undefined and matcher.js treats it as always-fire.
|
|
205
|
+
const toolName = payload && payload.tool_name;
|
|
206
|
+
|
|
207
|
+
// Select enabled hooks for this event whose matcher matches, in MANIFEST ORDER.
|
|
208
|
+
const candidates = this.loader.enabledHooksFor(event) || [];
|
|
209
|
+
const selected = candidates.filter((spec) => {
|
|
210
|
+
try {
|
|
211
|
+
return this._matches(spec && spec.matcher, toolName);
|
|
212
|
+
} catch (_) {
|
|
213
|
+
// A broken matcher string should not crash the engine; skip that hook.
|
|
214
|
+
return false;
|
|
215
|
+
}
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
// Resolve the child cwd/env once for the runner.
|
|
219
|
+
const runCwd = (payload && payload.cwd) || this.cwd;
|
|
220
|
+
const runOpts = { cwd: runCwd };
|
|
221
|
+
if (this.env) runOpts.env = this.env;
|
|
222
|
+
|
|
223
|
+
// Build task factories (one per selected hook) - never reject.
|
|
224
|
+
const factories = selected.map((spec) => async () => {
|
|
225
|
+
const started = Date.now();
|
|
226
|
+
try {
|
|
227
|
+
const res = await this._runHook(spec, payload, runOpts);
|
|
228
|
+
// Defensive: ensure the result carries id/event even if the runner omitted them.
|
|
229
|
+
if (res && typeof res === 'object') {
|
|
230
|
+
if (res.id === undefined) res.id = spec.id;
|
|
231
|
+
if (res.event === undefined) res.event = event;
|
|
232
|
+
return res;
|
|
233
|
+
}
|
|
234
|
+
return syntheticFailure(spec, event, new Error('runner returned no result'), Date.now() - started);
|
|
235
|
+
} catch (err) {
|
|
236
|
+
return syntheticFailure(spec, event, err, Date.now() - started);
|
|
237
|
+
}
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
// Run concurrently with the cap; results come back in manifest order.
|
|
241
|
+
const results = await runWithConcurrency(factories, this.concurrency);
|
|
242
|
+
|
|
243
|
+
// Aggregate per §6.
|
|
244
|
+
const injectFragments = [];
|
|
245
|
+
let blocked = false;
|
|
246
|
+
let reason = null;
|
|
247
|
+
let stopLoop = false;
|
|
248
|
+
|
|
249
|
+
for (const r of results) {
|
|
250
|
+
if (!r) continue;
|
|
251
|
+
if (r.inject != null) injectFragments.push(r.inject);
|
|
252
|
+
if (r.blocked && !blocked) {
|
|
253
|
+
// First blocker in order owns the reason.
|
|
254
|
+
blocked = true;
|
|
255
|
+
reason = r.reason != null ? r.reason : null;
|
|
256
|
+
}
|
|
257
|
+
if (r.stopLoop) stopLoop = true;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
return {
|
|
261
|
+
injected: injectFragments.join('\n'),
|
|
262
|
+
blocked,
|
|
263
|
+
reason,
|
|
264
|
+
stopLoop,
|
|
265
|
+
results,
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
module.exports = {
|
|
271
|
+
HookEngine,
|
|
272
|
+
DEFAULT_CONCURRENCY,
|
|
273
|
+
// Exported for unit tests / reuse:
|
|
274
|
+
runWithConcurrency,
|
|
275
|
+
};
|