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 eight 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, and keeps asking while the answer is hold. |
prompt.submit | Sends a prompt a person wrote to the daemon for a model, without waiting. |
turn.step | Gives each model request of the main turn to the model the daemon chose, if it chose one. |
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. |
Here is what that looks like from your chair. An ordinary command runs without a prompt, and the band above the prompt says what Jev made of it:
A command that is hard to undo is put to you, with Jev's reason in the dialog:
(Both pictures, and the two in chapter 14, are captured from a real
session by mise run screenshots; chapter 16 says how.)
Combining verdicts
The daemon answers a deciding event with a verdict: allow, ask,
pass, or for a command, hold (below). 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.
Holding, and choosing a model
hold means the machine has no room for this command yet (the waiting
room, chapter 14). decideCommand simply asks again, and again, until the
verdict is something else. It is not a busy loop: the daemon keeps each
request for a second and a half before it says hold, so the mod's side
of a wait is a handful of slow requests. After a minute of holds the mod
stops and asks you, though the daemon will have done so itself at 45 s.
Aside: whose time is it? Claude Code gives a hook ten seconds of its own time per event, and a command might wait forty. The trick is in what counts: time spent inside
next(e)or a$call such as$.http.fetchis not the hook's, with one exception,$.clockwaits, which are. So the waiting is done by the daemon, inside a fetch, and a$.clock.sleepbetween requests would have been the one way to get it wrong.
Choosing a model takes two hooks, because the two halves of the job
happen at different moments. prompt.submit has the prompt's text but
must not delay the prompt, so it starts the question and keeps the
promise, chosen, without waiting for it. turn.step fires just before
each request to a model and may name another (next({ ...e, model })),
but it has no idea what you typed; it awaits chosen and uses it. By the
time the first request is ready to leave, Jev has usually answered.
Only a prompt a person wrote is put to the daemon (fromAPerson: typed
here, sent from a phone, or submitted for the user by another plugin). A
wakeup, a task notification or another session's message keeps the model
the turn before it had. A subagent's requests are never touched.
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),
and the mod does not work that out itself: locate() in bridge.ts asks the
same Rust function the daemon uses, so the two cannot disagree. A Unix
socket's path has a hard length limit (103 bytes here); when the state
directory's is too long the socket is in $XDG_RUNTIME_DIR/jevhooks
instead. 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. Withchoose_model = truein the daemon's config, the band after each prompt reads likeprompt: Jev: a lookup (0.1 of 3); haiku.
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(), locate() and the Role type. |
| jevhooks.js | Generated by mise run 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. |
| band.svg, ask.svg | Generated by mise run screenshots (chapter 16): the pictures above. |
| 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. While a command
waits, it shows the daemon's line: what the command needs, what there is
to spare, and how long it has waited.
← Previous: Chapter 2, .claude-plugin/ · Up: plugin · Next: Chapter 4, types/ →