toolfunnel 0.6.0 → 0.7.1

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.
Files changed (72) hide show
  1. package/README.md +228 -231
  2. package/SECURITY.md +84 -0
  3. package/bin/toolfunnel.js +451 -372
  4. package/docs/KNOWN_BUGS.md +80 -99
  5. package/docs/MANUAL.pdf +0 -0
  6. package/docs/design.md +84 -70
  7. package/docs/packaging.md +115 -115
  8. package/hooks/hooks.manifest.json +27 -27
  9. package/hooks/scripts/example-audit.js +21 -21
  10. package/hooks/scripts/example-deny-dangerous.js +39 -31
  11. package/mcp/expose.example.json +30 -30
  12. package/mcp/servers/mock-modern/server.js +147 -147
  13. package/mcp/servers/mock-upstream/server.js +380 -350
  14. package/package.json +3 -1
  15. package/src/auth/config.js +174 -174
  16. package/src/auth/install.js +181 -181
  17. package/src/auth/resource-server.js +353 -344
  18. package/src/core/child-reaper.js +61 -0
  19. package/src/core/config-home.js +134 -134
  20. package/src/core/events.js +105 -105
  21. package/src/core/hook-engine.js +329 -275
  22. package/src/core/hook-loader.js +824 -791
  23. package/src/core/hook-runner.js +566 -346
  24. package/src/core/logger.js +297 -171
  25. package/src/core/matcher.js +100 -51
  26. package/src/core/metrics.js +75 -75
  27. package/src/core/rename-migration.js +163 -0
  28. package/src/core/requires.js +138 -138
  29. package/src/core/server-config.js +98 -98
  30. package/src/extend/add-hook.md +152 -152
  31. package/src/extend/add-mcp.md +119 -122
  32. package/src/extend/configure.md +88 -87
  33. package/src/extend/create-tool.md +168 -168
  34. package/src/extend/howto.js +102 -102
  35. package/src/extend/package.md +190 -187
  36. package/src/extend/wrap.md +99 -99
  37. package/src/mcp/aggregator.js +1719 -1246
  38. package/src/mcp/expose-store.js +577 -460
  39. package/src/mcp/gated-run.js +210 -201
  40. package/src/mcp/http-transport.js +1563 -1311
  41. package/src/mcp/mcp-client.js +1070 -907
  42. package/src/mcp/modern.js +406 -372
  43. package/src/mcp/protocol.js +468 -468
  44. package/src/mcp/server.js +3112 -2688
  45. package/src/tools/registry.js +661 -513
  46. package/src/tools/tool-state.js +170 -170
  47. package/src/ui/public/app.js +1187 -1174
  48. package/src/ui/public/index.html +286 -286
  49. package/src/ui/public/styles.css +535 -535
  50. package/src/ui/server.js +1616 -1348
  51. package/tools/scripts/base64.js +139 -139
  52. package/tools/scripts/danger.js +61 -61
  53. package/tools/scripts/echo.js +39 -39
  54. package/tools/scripts/hash.js +101 -101
  55. package/tools/scripts/json.js +109 -109
  56. package/tools/scripts/text-stats.js +131 -131
  57. package/tools/scripts/tf-env.js +43 -43
  58. package/tools/scripts/tf-hook-add.js +151 -151
  59. package/tools/scripts/tf-hook-set.js +120 -112
  60. package/tools/scripts/tf-list.js +192 -192
  61. package/tools/scripts/tf-log.js +86 -86
  62. package/tools/scripts/tf-mcp-add.js +117 -117
  63. package/tools/scripts/tf-mcp-set.js +121 -121
  64. package/tools/scripts/tf-pack.js +237 -237
  65. package/tools/scripts/tf-tool-add.js +134 -134
  66. package/tools/scripts/tf-tool-set.js +142 -142
  67. package/tools/scripts/tf-wrap.js +103 -103
  68. package/tools/scripts/uuid.js +102 -102
  69. package/tools/tools.register.json +227 -227
  70. package/src/core/tool-registry.js +0 -180
  71. package/src/packages/packager.js +0 -715
  72. package/src/tools/drift.js +0 -138
@@ -0,0 +1,61 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * child-reaper.js - last-resort cleanup for spawned hook/tool children.
5
+ *
6
+ * The hook runner and both local executors spawn their children `detached` on POSIX so the
7
+ * process GROUP can be killed as a tree. setsid() has a consequence: the children leave the
8
+ * gateway's session, so a terminal Ctrl-C no longer reaches them, and a gateway that dies
9
+ * without executing its per-child kill path leaves them reparented to init running to their
10
+ * natural end - with their supervising timeout timers dead alongside the gateway.
11
+ *
12
+ * This module is the counterpart: every spawn site registers its child here and unregisters on
13
+ * settle; process exit / SIGINT / SIGTERM sweeps whatever is still tracked. Synchronous kills
14
+ * only - an 'exit' handler cannot await. Windows gets child.kill() (the shell only; taskkill is
15
+ * async and cannot run here) - best effort, matching the platform's weaker group semantics.
16
+ * A SIGKILLed gateway sweeps nothing; that limit is inherent to SIGKILL.
17
+ */
18
+
19
+ const live = new Set();
20
+ let installed = false;
21
+
22
+ function sweep() {
23
+ for (const child of live) {
24
+ try {
25
+ if (child.pid != null && process.platform !== 'win32') {
26
+ try { process.kill(-child.pid, 'SIGKILL'); } catch (_eg) { child.kill('SIGKILL'); }
27
+ } else {
28
+ child.kill();
29
+ }
30
+ } catch (_e) { /* already gone */ }
31
+ }
32
+ live.clear();
33
+ }
34
+
35
+ function install() {
36
+ if (installed) return;
37
+ installed = true;
38
+ process.on('exit', sweep);
39
+ // On a signal, sweep and re-raise the default behaviour by exiting: only do so if nobody
40
+ // else handles the signal (respect an embedding host's own handlers).
41
+ for (const sig of ['SIGINT', 'SIGTERM']) {
42
+ process.on(sig, () => {
43
+ sweep();
44
+ if (process.listenerCount(sig) === 1) process.exit(sig === 'SIGINT' ? 130 : 143);
45
+ });
46
+ }
47
+ }
48
+
49
+ /** Register a spawned child for the last-resort sweep. Call untrack() when it settles. */
50
+ function track(child) {
51
+ if (!child || typeof child.kill !== 'function') return;
52
+ install();
53
+ live.add(child);
54
+ }
55
+
56
+ /** Remove a settled child from the sweep set. Safe to call twice. */
57
+ function untrack(child) {
58
+ live.delete(child);
59
+ }
60
+
61
+ module.exports = { track, untrack };
@@ -1,134 +1,134 @@
1
- 'use strict';
2
-
3
- /**
4
- * config-home.js - WHERE the gateway's mutable config lives (board item 9, the packaging enabler).
5
- *
6
- * The problem this solves: an npm/npx-installed gateway keeps its config INSIDE the package
7
- * directory, so `npm update toolfunnel` replaces the package tree and EATS the user's tools,
8
- * hooks, curation, and identity. Config must be able to live in a home of its own:
9
- *
10
- * resolution precedence: --config-dir flag > TOOLFUNNEL_HOME env > the package root
11
- *
12
- * The default is UNCHANGED: a git clone keeps everything in the repo root exactly as before
13
- * (home === package root, no seeding, byte-identical behaviour). Pointing TOOLFUNNEL_HOME at an
14
- * empty directory makes the gateway SEED it on first use - the shipped register + its scripts, an
15
- * empty expose.json, the default hooks manifest - and every mutable path (tools/ mcp/ hooks/
16
- * auth/ logs/ toolfunnel.json packages/) anchors there from then on. Seeding never overwrites an
17
- * existing file, so a home survives package updates by construction.
18
- *
19
- * Two env vars are the process-tree contract (children inherit them via defaultRunScript's
20
- * spawn env):
21
- * TOOLFUNNEL_HOME - the resolved ABSOLUTE config home (normalised at init so a relative value
22
- * can't skew between processes with different cwds).
23
- * TOOLFUNNEL_PKG - the package root of the gateway that spawned the tool, so a management
24
- * script SEEDED INTO an external home can still require the engine code
25
- * (src/...) it was copied away from. A script always prefers its own
26
- * __dirname-derived root when that root contains the engine (the git-clone
27
- * layout), so a stale inherited value can never shadow a real local install.
28
- *
29
- * Zero new dependencies. Node built-ins only.
30
- */
31
-
32
- const fs = require('node:fs');
33
- const path = require('node:path');
34
-
35
- /** The package root: this file lives at <pkg>/src/core/config-home.js. */
36
- const PKG_ROOT = path.resolve(__dirname, '..', '..');
37
-
38
- /**
39
- * Resolve the config home. Pure - no filesystem writes, no env mutation.
40
- * @param {{dir?: string}} [opts] dir = an explicit --config-dir value (wins over the env)
41
- * @returns {string} absolute config-home path
42
- */
43
- function resolveConfigHome(opts) {
44
- const o = opts || {};
45
- const fromFlag = typeof o.dir === 'string' && o.dir.trim().length > 0 ? o.dir.trim() : null;
46
- const fromEnv = typeof process.env.TOOLFUNNEL_HOME === 'string' && process.env.TOOLFUNNEL_HOME.trim().length > 0
47
- ? process.env.TOOLFUNNEL_HOME.trim()
48
- : null;
49
- const chosen = fromFlag || fromEnv;
50
- return chosen ? path.resolve(chosen) : PKG_ROOT;
51
- }
52
-
53
- /** Recursively copy a directory, skipping any destination file that already exists. */
54
- function copyTreeIfAbsent(srcDir, destDir, seeded) {
55
- let entries;
56
- try {
57
- entries = fs.readdirSync(srcDir, { withFileTypes: true });
58
- } catch (_e) {
59
- return; // a missing source dir seeds nothing (defensive)
60
- }
61
- fs.mkdirSync(destDir, { recursive: true });
62
- for (const e of entries) {
63
- const from = path.join(srcDir, e.name);
64
- const to = path.join(destDir, e.name);
65
- if (e.isDirectory()) {
66
- copyTreeIfAbsent(from, to, seeded);
67
- } else if (e.isFile() && !fs.existsSync(to)) {
68
- fs.copyFileSync(from, to);
69
- seeded.push(to);
70
- }
71
- }
72
- }
73
-
74
- /**
75
- * Make `home` a working config home, seeding whatever is ABSENT from the shipped defaults.
76
- * Idempotent and never destructive: an existing file is NEVER overwritten (a home must survive
77
- * package updates - that is the whole point). home === package root -> a no-op (the git-clone
78
- * layout is already complete). Throws only if the home cannot be created at all.
79
- *
80
- * Deliberately NOT seeded: tools.state.json (absent = default visibility), auth/ (absent = auth
81
- * off), logs/ (absent = logging off), toolfunnel.json (absent = the default identity - an
82
- * npm-wrap ships its OWN copy inside its bundled home).
83
- *
84
- * @param {string} home absolute config-home path (from resolveConfigHome)
85
- * @returns {{home: string, seeded: string[]}} the files seeded on THIS call (empty = was complete)
86
- */
87
- function ensureConfigHome(home) {
88
- const seeded = [];
89
- if (path.resolve(home) === PKG_ROOT) return { home: PKG_ROOT, seeded };
90
- fs.mkdirSync(home, { recursive: true });
91
-
92
- // The register + every shipped script (management tf_* + demo tools). The management scripts
93
- // are config-tree citizens (the register's invoke paths point at them), so they travel.
94
- copyTreeIfAbsent(path.join(PKG_ROOT, 'tools'), path.join(home, 'tools'), seeded);
95
- // The state overlay is user-state, not a shipped default - drop a copied one if the package
96
- // tree happened to carry local toggles (dev clone); a FRESH home starts with default visibility.
97
- const strayState = path.join(home, 'tools', 'tools.state.json');
98
- if (seeded.includes(strayState)) {
99
- try { fs.unlinkSync(strayState); } catch (_e) { /* best-effort */ }
100
- seeded.splice(seeded.indexOf(strayState), 1);
101
- }
102
-
103
- // The upstream/curation store and the hooks tree. NOTE the npm tarball ships
104
- // mcp/expose.example.json but NOT mcp/expose.json (the live store is user-state), so after the
105
- // tree copy the live store is synthesised EMPTY when absent. Never seed it from the example -
106
- // the example is a POPULATED demo (it attaches the mock upstream), and a fresh home must start
107
- // connected to nothing, exactly like a fresh clone.
108
- copyTreeIfAbsent(path.join(PKG_ROOT, 'mcp'), path.join(home, 'mcp'), seeded);
109
- const exposePath = path.join(home, 'mcp', 'expose.json');
110
- if (!fs.existsSync(exposePath)) {
111
- fs.mkdirSync(path.dirname(exposePath), { recursive: true });
112
- fs.writeFileSync(exposePath, JSON.stringify({ version: 1, upstreams: [], expose: [] }, null, 2) + '\n');
113
- seeded.push(exposePath);
114
- }
115
- copyTreeIfAbsent(path.join(PKG_ROOT, 'hooks'), path.join(home, 'hooks'), seeded);
116
- return { home, seeded };
117
- }
118
-
119
- /**
120
- * The one-call init used by bin/toolfunnel.js for every mode: resolve, seed, and write the
121
- * process-tree contract back into the env (see the header) so every module loaded AFTER this -
122
- * and every child tool spawned later - reads the same resolved home.
123
- * @param {{dir?: string}} [opts]
124
- * @returns {{home: string, seeded: string[]}}
125
- */
126
- function initConfigHome(opts) {
127
- const home = resolveConfigHome(opts);
128
- const res = ensureConfigHome(home);
129
- process.env.TOOLFUNNEL_HOME = res.home;
130
- process.env.TOOLFUNNEL_PKG = PKG_ROOT;
131
- return res;
132
- }
133
-
134
- module.exports = { PKG_ROOT, resolveConfigHome, ensureConfigHome, initConfigHome };
1
+ 'use strict';
2
+
3
+ /**
4
+ * config-home.js - WHERE the gateway's mutable config lives (board item 9, the packaging enabler).
5
+ *
6
+ * The problem this solves: an npm/npx-installed gateway keeps its config INSIDE the package
7
+ * directory, so `npm update toolfunnel` replaces the package tree and EATS the user's tools,
8
+ * hooks, curation, and identity. Config must be able to live in a home of its own:
9
+ *
10
+ * resolution precedence: --config-dir flag > TOOLFUNNEL_HOME env > the package root
11
+ *
12
+ * The default is UNCHANGED: a git clone keeps everything in the repo root exactly as before
13
+ * (home === package root, no seeding, byte-identical behaviour). Pointing TOOLFUNNEL_HOME at an
14
+ * empty directory makes the gateway SEED it on first use - the shipped register + its scripts, an
15
+ * empty expose.json, the default hooks manifest - and every mutable path (tools/ mcp/ hooks/
16
+ * auth/ logs/ toolfunnel.json packages/) anchors there from then on. Seeding never overwrites an
17
+ * existing file, so a home survives package updates by construction.
18
+ *
19
+ * Two env vars are the process-tree contract (children inherit them via defaultRunScript's
20
+ * spawn env):
21
+ * TOOLFUNNEL_HOME - the resolved ABSOLUTE config home (normalised at init so a relative value
22
+ * can't skew between processes with different cwds).
23
+ * TOOLFUNNEL_PKG - the package root of the gateway that spawned the tool, so a management
24
+ * script SEEDED INTO an external home can still require the engine code
25
+ * (src/...) it was copied away from. A script always prefers its own
26
+ * __dirname-derived root when that root contains the engine (the git-clone
27
+ * layout), so a stale inherited value can never shadow a real local install.
28
+ *
29
+ * Zero new dependencies. Node built-ins only.
30
+ */
31
+
32
+ const fs = require('node:fs');
33
+ const path = require('node:path');
34
+
35
+ /** The package root: this file lives at <pkg>/src/core/config-home.js. */
36
+ const PKG_ROOT = path.resolve(__dirname, '..', '..');
37
+
38
+ /**
39
+ * Resolve the config home. Pure - no filesystem writes, no env mutation.
40
+ * @param {{dir?: string}} [opts] dir = an explicit --config-dir value (wins over the env)
41
+ * @returns {string} absolute config-home path
42
+ */
43
+ function resolveConfigHome(opts) {
44
+ const o = opts || {};
45
+ const fromFlag = typeof o.dir === 'string' && o.dir.trim().length > 0 ? o.dir.trim() : null;
46
+ const fromEnv = typeof process.env.TOOLFUNNEL_HOME === 'string' && process.env.TOOLFUNNEL_HOME.trim().length > 0
47
+ ? process.env.TOOLFUNNEL_HOME.trim()
48
+ : null;
49
+ const chosen = fromFlag || fromEnv;
50
+ return chosen ? path.resolve(chosen) : PKG_ROOT;
51
+ }
52
+
53
+ /** Recursively copy a directory, skipping any destination file that already exists. */
54
+ function copyTreeIfAbsent(srcDir, destDir, seeded) {
55
+ let entries;
56
+ try {
57
+ entries = fs.readdirSync(srcDir, { withFileTypes: true });
58
+ } catch (_e) {
59
+ return; // a missing source dir seeds nothing (defensive)
60
+ }
61
+ fs.mkdirSync(destDir, { recursive: true });
62
+ for (const e of entries) {
63
+ const from = path.join(srcDir, e.name);
64
+ const to = path.join(destDir, e.name);
65
+ if (e.isDirectory()) {
66
+ copyTreeIfAbsent(from, to, seeded);
67
+ } else if (e.isFile() && !fs.existsSync(to)) {
68
+ fs.copyFileSync(from, to);
69
+ seeded.push(to);
70
+ }
71
+ }
72
+ }
73
+
74
+ /**
75
+ * Make `home` a working config home, seeding whatever is ABSENT from the shipped defaults.
76
+ * Idempotent and never destructive: an existing file is NEVER overwritten (a home must survive
77
+ * package updates - that is the whole point). home === package root -> a no-op (the git-clone
78
+ * layout is already complete). Throws only if the home cannot be created at all.
79
+ *
80
+ * Deliberately NOT seeded: tools.state.json (absent = default visibility), auth/ (absent = auth
81
+ * off), logs/ (absent = logging off), toolfunnel.json (absent = the default identity - an
82
+ * npm-wrap ships its OWN copy inside its bundled home).
83
+ *
84
+ * @param {string} home absolute config-home path (from resolveConfigHome)
85
+ * @returns {{home: string, seeded: string[]}} the files seeded on THIS call (empty = was complete)
86
+ */
87
+ function ensureConfigHome(home) {
88
+ const seeded = [];
89
+ if (path.resolve(home) === PKG_ROOT) return { home: PKG_ROOT, seeded };
90
+ fs.mkdirSync(home, { recursive: true });
91
+
92
+ // The register + every shipped script (management tf_* + demo tools). The management scripts
93
+ // are config-tree citizens (the register's invoke paths point at them), so they travel.
94
+ copyTreeIfAbsent(path.join(PKG_ROOT, 'tools'), path.join(home, 'tools'), seeded);
95
+ // The state overlay is user-state, not a shipped default - drop a copied one if the package
96
+ // tree happened to carry local toggles (dev clone); a FRESH home starts with default visibility.
97
+ const strayState = path.join(home, 'tools', 'tools.state.json');
98
+ if (seeded.includes(strayState)) {
99
+ try { fs.unlinkSync(strayState); } catch (_e) { /* best-effort */ }
100
+ seeded.splice(seeded.indexOf(strayState), 1);
101
+ }
102
+
103
+ // The upstream/curation store and the hooks tree. NOTE the npm tarball ships
104
+ // mcp/expose.example.json but NOT mcp/expose.json (the live store is user-state), so after the
105
+ // tree copy the live store is synthesised EMPTY when absent. Never seed it from the example -
106
+ // the example is a POPULATED demo (it attaches the mock upstream), and a fresh home must start
107
+ // connected to nothing, exactly like a fresh clone.
108
+ copyTreeIfAbsent(path.join(PKG_ROOT, 'mcp'), path.join(home, 'mcp'), seeded);
109
+ const exposePath = path.join(home, 'mcp', 'expose.json');
110
+ if (!fs.existsSync(exposePath)) {
111
+ fs.mkdirSync(path.dirname(exposePath), { recursive: true });
112
+ fs.writeFileSync(exposePath, JSON.stringify({ version: 1, upstreams: [], expose: [] }, null, 2) + '\n');
113
+ seeded.push(exposePath);
114
+ }
115
+ copyTreeIfAbsent(path.join(PKG_ROOT, 'hooks'), path.join(home, 'hooks'), seeded);
116
+ return { home, seeded };
117
+ }
118
+
119
+ /**
120
+ * The one-call init used by bin/toolfunnel.js for every mode: resolve, seed, and write the
121
+ * process-tree contract back into the env (see the header) so every module loaded AFTER this -
122
+ * and every child tool spawned later - reads the same resolved home.
123
+ * @param {{dir?: string}} [opts]
124
+ * @returns {{home: string, seeded: string[]}}
125
+ */
126
+ function initConfigHome(opts) {
127
+ const home = resolveConfigHome(opts);
128
+ const res = ensureConfigHome(home);
129
+ process.env.TOOLFUNNEL_HOME = res.home;
130
+ process.env.TOOLFUNNEL_PKG = PKG_ROOT;
131
+ return res;
132
+ }
133
+
134
+ module.exports = { PKG_ROOT, resolveConfigHome, ensureConfigHome, initConfigHome };
@@ -1,105 +1,105 @@
1
- 'use strict';
2
-
3
- /**
4
- * events.js - lifecycle event names + per-event stdin payload builder.
5
- *
6
- * This is the contract surface from HOOK_ENGINE.md §1 (the six events) and §2
7
- * (the exact stdin JSON shape). Field names are FROZEN by §2's table - they must
8
- * match Claude Code's documented hook input byte-for-byte so the real hook
9
- * scripts run unchanged. Do not rename or add fields.
10
- *
11
- * CommonJS only. Zero host imports (core/ must run headless under node --test).
12
- */
13
-
14
- /**
15
- * The six lifecycle events v3 supports (HOOK_ENGINE.md §1).
16
- * Values equal the `hook_event_name` written into the stdin payload.
17
- * Frozen so callers can rely on identity comparison and can't mutate the set.
18
- */
19
- const EVENTS = Object.freeze({
20
- SessionStart: 'SessionStart',
21
- UserPromptSubmit: 'UserPromptSubmit',
22
- PreToolUse: 'PreToolUse',
23
- PostToolUse: 'PostToolUse',
24
- Stop: 'Stop',
25
- PreCompact: 'PreCompact',
26
- });
27
-
28
- // Set of valid event names for fast membership checks / validation.
29
- const EVENT_NAMES = Object.freeze(Object.values(EVENTS));
30
-
31
- /**
32
- * Build the stdin JSON object the runner pipes to a hook command.
33
- *
34
- * Every event carries the four common fields (HOOK_ENGINE.md §2):
35
- * session_id, transcript_path, cwd, hook_event_name
36
- * supplied by `ctx`. Each event then adds its own fields from `extra`:
37
- *
38
- * SessionStart -> source : "startup" | "resume" | "compact"
39
- * UserPromptSubmit -> prompt : "<user text>"
40
- * PreToolUse -> tool_name, tool_input
41
- * PostToolUse -> tool_name, tool_input, tool_response
42
- * Stop -> stop_hook_active : true | false
43
- * PreCompact -> trigger ("manual"|"auto"), custom_instructions
44
- *
45
- * @param {string} event one of EVENTS (the hook_event_name).
46
- * @param {object} ctx common fields: { session_id, transcript_path, cwd }.
47
- * @param {object} [extra] event-specific fields (see table above).
48
- * @returns {object} the stdin payload, with frozen field names per §2.
49
- * @throws {Error} if `event` is not one of the six known events.
50
- */
51
- function buildPayload(event, ctx, extra) {
52
- if (!EVENT_NAMES.includes(event)) {
53
- throw new Error(`buildPayload: unknown event "${event}"`);
54
- }
55
-
56
- const c = ctx || {};
57
- const e = extra || {};
58
-
59
- // Common fields - present on EVERY event, in the order §2 lists them.
60
- // hook_event_name is always the canonical event string (not whatever ctx held).
61
- const payload = {
62
- session_id: c.session_id,
63
- transcript_path: c.transcript_path,
64
- cwd: c.cwd,
65
- hook_event_name: event,
66
- };
67
-
68
- // Event-specific additions. Each branch sets EXACTLY the fields §2 names -
69
- // no more, no fewer - so the payload matches Claude Code's shape precisely.
70
- switch (event) {
71
- case EVENTS.SessionStart:
72
- payload.source = e.source;
73
- break;
74
-
75
- case EVENTS.UserPromptSubmit:
76
- payload.prompt = e.prompt;
77
- break;
78
-
79
- case EVENTS.PreToolUse:
80
- payload.tool_name = e.tool_name;
81
- payload.tool_input = e.tool_input;
82
- break;
83
-
84
- case EVENTS.PostToolUse:
85
- payload.tool_name = e.tool_name;
86
- payload.tool_input = e.tool_input;
87
- payload.tool_response = e.tool_response;
88
- break;
89
-
90
- case EVENTS.Stop:
91
- payload.stop_hook_active = e.stop_hook_active;
92
- break;
93
-
94
- case EVENTS.PreCompact:
95
- payload.trigger = e.trigger;
96
- payload.custom_instructions = e.custom_instructions;
97
- break;
98
-
99
- // No default: the membership check above already rejected unknown events.
100
- }
101
-
102
- return payload;
103
- }
104
-
105
- module.exports = { EVENTS, EVENT_NAMES, buildPayload };
1
+ 'use strict';
2
+
3
+ /**
4
+ * events.js - lifecycle event names + per-event stdin payload builder.
5
+ *
6
+ * This is the hook contract surface: the six lifecycle events and
7
+ * (the exact stdin JSON shape). Field names are FROZEN - they are the hook wire contract and must
8
+ * match Claude Code's documented hook input byte-for-byte so the real hook
9
+ * scripts run unchanged. Do not rename or add fields.
10
+ *
11
+ * CommonJS only. Zero host imports (core/ must run headless under node --test).
12
+ */
13
+
14
+ /**
15
+ * The six lifecycle events v3 supports.
16
+ * Values equal the `hook_event_name` written into the stdin payload.
17
+ * Frozen so callers can rely on identity comparison and can't mutate the set.
18
+ */
19
+ const EVENTS = Object.freeze({
20
+ SessionStart: 'SessionStart',
21
+ UserPromptSubmit: 'UserPromptSubmit',
22
+ PreToolUse: 'PreToolUse',
23
+ PostToolUse: 'PostToolUse',
24
+ Stop: 'Stop',
25
+ PreCompact: 'PreCompact',
26
+ });
27
+
28
+ // Set of valid event names for fast membership checks / validation.
29
+ const EVENT_NAMES = Object.freeze(Object.values(EVENTS));
30
+
31
+ /**
32
+ * Build the stdin JSON object the runner pipes to a hook command.
33
+ *
34
+ * Every event carries the same four common fields:
35
+ * session_id, transcript_path, cwd, hook_event_name
36
+ * supplied by `ctx`. Each event then adds its own fields from `extra`:
37
+ *
38
+ * SessionStart -> source : "startup" | "resume" | "compact"
39
+ * UserPromptSubmit -> prompt : "<user text>"
40
+ * PreToolUse -> tool_name, tool_input
41
+ * PostToolUse -> tool_name, tool_input, tool_response
42
+ * Stop -> stop_hook_active : true | false
43
+ * PreCompact -> trigger ("manual"|"auto"), custom_instructions
44
+ *
45
+ * @param {string} event one of EVENTS (the hook_event_name).
46
+ * @param {object} ctx common fields: { session_id, transcript_path, cwd }.
47
+ * @param {object} [extra] event-specific fields (see table above).
48
+ * @returns {object} the stdin payload, with the contract's frozen field names.
49
+ * @throws {Error} if `event` is not one of the six known events.
50
+ */
51
+ function buildPayload(event, ctx, extra) {
52
+ if (!EVENT_NAMES.includes(event)) {
53
+ throw new Error(`buildPayload: unknown event "${event}"`);
54
+ }
55
+
56
+ const c = ctx || {};
57
+ const e = extra || {};
58
+
59
+ // Common fields - present on EVERY event, in their contract order.
60
+ // hook_event_name is always the canonical event string (not whatever ctx held).
61
+ const payload = {
62
+ session_id: c.session_id,
63
+ transcript_path: c.transcript_path,
64
+ cwd: c.cwd,
65
+ hook_event_name: event,
66
+ };
67
+
68
+ // Event-specific additions. Each branch sets EXACTLY the fields the contract names -
69
+ // no more, no fewer - so the payload matches Claude Code's shape precisely.
70
+ switch (event) {
71
+ case EVENTS.SessionStart:
72
+ payload.source = e.source;
73
+ break;
74
+
75
+ case EVENTS.UserPromptSubmit:
76
+ payload.prompt = e.prompt;
77
+ break;
78
+
79
+ case EVENTS.PreToolUse:
80
+ payload.tool_name = e.tool_name;
81
+ payload.tool_input = e.tool_input;
82
+ break;
83
+
84
+ case EVENTS.PostToolUse:
85
+ payload.tool_name = e.tool_name;
86
+ payload.tool_input = e.tool_input;
87
+ payload.tool_response = e.tool_response;
88
+ break;
89
+
90
+ case EVENTS.Stop:
91
+ payload.stop_hook_active = e.stop_hook_active;
92
+ break;
93
+
94
+ case EVENTS.PreCompact:
95
+ payload.trigger = e.trigger;
96
+ payload.custom_instructions = e.custom_instructions;
97
+ break;
98
+
99
+ // No default: the membership check above already rejected unknown events.
100
+ }
101
+
102
+ return payload;
103
+ }
104
+
105
+ module.exports = { EVENTS, EVENT_NAMES, buildPayload };