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:

HookWhat it does
session.startRegisters the /jev command.
command.run for jevPrints the daemon's status and its last ten records.
classic.PreToolUseAsks 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.submitSends a prompt a person wrote to the daemon for a model, without waiting.
turn.stepGives each model request of the main turn to the model the daemon chose, if it chose one.
classic.StopSends 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 AbovePromptDraws 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:

The band above the prompt after a command Jev allowed

A command that is hard to undo is put to you, with Jev's reason in the dialog:

The permission dialog carrying Jev's line

(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 saysJev says allowJev says askJev says pass, or no answer
denydenydenydeny
askask, with their reasonask, with their reasonask
nothing, or allowallowask, with Jev's linewhatever 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.fetch is not the hook's, with one exception, $.clock waits, which are. So the waiting is done by the daemon, inside a fetch, and a $.clock.sleep between 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 ArrayBuffer behind memory.buffer. A Uint8Array made before that still points at the old one, and reads garbage without complaint. So bridge.ts takes a fresh view from memory.buffer at the moment it reads or writes, and exports only route(): 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 run git status, then cargo 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 as command: 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. With choose_model = true in the daemon's config, the band after each prompt reads like prompt: Jev: a lookup (0.1 of 3); haiku.

For the people who maintain it

In this folder

PathWhat
hooks.jsonTells Claude Code which modules to load: ./register.tsx.
register.tsxThe hooks, the band, and /jev.
bridge.tsThe only code that touches the compiled module's memory; exports route(), locate() and the Role type.
jevhooks.jsGenerated 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.svgGenerated by mise run screenshots (chapter 16): the pictures above.
jevhooks.d.tsTypes 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/ →