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:

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.
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.

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 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.

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 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.

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() and the Role type.
jevhooks.jsGenerated 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.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.

← Previous: Chapter 2, .claude-plugin/ · Up: plugin · Next: Chapter 4, types/ →