jevhooks.git / crates / jevhooks-daemon

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:

CommandWhat it is
jevhooks serveThe daemon.
jevhooks mcpThe MCP server a Claude Code session starts (the plugin's .mcp.json).
jevhooks statusPrints the running daemon's status.
jevhooks recent [count]Prints the latest decisions and outcomes, oldest first (20 by default).
jevhooks stopAsks 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):

FileWhat
daemon.sockThe socket every session's mod and MCP server talk to.
daemon.lockHeld while a daemon runs, so two cannot serve at once.
daemon.logThe daemon's stderr.
decisions.jsonlEvery 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

status shows whether Jev can be asked (ready, no key or broken), 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:

RouteWhat
POST /decideA deciding event; answers with a Decision.
POST /observeAn event the daemon needs but does not answer.
GET /statusWhether Jev can be asked, the identity, counts, the ledger, the headroom source and the thresholds.
GET /recent?count=20The last records of decisions.jsonl, oldest first.
POST /shutdownStop, so a newer build can take the socket.

In this folder

PathWhat
src/Chapter 12: the questions, the verdicts, and the daemon's parts.
Cargo.tomlThe 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/ →