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.