What the four hooks do, what a hooks.d/ listener is allowed to say, and why SessionEnd flushes but never writes a handoff. Moved verbatim from the README.
The plugin registers four Claude Code hooks:
| Hook | Script | Purpose |
|---|---|---|
SessionStart |
session-start-hook.sh |
Loads memory files into context (identity only at source=compact), recovers missed sessions, occasionally a cross-plugin systemMessage (#574) |
UserPromptSubmit |
user-prompt-hook.sh |
Injects current timestamp so the agent knows the time |
PostToolUse |
post-tool-hook.sh |
Auto-saves session when tool call delta exceeds threshold |
SessionEnd |
session-end-hook.sh |
Unconditionally flushes whatever PostToolUse has not yet saved (#345) |
SessionEnd ignores the cooldown and min-human-message gates the other saves respect — it is the last chance a session gets, not a routine tick — but it does not write a handoff note; see below for why.
SessionStart sources log.sh for shared config, timezone, logging, and the dispatch() system; PostToolUse does too on the run that resolves, and replays that resolution on the rest (see below). Hooks dispatch lifecycle events (e.g., after_user_prompt) to extensible listeners in hooks.d/. Installing a listener for an event puts that hook back on the full chain, because dispatch() lives in log.sh — the fast paths only skip it when there is nothing executable to dispatch to.
SessionStart is also the only hook that ever writes systemMessage on its own account, distinct from every hooks.d/ listener's stdout above: once per cooldowns.promo_seconds (default 7 days) it names one sibling Digital-Process-Tools plugin you have not installed, in a channel the model never reads. Copy lives in promos.json (features.plugin_promos turns it off), and the rendered line is prefixed claude-remember: and suffixed with that key, so a reader can tell who spoke and switch it off without leaving the terminal (#631); see Configuring it. systemMessage is emitted raw, so any attribution has to come from this hook. The rendered length is capped at 170 characters -- an entry over budget is skipped and the skip is logged, never a truncated line. This is the one case where the hook's own stdout becomes a JSON object ({"hookSpecificOutput": {...}, "systemMessage": "..."}) instead of the plain text every other SessionStart run still prints byte-for-byte.
Two of these events deliver their listeners' stdout to the model: after_user_prompt becomes additionalContext on every prompt, and after_session_start is printed into the session's opening context. Contributing context is the point of both, so stdout is delivered — but it is delivered as yours, never as the plugin's (#280):
- Every line you print is prefixed
[hook]. An unprefixed line in dispatched output is the plugin speaking, and a hook cannot produce one — including a hook that prints something that looks exactly like the plugin's own framing, or like the frame that would end its own region. Write for a reader who can see which lines are yours; do not draw banners that assume they are not marked. - The plugin frames your block with an unprefixed
=== hooks.d: <event>/<script> … ===line naming your script. A hook that prints nothing gets no frame and no marker, and costs the prompt nothing. - Your stdout is capped at 200 lines and 2000 characters per line, and the cap announces itself — a trailing frame line says how many lines were not shown. Nothing is ever shortened silently. If your listener needs to say more than 200 lines to the model on every prompt, the context window is the wrong channel for it.
- stdout is for the model; stderr is for the humans. A listener that exits non-zero has its first five stderr lines written to
hook-errors.logwith its exit status (#277). A listener that exits 0 is not reported anywhere, by design — this runs on every tool call. - A listener that does not return is stopped, and the stop is reported as an unknown, not a failure. The budget is
hooks.dispatch_timeout_seconds(15s for the events the agent waits on) orhooks.dispatch_timeout_detached_seconds(120s for the save and consolidate events, which nobody waits on), per listener rather than per dispatch. You getSIGTERMfirst andSIGKILLafterhooks.dispatch_kill_grace_seconds, so a listener holding a lock or a temp file can unwind it from anEXITtrap — anything it leaves half-done is its own to clean up, since nothing here can know what a third-party listener was in the middle of. The report names the listener and the budget and says outright that whether it did its work is unknown; it is not given an exit status, because the one it died with is the signal the plugin sent, not an answer the listener gave. - Your detached work is deliberately left alone. The signal goes to your script's own PID, never its process group, so a
( … ) &you disowned keeps running — that is how both shipped listeners do their git I/O, and killing it is how a timeout would turn an indefinite stall into a corrupt store. The cost is the other way round: a listener blocked in a foreground child leaks that child when the script is killed. Put anything slow in the background yourself. - Close every extra file descriptor when you detach, not only stdin/stdout/stderr (#646).
nohup … </dev/null >/dev/null 2>&1 &redirects fds 0, 1 and 2 and nothing else. A hook that has doneexec 3>&1still has the client's own pipe on fd 3, your detached child inherits it, and the client — which is reading to EOF, not waiting on the hook's PID — sees EOF only when your child exits.session-start-hook.shshipped exactly that: the hook returned in milliseconds and the caller sat there for the 93 seconds consolidation took, which on the VS Code extension's 60s subprocess-init deadline is an editor that will not open, under an error message about authentication and network connectivity. Add3>&-(and any other fd you have opened) to the redirect list.tests/test_detached_spawn_fd_hygiene_648.pychecks every shell script in the repo for exactly this and times each spawn site against a stubbed slow child, so a new leak fails the suite rather than a user's editor launch. - A listener that is not owned by you, or is group/world-writable, is refused and never runs. That refusal is now written to
hook-errors.logas well as the daily log, so/remember:doctorshows it instead of reporting OK.
Nothing here bounds what a hook can do — it runs as you, with your environment. What it bounds is what a hook can appear to be once its output reaches the model.
UserPromptSubmit and PostToolUse are the exceptions, and deliberately so: they run on every prompt and every tool call respectively, and the agent waits for both. Rather than re-derive their inputs through the full chain (git rev-parse, a slug, a three-layer config merge — 19 processes, and 27 on Windows/ARM64 under QEMU, where it cost a p50 of 8.7s per prompt), they replay the resolution a previous hook already published, via lib-env-cache.sh. The cache is refused unless it is newer than every config.json layer and was written for the same project, plugin root and HOME, so editing config still takes effect on the next prompt. The cache key falls back to REMEMBER_HOOK_CWD when CLAUDE_PROJECT_DIR is unset (#469) — at the time, Codex and Gemini CLI were both believed to never set the latter, and without the fallback the fast path was permanently dead on both: a resolution was still published on every slow-path run, keyed on a variable that would never again be set when the next invocation went looking for it, so the cache was written every time and hit never. Gemini CLI's own bundled docs now say it DOES set CLAUDE_PROJECT_DIR, as a compatibility alias (#456, unverified live — #532) — Codex's own non-setting stays live-confirmed (tests/fixtures/codex-env-463.txt, #463) — so on Gemini the cache is expected to key on CLAUDE_PROJECT_DIR directly and never need this fallback at all (#534); the fallback itself is unchanged and stays correct for Codex and any other host that genuinely leaves the variable unset. That fallback was itself inert in user-prompt-hook.sh until #479: unlike post-tool-hook.sh, which already read REMEMBER_HOOK_CWD from stdin ahead of its own cache lookup, user-prompt-hook.sh set it from stdin only inside the "cache missed" branch -- only after the lookup that variable was meant to key had already failed for want of it. user-prompt-hook.sh's stdin read now runs unconditionally too, ahead of the cache lookup, matching post-tool-hook.sh's ordering. Both fall back to the full chain whenever it declines — including when you install a listener for the event they dispatch, which needs dispatch(). Set REMEMBER_ENV_CACHE=0 to turn it off (#227, #350). The cache key itself is now normalized the same way resolve-paths.sh normalizes a Windows-native project path, before it is ever pinned (#504): without that, user-prompt-hook.sh's fast-path lookup (which runs before resolve-paths.sh has normalized anything) and session-start-hook.sh's publish (which runs after) could key the same project under two different spellings on a Windows drive-letter path, so the fast path would never hit what the slow path wrote.
PostToolUse registers with no matcher, so it is the hottest hook in the plugin — tool calls outnumber prompts roughly ten to one. On macOS/bash 3.2 a warm tool call costs 6 external spawns instead of 14 (130 ms instead of 336 ms); the reporter who filed #350 measured 750-1000 ms per tool call on Windows 11 / Git Bash before the change. The merged config file itself is still never cached — it can carry a live OAuth token and is 0600, freshly named every invocation, for that reason — only the two numbers this hook reads out of it. The first tool call of a session, and the first after any config edit, still takes the whole chain and publishes it.
PostToolUse, UserPromptSubmit and SessionEnd read their JSON payload from stdin in large blocks, not a byte at a time (#1040). bash reads a pipe one byte per system call, and on Windows every one of those calls is slow, so a PostToolUse call whose tool returned 1 MB of text spent about 25 s just reading it (measured by @gwangyong-kim on the Windows MSYS bash 5.2). scripts/lib-stdin.sh reads with read -N where bash has it (4.1 and later) and keeps the old line-by-line loop for the bash 3.2 that ships with macOS, where that loop costs about a second per MB. SessionStart keeps the line loop: its payload has no tool output or prompt text in it, only a few hundred bytes, and its compiled hook has no room left under the 120 KiB release budget for the library. Either way each read still gives up after one second of silence, a terminal on stdin is never read, and the text the hooks see is the same as before: newlines removed, as the line loop always did.
All four are registered together, from hooks/hooks.json, when the session starts — which is why enabling the plugin mid-session wires up none of them (see the install note above).
session-end-hook.sh forks save-session.sh --force into the background, once, when the session ends, and returns immediately — the same fork pattern post-tool-hook.sh already uses, and for the same reason Claude Code documents for its own hook budget: Claude Code kills a hook at 60s of its own accord, and save-session.sh's own Haiku call already asks for up to 120s (180s for NDC compression) — past 60s on exactly the long, content-heavy sessions this hook exists to rescue. Running the flush in the foreground and waiting on it would risk losing the entire flush to that kill, silently. --force bypasses the save cooldown and the min-human-message gate — the two gates that exist to throttle a live session's routine saves and that can otherwise leave a session's entire final stretch (a design discussion, a review, a decision — often the part worth keeping) unsaved if nothing after the last save cleared them (#345). It still costs nothing extra when there is genuinely nothing new: the zero-exchange gate is not bypassed, so a session with no unsaved content just advances its position.
It does not write a handoff note. /remember composes remember.md from the model's own first-person recollection of the session; there is no model turn running at SessionEnd for a hook to narrate from. A fabricated placeholder would silently overwrite a real handoff written earlier in the same session with something that carries no forward-looking content — worse than leaving the existing file alone, and adjacent to (not a fix for) #341's stale-delivery-count problem. Run /remember yourself before ending a session you want a narrated handoff for; this hook is the safety net under now.md, not a replacement for that skill.
When SessionEnd actually fires is only partially documented. The Claude Code hooks reference (checked 2026-08) documents reason values clear, resume, logout and prompt_input_exit — the graceful exits — and is silent on a crash, a killed terminal, or a session ending by hitting its usage cap. This hook cannot make SessionEnd fire where Claude Code itself would not invoke it, so features.recovery's next-session-start repair stays in place regardless: it is what still covers the abrupt endings this hook cannot reach.
A flush failure (missing python3, a Haiku call that errors, or — since #369 — --force still unable to take save.lock after a bounded wait, e.g. because post-tool-hook.sh's own background save from the last tool call is still running) is reported via the same channel /remember:doctor already reads (hook-errors.log) rather than swallowed silently, unlike the plugin's other hooks — this one is the last chance a session gets, so a failure here has nowhere left to retry from. Before #369, that lock case in particular reported nothing at all: --force used a 0-second lock timeout and exited 0 on contention exactly as a genuine "nothing new to flush" does, so this hook's whole failure-reporting guarantee did not cover the one failure most likely to hit it. It now waits (REMEMBER_FORCE_LOCK_TIMEOUT, default 30s) before giving up, and only then reports. Because the flush is backgrounded, that report lands once the background process finishes, not synchronously with the hook itself — the same trade the git-backup hook's own detached push already makes.
A store that could never be created at all (a read-only or otherwise unwritable project root) is a narrower case than a flush failure — there is no hook-errors.log to write to, because the directory that would hold it is the one that failed. That one warning goes to this hook's own stderr instead (#372); it will not show up in /remember:doctor.
SessionEnd shares a 1.5-second budget across every hook registered for that event, and this hook can miss it before it ever reaches the fork (#560). The Claude Code hooks reference (checked 2026-09, "SessionEnd" / "Timeouts") documents that budget, and one reporter measured this hook's own synchronous preamble — lib-clock.sh, resolve-paths.sh, detect-tools.sh's python/jq detection, bootstrap-dirs.sh, log.sh — at roughly 3.4 seconds on a slow Windows/Git-Bash machine, well past 1.5s, with Claude Code logging Hook cancelled and no session-end-*.log ever appearing. hooks/hooks.json now declares "timeout": 10 on this hook, which is the correct, harmless thing to declare — but the same reference is explicit that "Timeouts set on plugin-provided hooks don't raise the budget." This hook ships as hooks/hooks.json inside a plugin, exactly the location that clause names, so on an install where Claude Code enforces that carve-out, declaring timeout here does not, by itself, raise the effective 1.5s ceiling.
If your own session-end-*.log files are still missing and your terminal still shows Hook cancelled after upgrading past this fix, the plugin genuinely cannot raise the SessionEnd budget on your behalf — set it yourself with the environment variable the same reference documents:
export CLAUDE_CODE_SESSIONEND_HOOKS_TIMEOUT_MS=10000(10 seconds, matching the timeout this hook declares; the reference caps the SessionEnd budget at 60000 regardless of what any hook or this variable asks for.)
Since #647, the hook no longer needs that budget at all. The process Claude Code invokes reads its stdin and re-launches itself detached -- preamble, trace seed and flush all run in the child -- then exits, in tens of milliseconds on any machine and on any install route. The declared timeout and the environment variable above stay as belt-and-braces, but nothing this hook does on the clock Claude Code is watching depends on them any more. The earlier version of this paragraph said making the preamble fit inside 1.5s was "tracked separately"; it was not, and moving the preamble off the budget entirely turned out to be the smaller change.
Two things follow from the detach, both deliberate:
- The
session-end-*.logtrace is seeded before the flush is attempted, not after. It used to be written immediately before the backgrounded flush, below every early exit in the hook -- so a store that could not be created, or asave-session.shmissing from a half-finished install, left the same emptylogs/autonomous/an unregistered hook leaves, and/remember:doctorreported "SessionEnd has never fired for this project" and blamed hook registration. It now goes in as soon as$REMEMBER_DIRandreport_error()exist, in the detached child, so every exit after that point leaves evidence that the hook fired -- including on a machine where the preamble itself runs past 1.5s. - The one report the detached path cannot deliver is #372's -- a store that could never be created at all. That warning went to the hook's own stderr precisely because there is no
hook-errors.logto write to when the directory that would hold it is what failed, and a detached child has no stderr the caller can see.REMEMBER_SESSION_END_FOREGROUND=1runs the old inline shape for debugging, with the warning visible; in production that case is silent. Traded knowingly for a hook that fits the budget everywhere, and pinned by a test so the doc and the code cannot drift apart.
A manual install (registering these hooks yourself in your project's .claude/settings.json, per docs/install-claude-code.md) is not subject to the plugin carve-out above: that snippet's SessionEnd entry declares "timeout": 10 too, and a timeout declared in a settings file is one Claude Code's own reference says raises the shared budget. That is the one install route where declaring timeout is a complete fix on its own, with no CLAUDE_CODE_SESSIONEND_HOOKS_TIMEOUT_MS fallback needed.