jevhooks.git / plugin / hooks / README.md
README.mdpreviewREADME.mdsource128 lines · 6.0 KB · raw
1# Chapter 3: hooks, the mod that sits in the doorway
2
3This is the part of jevhooks that runs inside Claude Code. It is a
4**mod**: a TypeScript module whose `register` function is handed `on`, and
5calls `on(event, handler)` for each event it wants to hear. If you have
6written Express or Koa middleware you already know the shape, with one
7twist:
8
9```ts
10on('classic.Stop', async ($, e, next) => {
11  const [decision, beneath] = await Promise.all([decide($, 'Stop', ...), next(e)])
12  ...
13  return beneath
14})
15```
16
17`next(e)` runs whatever sits beneath this hook (other plugins, and the
18hooks in your own Claude Code settings) and **hands back their answer**.
19So the mod never replaces your hooks. It runs them alongside its own
20question and then combines the two, and the stricter answer wins. The
21`$` is the mod's only window on the world: `$.http.fetch`, `$.env.get`,
22`$.session.id`, `$.ui.log`, `$.clock.sleep`. A mod has no Node, no DOM, no
23`setTimeout`.
24
25[register.tsx](register.tsx) registers six hooks:
26
27| Hook | What it does |
28| --- | --- |
29| `session.start` | Registers the `/jev` command. |
30| `command.run` for `jev` | Prints the daemon's status and its last ten records. |
31| `classic.PreToolUse` | Asks the routing whether to judge this tool call; if so, sends it to the daemon and waits. |
32| `classic.Stop` | Sends the turn's end to the daemon and waits. |
33| `classic.*` | Every other event: asks the routing whether the daemon needs to hear of it, and if so sends it without waiting. |
34| `ui.render` for `AbovePrompt` | Draws the band above the prompt: the last decision. |
35
36## Combining verdicts
37
38The daemon answers a deciding event with a verdict: `allow`, `ask` or
39`pass`. Here is how `classic.PreToolUse` folds that into what the hooks
40beneath said:
41
42| Beneath says | Jev says `allow` | Jev says `ask` | Jev says `pass`, or no answer |
43| --- | --- | --- | --- |
44| deny | deny | deny | deny |
45| ask | ask, with their reason | ask, with their reason | ask |
46| nothing, or allow | allow | ask, with Jev's line | whatever beneath said |
47
48A deny beneath always wins; then any ask; then this plugin's allow. A
49`classic.Stop` that Jev judges "stopped early" becomes a `block` with Jev's
50line and "Finish what was asked, or say plainly what is stopping you.",
51unless something beneath already blocked.
52
53> **Aside: the 2.5 second rule.** The mod races every request to the
54> daemon against `$.clock.sleep(2500)`. The daemon gives up on Jev after
55> 1.5 s; this second limit is for a daemon that is itself wedged. If the
56> clock wins, the mod acts as if it were not installed. A slow judge is
57> never allowed to become a slow assistant.
58
59## Reaching the daemon
60
61`daemon()` calls `$.http.fetch('http://jevhooks/decide', { socketPath, ... })`.
62The host name is decoration; `socketPath` is what matters. It is
63`$XDG_STATE_HOME/jevhooks/daemon.sock` (or `~/.local/state/jevhooks/daemon.sock`),
64the same path the daemon's own `paths.rs` computes from the same two
65variables. Every failure, a
66missing socket or a refusal, comes back as `undefined`, which every caller
67treats as "pass".
68
69Every event sent carries an envelope: the session id, the event's name, its
70own fields, the working directory and the project root.
71
72## The bridge to Rust
73
74The routing decision ("judge this? report this? ignore it?") is written in
75Rust (chapter 10) and compiled into [jevhooks.js](jevhooks.js). Talking to
76compiled Rust means talking through its memory: a flat array of bytes.
77[bridge.ts](bridge.ts) does that in one function, `route()`:
78
79```mermaid
80sequenceDiagram
81  participant B as bridge.ts route()
82  participant W as jevhooks.js (Rust, as JS)
83  B->>W: alloc(len): somewhere to put the question
84  B->>W: write the JSON bytes into memory.buffer
85  B->>W: handle(ptr, len)
86  W-->>B: a pointer to the answer
87  B->>W: out_len(): how long it is
88  B->>B: read and JSON.parse the answer
89  B->>W: dealloc both
90```
91
92> **Aside: why memory is touched in exactly one file.** When the Rust side
93> needs more memory, wasm2js replaces the whole `ArrayBuffer` behind
94> `memory.buffer`. A `Uint8Array` made before that still points at the old
95> one, and reads garbage without complaint. So `bridge.ts` takes a fresh
96> view from `memory.buffer` at the moment it reads or writes, and exports
97> only `route()`: nothing outside it can hold a view at all.
98
99> **Try it.** In a session (`claude --plugin-dir ./plugin`, with the daemon
100> built), ask the assistant to run `git status`, then `cargo build`. The
101> first never reaches the daemon (the routing calls it plainly read-only),
102> so the band does not change. The second does: the band shows a line such
103> as `command: Jev: builds or tests (97%); undone by one command (1.0 of 3);
104> allowed`, the milliseconds it took, and the session's cost so far. Then
105> type `/jev`.
106
107## For the people who maintain it
108
109### In this folder
110
111| Path | What |
112| --- | --- |
113| [hooks.json](hooks.json) | Tells Claude Code which modules to load: `./register.tsx`. |
114| [register.tsx](register.tsx) | The hooks, the band, and `/jev`. |
115| [bridge.ts](bridge.ts) | The only code that touches the compiled module's memory; exports `route()` and the `Role` type. |
116| [jevhooks.js](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`. |
117| [jevhooks.d.ts](jevhooks.d.ts) | Types for `jevhooks.js`'s exports, kept by hand to match the crate's `abi` module. |
118
119The events the generic `classic.*` hook reports are whatever the routing
120calls `observe` (chapter 8): `UserPromptSubmit`, `PostToolUse`,
121`PostToolUseFailure`, `PermissionDenied` and `SessionEnd`. It reads the
122settings-hook field names (`tool_name`, `tool_input.command`) and sends
123`prompt`, `source` and `tool_use_id`.
124
125The band is hidden while a survey is showing and before the first
126decision; it is yellow for an `ask` and dim otherwise.
127
128← Previous: [Chapter 2, .claude-plugin/](../.claude-plugin/) · Up: [plugin](../) · Next: [Chapter 4, types/](../types/) →