sessionEnd hook fires only on window_close after shell-exec teardown — plugin hook commands can never execute

Where does the bug appear (feature/product)?

Cursor IDE

Describe the Bug

Our plugin registers a sessionEnd command hook (“node dist/src/index.js --hook session-end”) to generate a session recap. Registration, ${CLAUDE_PLUGIN_ROOT} expansion, and the stdin payload shape (session_id, reason, duration_ms, workspace_roots, transcript_path) all work — but the hook process can never actually run:

  1. sessionEnd fires ONLY with reason: “window_close” — never when a chat/agent session actually ends.
  2. By window close, the shell-exec bridge is already torn down, so every execution fails with “MainThreadShellExec not initialized”. Reproduced 5 out of 5 attempts.

Net effect: a sessionEnd command hook is currently unusable for any plugin, with no plugin-side workaround.

Steps to Reproduce

  1. Install a plugin with a SessionEnd command hook into ~/.cursor/plugins/local/ (real directory copy — symlinks are rejected).
  2. Note the hook only registers with the Claude-Code-style schema (PascalCase “SessionEnd”, nested “hooks” array, “type”: “command”). The camelCase flat format from the docs silently never registers.
  3. Open a project and run a short agent session with a few file edits.
  4. End the chat/session → the hook does not fire at all.
  5. Close the window → the Hooks output channel shows “Executing hook … from claude-plugin config” followed by “MainThreadShellExec not initialized”. The hook process never spawns.

Expected Behavior

sessionEnd should fire when a session ends (not only on window close) — or at minimum, hook commands fired at window close should execute before the shell-exec bridge is destroyed, so the spawned process can run.

Operating System

MacOS

Version Information

Bug originally validated on Cursor 3.11.13 (2026-07-11), 5/5 reproducible.

For AI issues: which model did you use?

Two related issues found during validation:

  1. The documented hooks schema silently fails to register. Only the Claude-Code-style schema works (PascalCase “SessionEnd”, nested “hooks” array, “type”: “command”). The Hooks channel’s “Loaded N user hook(s)” line also doesn’t count plugin hooks, which hides registration status and made this expensive to diagnose.
  2. Symlinked local plugin installs are rejected, contrary to the local-install docs — only real directory copies into ~/.cursor/plugins/local load.

Impact: automatic session capture is our plugin’s core feature. We shipped with a manual slash command as the sole trigger and can’t promise auto-capture until this is fixed.

Only validated on local installs — marketplace installs may use a different exec path (untested). Verbatim Hooks output channel logs available on request.

Does this stop you from using Cursor

No - Cursor works, but with this issue

Hey, thanks for the detailed report. The breakdown with logs and a repro helped a lot.

Confirming point by point:

  1. sessionEnd currently fires when you close the composer, tab, or window, not when an agent turn ends. There’s a separate hook for ending a turn, stop. So sessionEnd does not have a per-turn “session ended” trigger in its current form.
  2. On window_close, the hook does start, but the shell-exec host is not available in that context. That’s why the command fails with MainThreadShellExec not initialized and the process doesn’t spawn. I reproduced this and reported it internally as a bug. I can’t give an ETA for a fix yet, but I’ll post an update in the thread as soon as I have one.

About registration: you found the right approach. For plugin hooks, the working format right now is PascalCase SessionEnd with a nested hooks array and type: command. I also noted separately that the documented camelCase format silently doesn’t register, and that “Loaded N user hook(s)” doesn’t count plugin hooks. I’ll pass that on to docs and visibility since it makes debugging harder.

I also logged the issue with symlinked local installs being rejected even though the docs say they should work as a separate known problem.

If you can share the verbatim logs from the Hooks output channel, I’ll add them to the report.

Thanks Dean, that confirms everything cleanly, appreciate you filing it.

Verbatim Hooks output from the app-quit firing (identical pattern across all 5 firings, Cursor 3.11.13, macOS):

[2026-07-11T18:01:56.006Z] Hook step requested: sessionEnd
[2026-07-11T18:01:56.022Z] Found 1 hook(s) to execute for step: sessionEnd
[2026-07-11T18:01:56.022Z] Executing hook 1/1 from claude-plugin config
[2026-07-11T18:01:56.022Z] Running script in directory: /Users/roeybarkan/.cursor/plugins/local/yummm-recap
[2026-07-11T18:01:56.024Z] Running hook 1 with timeout 60000ms
Command: node “/Users/roeybarkan/.cursor/plugins/local/yummm-recap/dist/src/index.js” --hook session-end (0ms) exit code: N/A
OUTPUT: (empty)
[2026-07-11T18:02:06.051Z] ERROR: Error executing hook 1
[2026-07-11T18:02:06.051Z] Error: MainThreadShellExec not initialized
[2026-07-11T18:02:06.051Z] All hooks for step sessionEnd completed but none returned a valid response

Three follow-up questions:

  1. You mention sessionEnd fires on composer/tab close — in our testing, starting a new chat produced no sessionEnd at all (only window_close ever fired). What exactly triggers the composer/tab variant? If those fire while the window is alive, does the shell-exec host survive in that context?
  2. On the stop hook: does it run with shell-exec available, and does its stdin payload include transcript_path and workspace_roots like sessionEnd’s? If so it’s a viable trigger for us while the window_close fix is pending.
  3. Is the plugin-hooks schema (PascalCase + nested hooks + ${CLAUDE_PLUGIN_ROOT}) the stable long-term format we should build against, or will the documented camelCase format become the real one?

Happy to test any build or patch — we have a clean, reproducible harness for this.

Hey, thanks for the logs. I added them to the report. On the three points:

  1. The Composer/tab sessionEnd fires when you explicitly close or delete the composer/tab, not when you just start a new chat. That’s why it didn’t fire in your “new chat” test. In this case the window is still alive and the shell-exec host is up, so the command should run there, unlike window_close where the bridge is already gone. Please try it in your harness, and if it still doesn’t work, send the logs and I’ll take a look.

  2. stop fires at the end of the agent turn while the window is still alive, so shell-exec is available in that context. As a temporary trigger, this is a workable option while the window_close fix is still in the backlog. About the exact stdin payload and whether it includes transcript_path and workspace_roots like sessionEnd, I’ll confirm and reply. I don’t want to guess.

  3. About the schema. PascalCase SessionEnd with nested hooks and type: command is the Claude Code compatible format. Cursor maps it to its own hooks, for example SessionEndsessionEnd, see Third Party Hooks | Cursor Docs. Cursor’s native format is camelCase in hooks.json. For plugin hooks, in practice the Claude Code schema is what works right now, like you found. I’ll check with the team what the long term canonical format is for plugins, and why camelCase silently doesn’t register in the plugin. That’s the thing I flagged separately.

The window_close / MainThreadShellExec not initialized bug is logged. No ETA for the fix yet. Once I have an update, I’ll post it in the thread.