Add a blocking pre-exit hook event (or make SessionEnd blockable)

Resolved 💬 3 comments Opened Apr 26, 2026 by nicksonnenberg Closed Apr 30, 2026

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:

  1. /exit is a slash command and may never reach UserPromptSubmit (please confirm — the docs are unclear).
  2. It requires session-state on disk, which is brittle across crashes / parallel sessions / Ctrl+D.
  3. 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 reason and 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

View original on GitHub ↗

This issue has 3 comments on GitHub. Read the full discussion on GitHub ↗