Chapter 11: jevhooks-daemon, one process for the machine
You might have three Claude Code sessions open, each about to run a command. Each could open its own connection to Jev, fetch the key, and keep its own log. Instead they all talk to one daemon: a small local server, one for the whole machine, listening on a Unix socket. It opens the connection to Jev once and keeps it warm, holds the key in one process and nowhere else, and writes every decision from every session to one log.
This crate builds one binary, jevhooks, that plays every part:
| Command | What it is |
|---|---|
jevhooks serve | The daemon. |
jevhooks mcp | The MCP server a Claude Code session starts (the plugin's .mcp.json). |
jevhooks status | Prints the running daemon's status. |
jevhooks recent [count] | Prints the latest decisions and outcomes, oldest first (20 by default). |
jevhooks stop | Asks the daemon to stop. |
Who starts the daemon
Nobody starts it by hand. Each session starts jevhooks mcp, and the MCP
server's first act is to make sure a daemon is running, and that it is the
right one:
sequenceDiagram
participant S as Claude Code session
participant M as jevhooks mcp
participant D as jevhooks serve
S->>M: start (stdio)
M-->>S: two tools, at once
M->>D: GET /status
alt same build answers
D-->>M: identity matches: done
else another build answers
M->>D: POST /shutdown, wait for the socket to close
M->>D: start one from this executable
else nothing answers
M->>D: start one from this executable
end
M->>D: GET /status until it answers (up to 30 s)
"The right one" means the same build. A daemon's identity is its
executable's device, inode, size and modification time, so the first
session after just daemon sees a different identity, retires the old
daemon and starts the new one. You never have to remember to restart it.
The MCP server does not wait for any of that before answering Claude Code:
fetching the key can take seconds, and a session should not wait on that to
list two tools. The two tools are status and recent, both read-only,
both just the daemon's own routes.
Aside: a socket, a lock, and a process group. Three small things keep "one daemon" true. The socket is a file in the state directory, so only processes on this machine, and with that user's permissions, can reach it. A lock file (
daemon.lock) is held for as long as a daemon runs, so a second one started by accident sees it, says so on stderr, and exits. And the daemon is started in its own process group, so the session that started it can end without taking it along.
The key
TYPESAFE_API_KEY reaches the daemon one of two ways: it is already in the
environment of the session that starts it, and is inherited; or it is not,
op-env-run is on PATH, and the daemon is started through it, which puts
the key in the daemon's environment and nowhere else. With neither, the
daemon still runs, asks Jev nothing, and every verdict is "pass"; status
says no key.
What it keeps
All under $XDG_STATE_HOME/jevhooks (~/.local/state/jevhooks when that is
not set):
| File | What |
|---|---|
daemon.sock | The socket every session's mod and MCP server talk to. |
daemon.lock | Held while a daemon runs, so two cannot serve at once. |
daemon.log | The daemon's stderr. |
decisions.jsonl | Every judgment (what was judged, every option's probability, the verdict, time and cost) and what became of each judged command (ran, failed, denied). This is the evidence for tuning the thresholds. |
Spend is booked in Jev's shared ledger, kept by jev-http under
$XDG_STATE_HOME/jev, whose guards apply across every program on the
machine that asks Jev: 30 questions a minute and $0.25 an hour by default
(JEV_MAX_QUESTIONS_PER_MINUTE, JEV_MAX_DOLLARS_PER_HOUR). A question the
ledger refuses is a "pass".
Its one setting
$XDG_CONFIG_HOME/jevhooks/config.toml (~/.config/jevhooks/config.toml).
The file is optional, and so is its one key:
# The headroom gate asks before a heavy command when too little memory is
# available. By default "available" is the kernel's own figure (MemAvailable
# on Linux), which is right on bare metal. Set a command where that figure is
# wrong or missing: inside a virtual machine that cannot see its host, or on a
# system with no such figure. It must print the megabytes available as its
# first number, within two seconds.
available_memory_command = "my-host-memory --megabytes"
A misspelt key is reported by status, and the daemon runs on the defaults:
a typo must not switch the gate off. Where there is neither a built-in
figure nor a command, a command's load is simply not judged; everything
else works.
Try it. Build it (
just daemon, which needs the rmcp fork) and start a session once, then from the repository root:plugin/bin/jevhooks status plugin/bin/jevhooks recent 5
statusshows whether Jev can be asked (ready,no keyorbroken), the pinned model, how many events it has decided and observed, the spend ledger's line, where the memory figure comes from and what it reads now, and every threshold that turns a probability into a verdict.
For the people who maintain it
Measured on Claude Code 2.1.287: the mod's call to the daemon adds 4 to 13 ms to the daemon's own 120 to 260 ms; the MCP server connects in 55 ms.
The daemon's routes, served with hyper as HTTP/1.1 on the socket:
| Route | What |
|---|---|
POST /decide | A deciding event; answers with a Decision. |
POST /observe | An event the daemon needs but does not answer. |
GET /status | Whether Jev can be asked, the identity, counts, the ledger, the headroom source and the thresholds. |
GET /recent?count=20 | The last records of decisions.jsonl, oldest first. |
POST /shutdown | Stop, so a newer build can take the socket. |
In this folder
| Path | What |
|---|---|
| src/ | Chapter 12: the questions, the verdicts, and the daemon's parts. |
| Cargo.toml | The jevhooks binary: hyper, tokio, rmcp, clap, toml, and jev-http/jev-protocol from third-party/jevcrates. |
← Previous: Chapter 10, jevhooks-mod/src/ · Up: crates · Next: Chapter 12, jevhooks-daemon/src/ →