Add a blocking pre-exit hook event (or make SessionEnd blockable)
Feature request
Add a hook event that fires before the session terminates and can block the exit. Today, no such event exists:
| Event | Fires when | Can block? |
|-------------|--------------------|-----------:|
| Stop | Every turn-end | Yes |
| SessionEnd| Session terminates | No |
This leaves no clean way to implement a "you have uncommitted work, are you sure you want to exit?" gate. Plugin authors who want this end up mis-registering on Stop (which fires every turn) and creating accidental infinite loops.
Use case
Plugin authors want to run pre-flight checks before a user's session ends and surface blockers (uncommitted work, unpushed commits, unsaved state) in a way the user must acknowledge — not just a stderr log they'll scroll past.
Concrete prior art: the zelda plugin's gate-session-exit.py hook attempted this via Stop, on the (incorrect) premise that Stop only fires on /exit. It instead fires on every assistant turn-end, so any session in a repo with uncommitted work hits the gate after every model response and loops indefinitely. The plugin is now reverting to advisory-only because the primitive doesn't exist.
Other plausible plugins that would benefit:
- "You have an unsaved Jupyter notebook" gate
- "You have a long-running background task — really exit?" gate
- "Push your branch before exiting?" gate
- "Compaction is in progress, wait or skip?" gate
Proposed designs (pick one)
Option A: New event PreSessionEnd
{
"hooks": {
"PreSessionEnd": [
{ "type": "command",
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/check-exit.py" }
]
}
}
Fires once per session, before the terminal cleanup. Supports the same block decision JSON as Stop:
{ "decision": "block",
"reason": "Uncommitted work in 3 files. Run `git commit` first." }
When blocked, the user sees the reason and remains in the session. They can override with a literal second /exit (or :q!-style sigil) within ~5s, mirroring how vim handles :q vs :q!.
Option B: Make SessionEnd blockable
Add Can Block: Yes to SessionEnd. Same payload schema as today, but honor decision: "block" in the response.
Simpler — no new event — but reuses an event whose name implies the session has already ended, which is semantically muddier. Option A is cleaner.
Why a Stop-hook workaround isn't enough
Plugin authors can roughly fake this by tracking /exit intent via UserPromptSubmit and gating subsequent Stop events on a marker file. This is what zelda will fall back to if no upstream fix lands. But:
/exitis a slash command and may never reachUserPromptSubmit(please confirm — the docs are unclear).- It requires session-state on disk, which is brittle across crashes / parallel sessions /
Ctrl+D. - It blocks
Stop, which means the user sees the gate as part of the model's "finished" UI rather than an explicit pre-exit affordance. That's confusing.
A first-class PreSessionEnd is the right primitive.
Acceptance criteria
- [ ] New event documented at https://code.claude.com/docs/en/hooks.md
- [ ] Event fires exactly once per session, on
/exit,Ctrl+D, window close, and any other terminal-path - [ ] Honors
{"decision": "block", "reason": "..."}JSON - [ ] When blocked, user sees
reasonand remains in the session - [ ] Has a documented user-side override (literal repeat exit, or env var like
CLAUDE_FORCE_EXIT=1) - [ ] Reference example in the docs showing a "uncommitted git work" gate
This issue has 3 comments on GitHub. Read the full discussion on GitHub ↗