Skip to content

Plugin hooks

github-actions[bot] edited this page Oct 14, 2026 · 15 revisions

The plugin registers these hooks: SessionStart starts the daemon (Hooks and token savings), PreToolUse steers searches and whole-file reads, described here, SubagentStart makes the daemon forget what the worktree was read as (below), and PostToolUse checks a file just edited (Post-edit check).

Agents still reach for rg, grep, cat and whole-file reads. A hook runs outside the model and costs no tokens, so the plugin points those calls at the CodeLoupe call that answers them. All hooks go through one script (plugin/hooks/hook.sh: curl to POST /hook of the local daemon, a few hundred microseconds of work in the daemon) and fail open: no daemon, no curl, a daemon that does not know the repository, a malformed input or a timeout all mean "say nothing", never an error. The script calls the daemon without any proxy and prints only the three shapes the daemon writes (added context, a notice, a refusal with its reason); anything else that answers on the port is dropped, so a stranger there cannot approve or rewrite a tool call. When <home>/daemon.token exists the script first sends GET /status with a nonce and goes on only if the answer proves that the daemon holds the token (a process that took the port after a crash cannot, and then receives no prompt or command); the token goes to curl as a header file (-H @daemon.token), never on a command line. It needs sha256sum, shasum or openssl for that and does nothing without one. With no token file (a daemon from before the token) it behaves as it always did. The hook endpoint wants the token like the other routes, unless api.strict is set to false (Configuration).

Claude Code also has an http hook type, which would save starting bash (a call takes ~10 ms instead of ~80 ms on a busy Windows machine). The plugin does not use it: Claude Code takes the reply as it is, so whatever listens on the port when the daemon is down could approve or rewrite a tool call; the URL takes no variables, so a daemon on another port would still need the script; and a hook cannot be switched off for the default port only, so both handlers would run. Details in docs/plan.md (CL-146).

Steering (PreToolUse on Bash, PowerShell and Read). It looks at the call:

Call Answer
rg/grep/git grep/Select-String for class Foo, fun bar find q="Foo" (with kind)
the same for a name (OrderService, parseConfig(, MAX_SIZE) usages name="OrderService"
the same for a string, SQL or a regular expression grep pattern="…" (regex=true, ignoreCase=true)
find -name '*Service.kt', rg --files -g '*.kt' find q="*Service", or outline (the repository map) for every Kotlin file
cat, bat, head -n 300, tail -n +50, sed -n '1,400p', Get-Content of a source file of 150 lines or more, and Read of one without offset/limit outline target="path/File.kt", then symbol for one declaration

It stays out of the way when the daemon is not running or has not indexed the repository, when the file is not in the index or has fewer lines than minLines, when the command is not about Kotlin or Java source (builds, git, .md/.json files, a search of a pipe's output, a search whose output feeds an edit such as rg -l Foo | xargs sed -i …, a read cut short by head/grep), when a read has offset or limit, and when the same command comes again in the session (the agent insisted). A session gets at most maxPerSession pieces of advice, and none after giveUpAfter in a row without a CodeLoupe call on that repository in between: an agent that cannot or will not use the tools is not nagged, and one that does use them is advised again.

Modes, in <home>/config.json (read on every call, no restart):

{ "hooks": { "enabled": true, "steer": { "mode": "advise", "minLines": 150, "maxPerSession": 40, "giveUpAfter": 4 },
             "sessionStart": { "enabled": true, "map": false, "budget": 1200, "changes": true, "changesLimit": 12 },
             "weight": { "enabled": true, "warnAt": [150000, 300000], "top": 3 },
             "postEdit": { "enabled": false, "maxLines": 5, "timeoutMs": 300 } } }
  • advise (default): the command runs and the model reads the equivalent call next to its result (additionalContext; permissions are not touched).
  • redirect: a command that plainly concerns source files (a *.kt glob, --type kotlin, a source file as target) is refused the first time with the equivalent call; the same command again runs. Other commands are only advised.
  • off: no steering. "hooks": { "enabled": false } (or "hooks": false) turns off every hook of the plugin at once, and so does CODELOUPE_HOOKS=off in the environment of Claude Code.

Measured on a reviewer agent run headless (4-6 runs per mode, Agent runs): in advise the hook spoke 6-8 times per run and the agent made 0 CodeLoupe calls in 6 of 6 runs; in redirect 4 of 4 runs used the tools (5.3 calls per run), with the needed tools in the agent's tools: list. advise stays the default because redirect turns a first rg into a refusal; an agent that should use the tools needs redirect, or a routing table in its own instructions (Your own agents).

codeloupe metrics hooks --since 2026-10-01 counts the advice from <home>/hooks.jsonl (kind, suggested tool, session prefix, no command text) and how many were followed by a CodeLoupe code call on the same worktree within 180 s of the advice (calls.jsonl); /status has hooks (calls, advised, denied, why the rest was left alone, median and p95 ms of the decision). codeloupe metrics hooks --replay --since 2026-10-01 runs the shell and read calls of old transcripts through the same decision and prints how many it would advise and with which call (counts only; sizes from the transcript's own results).

Subagents and what was already read

issue, task_context, doc and dispatch_plan answer a repeated read from the same root with unchanged since your read. A subagent shares the root (and the MCP connection) with the window that started it, so without help its first read could be told that. The SessionStart and SubagentStart hooks send the working directory of the new session or subagent to the daemon, which drops the reads it remembers for that worktree (the root itself, a folder inside it or one around it): the new agent's first read is a whole one (the agent that read before also gets one whole answer, then one-line answers again). The hook needs no setting and costs no model tokens; without the plugin hooks (or with CODELOUPE_HOOKS=off) the old behaviour stays and since=none is the way to get the content.

Session start

The SessionStart hook (startup, resume, clear, compact) starts the daemon as before and then asks it for the context a session begins with, so the first turns need not go to ls, find and git status. It speaks only for a git repository the daemon has already indexed (it never starts a build), and says nothing otherwise.

  • State of the worktree (always, when the hook is on): CodeLoupe orientation for <worktree>: branch TER-5-x (task TER-5), default branch main, then what the worktree changed against the merge-base, by declaration (changes without the callers, at most changesLimit lines).
  • Map ("map": true; off by default, see the measurement in docs/plan.md): the ranked repository map (outline without a target) within budget tokens (default 1200, 3.2 characters a token as outline counts), centred on the files the worktree changed. A resumed or compacted session gets the state alone; it has seen the map.

sessionStart.enabled: false turns this hook off alone; hooks.enabled: false and CODELOUPE_HOOKS=off turn off all of them. codeloupe metrics orientation --since 2026-10-01 counts ls/find/tree/Glob calls in the first 8 turns of sessions, with and without the hook's context in the transcript, to show whether the map pays for itself. hooks.jsonl records each start (kind, tokens, milliseconds; no text).

Account stamp

The SessionStart and SubagentStart hooks also note which Claude login runs the session, so /usage snapshots and runs of two logins that share ~/.claude can be told apart (codeloupe metrics limits, In the desktop app). The session's own environment decides: hook.sh adds the account uuid Claude Desktop gives its sessions (CLAUDE_CODE_ACCOUNT_UUID), the account a launcher started it on (CODELOUPE_ACCOUNT, an id or e-mail of accounts.json, mapped to the identity declared there) and whether it signs in with a token of its own (CLAUDE_CODE_OAUTH_TOKEN, never its value). Only a session with none of these reads the account uuid of the .claude.json of its config directory (from the transcript_path; ~/.claude.json for the default directory): that is the login the CLI signs in with, while a desktop or token session may run as another login than the file names, which holds whichever was written last. The daemon appends session id -> 12-character fingerprint of the uuid to <home>/session-identities.jsonl; a newer stamp of a session replaces the identity stored for it. Neither the uuid nor the e-mail is written anywhere. The work is queued on a background thread, so the hook answers at once; any failure leaves the session without a stamp and never touches the session. It needs no sessionStart setting: hooks.stampAccount (default true) turns it off alone, hooks.enabled: false and CODELOUPE_HOOKS=off turn it off with every other hook. A subagent belongs to the session that started it and adds a stamp only for a session the daemon has not seen start.

Session weight

A long session is expensive to continue: every turn reads the whole context again. The UserPromptSubmit and Stop hooks ask the daemon for the weight of the session's transcript (transcript_path of the hook input) and, the first time the context reaches each size in weight.warnAt (default 150 000 and 300 000 tokens), show the user one line:

CodeLoupe: this session carries ~210k tokens, 62 % of it in 3 tool results (Read in turn 12, Bash in turn 13, Read in turn 14). Each turn reads it again: /compact, or start a new session when the task changes.

When single results hold little (under 15 %) the line says so (only 7 % of it in the 3 tool results that weigh most): the weight is the conversation itself. The line goes to the user (systemMessage), not to the model, and nothing is ever blocked. A size is announced once per session, whichever of the two events sees it first, also across a daemon restart (<home>/weight-warned.txt); after /compact the sizes count again. Below the first size the hook is silent.

The daemon reads the transcript incrementally with the parser of the metrics (a call reads only what was appended; a transcript far ahead of what was read, as in a session seen for the first time, is read in the background and answered from its tail meanwhile). Only names of tools and turn numbers leave the transcript. GET /session-weight?path=<transcript>.jsonl gives the same figures as JSON (contextTokens, turns, heavy, level, complete). weight.enabled: false turns this hook off alone. codeloupe metrics weight --since 2026-10-01 [--longest 20] replays the longest sessions of old transcripts through the verdict and prints at which turn each would have been warned.

Post-edit check

PostToolUse on Edit, Write and MultiEdit of a .kt or .java file the daemon has indexed. The edited file is read from disk and parsed; what the parser and the index say about it comes back as context next to the tool result, in at most maxLines lines (default 5, header included), or nothing when the file is clean. It is the parser and the index, not a compiler, and every answer says so. It is off ("postEdit": {"enabled": true} turns it on) until the controlled coder runs adopt it (docs/plan.md, CL-217). It never starts a build, a repository the daemon has not indexed gets nothing, and a check that takes longer than timeoutMs says nothing (the first parse after the parse worker was idle takes longer). The same findings are told once per session.

Finding Example line
syntax error (Kotlin and Java), with line and column 12:5 syntax error: Expecting ')'
a name nothing declares (Kotlin; an unqualified call or type that no file declares, imports or mentions) 40:9 unknown function fetchOrderByIdd: nothing in the index declares it and the file does not import it
a name declared only in another package, not imported 7:20 Money is not imported: it is declared as com.example.shop.Money (Money.kt:5)
wrong number of arguments for a function of the index (Kotlin) 31:12 total called with 3 argument(s), but it takes 1 to 2 (Orders.kt:4)

A syntax error is told alone: names in a file that does not parse are noise. It stays silent where it cannot be sure: a file with a star import of a package the index does not know, a name inside a class with supertypes outside the index or inside an extension function, names Kotlin and the JDK provide, qualified calls unless the receiver is written as the type that declares the function. Measured on the project's own sources and on another Kotlin backend (3,300 files), clean files got an answer in 0-0.1 % of cases; see docs/plan.md (CL-217) for the figures.

Clone this wiki locally