Chapter 3: hooks, the mod that sits in the doorway
This is the part of jevhooks that runs inside Claude Code. It is a
mod: a TypeScript module whose register function is handed on, and
calls on(event, handler) for each event it wants to hear. If you have
written Express or Koa middleware you already know the shape, with one
twist:
on('classic.Stop', async ($, e, next) => {
const [decision, beneath] = await Promise.all([decide($, 'Stop', ...), next(e)])
...
return beneath
})
next(e) runs whatever sits beneath this hook (other plugins, and the
hooks in your own Claude Code settings) and hands back their answer.
So the mod never replaces your hooks. It runs them alongside its own
question and then combines the two, and the stricter answer wins. The
$ is the mod's only window on the world: $.http.fetch, $.env.get,
$.session.id, $.ui.log, $.clock.sleep. A mod has no Node, no DOM, no
setTimeout.
register.tsx registers six hooks:
| Hook | What it does |
|---|---|
session.start | Registers the /jev command. |
command.run for jev | Prints the daemon's status and its last ten records. |
classic.PreToolUse | Asks the routing whether to judge this tool call; if so, sends it to the daemon and waits. |
classic.Stop | Sends the turn's end to the daemon and waits. |
classic.* | Every other event: asks the routing whether the daemon needs to hear of it, and if so sends it without waiting. |
ui.render for AbovePrompt | Draws the band above the prompt: the last decision. |
Combining verdicts
The daemon answers a deciding event with a verdict: allow, ask or
pass. Here is how classic.PreToolUse folds that into what the hooks
beneath said:
| Beneath says | Jev says allow | Jev says ask | Jev says pass, or no answer |
|---|---|---|---|
| deny | deny | deny | deny |
| ask | ask, with their reason | ask, with their reason | ask |
| nothing, or allow | allow | ask, with Jev's line | whatever beneath said |
A deny beneath always wins; then any ask; then this plugin's allow. A
classic.Stop that Jev judges "stopped early" becomes a block with Jev's
line and "Finish what was asked, or say plainly what is stopping you.",
unless something beneath already blocked.
Aside: the 2.5 second rule. The mod races every request to the daemon against
$.clock.sleep(2500). The daemon gives up on Jev after 1.5 s; this second limit is for a daemon that is itself wedged. If the clock wins, the mod acts as if it were not installed. A slow judge is never allowed to become a slow assistant.
Reaching the daemon
daemon() calls $.http.fetch('http://jevhooks/decide', { socketPath, ... }).
The host name is decoration; socketPath is what matters. It is
$XDG_STATE_HOME/jevhooks/daemon.sock (or ~/.local/state/jevhooks/daemon.sock),
the same path the daemon's own paths.rs computes from the same two
variables. Every failure, a
missing socket or a refusal, comes back as undefined, which every caller
treats as "pass".
Every event sent carries an envelope: the session id, the event's name, its own fields, the working directory and the project root.
The bridge to Rust
The routing decision ("judge this? report this? ignore it?") is written in
Rust (chapter 10) and compiled into jevhooks.js. Talking to
compiled Rust means talking through its memory: a flat array of bytes.
bridge.ts does that in one function, route():
sequenceDiagram participant B as bridge.ts route() participant W as jevhooks.js (Rust, as JS) B->>W: alloc(len): somewhere to put the question B->>W: write the JSON bytes into memory.buffer B->>W: handle(ptr, len) W-->>B: a pointer to the answer B->>W: out_len(): how long it is B->>B: read and JSON.parse the answer B->>W: dealloc both
Aside: why memory is touched in exactly one file. When the Rust side needs more memory, wasm2js replaces the whole
ArrayBufferbehindmemory.buffer. AUint8Arraymade before that still points at the old one, and reads garbage without complaint. Sobridge.tstakes a fresh view frommemory.bufferat the moment it reads or writes, and exports onlyroute(): nothing outside it can hold a view at all.
Try it. In a session (
claude --plugin-dir ./plugin, with the daemon built), ask the assistant to rungit status, thencargo build. The first never reaches the daemon (the routing calls it plainly read-only), so the band does not change. The second does: the band shows a line such ascommand: Jev: builds or tests (97%); undone by one command (1.0 of 3); allowed, the milliseconds it took, and the session's cost so far. Then type/jev.
For the people who maintain it
In this folder
| Path | What |
|---|---|
| hooks.json | Tells Claude Code which modules to load: ./register.tsx. |
| register.tsx | The hooks, the band, and /jev. |
| bridge.ts | The only code that touches the compiled module's memory; exports route() and the Role type. |
| jevhooks.js | Generated by just mod from crates/jevhooks-mod (chapter 9): wasm2js output, about 21,700 lines. Committed, because the plugin loads it; never edited by hand. Exports memory, alloc, dealloc, handle, out_len. |
| jevhooks.d.ts | Types for jevhooks.js's exports, kept by hand to match the crate's abi module. |
The events the generic classic.* hook reports are whatever the routing
calls observe (chapter 8): UserPromptSubmit, PostToolUse,
PostToolUseFailure, PermissionDenied and SessionEnd. It reads the
settings-hook field names (tool_name, tool_input.command) and sends
prompt, source and tool_use_id.
The band is hidden while a survey is showing and before the first
decision; it is yellow for an ask and dim otherwise.
← Previous: Chapter 2, .claude-plugin/ · Up: plugin · Next: Chapter 4, types/ →