For agents, on top of README.md, which they read first.
Notes for agents
jevhooks.js is generated: never edit it. mise run mod writes it from
crates/jevhooks-mod (cargo wasm32 build, wasm-opt -Oz, wasm2js -O2). Rebuild and
commit it with any change to jevhooks-mod or jevhooks-events, or the plugin runs
the old routing. Update jevhooks.d.ts by hand with any change to the crate's abi
exports.
Memory is touched only in bridge.ts. wasm2js swaps the module's ArrayBuffer
whenever memory grows, so a Uint8Array kept across a call into Rust can point at
storage the module has already replaced. Never export memory, or a view over it,
from bridge.ts; take every view from memory.buffer at the moment it is used.
$ calls live in top-level functions of register.tsx. Mods check $ calls from
the module source; a call written in a hook, or in a top-level function given $,
passes (claude plugin validate reports it "via" that function).
Hooks beneath this plugin still run. classic.PreToolUse and classic.Stop call
next(e) and combine: a deny beneath wins, then an ask, then this plugin's allow.
Returning without next would skip the user's own settings hooks.
The mod reaches the daemon with $.http.fetch and socketPath, not $.mcp.call.
$.mcp.call is an ordinary tool call: it goes through tool.call and the permission
check (refused outside bypass mode, whatever the type docs say), raises the plugin's
own hooks, and measured about 60 ms against 4 to 13.
Never compute the socket's path in TypeScript. socketPath asks locate(), which
is jevhooks_events::socket_path, the function the daemon's paths.rs calls. A path
written out here once drifted in waiting: the two had to be changed together, or every
event found no daemon and silently passed. It can also be undefined (no path short
enough for a socket), and then nothing is sent.
observe never rejects and is never awaited. Nothing waits on it, so nothing
could handle a rejection; keep the try/catch and the void.
Every failure to reach the daemon is undefined, which means pass. Do not turn a
missing socket into an error a hook throws.
Never wait with $.clock in a hook that may wait long. A hook has 10 s of its own
time per event; time inside next(e) and other $ calls is not counted, $.clock
waits are. The hold loop in decideCommand waits inside $.http.fetch (the daemon
keeps each request 1.5 s) for that reason; pacing it with $.clock.sleep would end a
45 s wait at 10 s.
chosen is a module variable on purpose. It holds a promise, which $.state
cannot. A reload starts it over as undefined, which is "the session's model stands"
until the next prompt. turn.step must stay an async function* that ends with
return yield* next(...): a plain function is a type error there.