Skip to content

Cursor pack: manifest-declared hooks never fire for tool events (Cursor 3.16) — emitted installer must register hooks in ~/.cursor/hooks.json so the plugin works out of the box #407

Description

@ScriptedAlchemy

Summary

A Cursor pack emitted by agent-bundle declares its hooks only through .cursor-plugin/plugin.json"hooks": "./hooks/hooks-cursor.json". On the current Cursor build (3.16.21, ~/.cursor-server), Cursor loads the local plugin (loadUserLocalPlugin cargo-hauler loaded in 130ms, 0 failures) but never invokes the manifest-declared preToolUse / postToolUse / stop hooks for tool events. The plugin's shell-rewrite hook — its primary reason to exist — was dead for two days while every framework gate (validate, build, contract matrix, install proof) was green.

The same hook scripts registered through the documented user-level ~/.cursor/hooks.json fire immediately for preToolUse, beforeShellExecution, postToolUse, afterShellExecution, hot-reload without a restart, and apply across every live agent session. So the plumbing gap is in how the installer wires hooks, not in the projection.

Framework mapping itself is correct: per https://cursor.com/docs/agent/hooks, preToolUse (matcher Shell) with updated_input is the only Cursor event that can rewrite a command; beforeShellExecution is allow/deny/ask only.

Evidence (cargo-hauler 0.3.2, ~/.cursor/plugins/local/cargo-hauler)

  1. Hook flight log written by the plugin's tool/before route (hook-events.jsonl) over ~2 days of heavy multi-session use: codex 53 events, claude fires via PreToolUse, cursor 2 events — both manual echo '{...}' | node hooks/event-route-tool-before.cursor.mjs smoke tests. Zero from real Cursor sessions, before and after several reinstalls (loadUserLocalPlugin logged at 03:47, 04:39, 05:28, 05:34, 06:05, 06:23, 06:42).
  2. Independent plugin on the same box (tracedecay, .cursor-plugin/plugin.jsonhooks/hooks.json declaring afterShellExecution, postToolUse, stop, sessionStart, …): its analytics show Cursor delivered only sessionStart (2 invocations); every other declared event: 0. Manifest hooks appear limited to session lifecycle on this build.
  3. Ran cargo --version from a live Cursor agent session: no preToolUse event; only the PATH shim caught it. Cursor's Cursor Agent Exec.log shows hookForcesPrompt:false on every shell evaluation and no hook execution lines.
  4. Control: a throwaway ~/.cursor/hooks.json with preToolUse/beforeShellExecution/postToolUse/afterShellExecution → logging script. Within seconds it logged all four events from six concurrent Cursor conversations, tool_name:"Shell" included. Swapping the file to the pack's real event-route-tool-before.cursor.mjs: the very next cargo --version was rewritten to node …/scripts/hauler.mjs exec --session <conversation_id> --host cursor -- cargo --version and tool/after recorded exitCode:0. (Reverted afterwards — this must come from the pack, not hand edits.)
  5. The hook script itself is fine: echo '{"tool_name":"Shell","tool_input":{"command":"cargo check -p demo"},...}' | node hooks/event-route-tool-before.cursor.mjs{"permission":"allow","updated_input":{"command":"… hauler.mjs exec … -- cargo check -p demo"}}.

Consequences observed

With the hook dead, agents on this machine learned to route around the PATH-shim backstop (absolute ~/.rustup/toolchains/*/bin/cargo, env -u …, private CARGO_TARGET_DIR), which is exactly what the host hook exists to prevent. A pack that validates and installs clean but whose hooks never run is the worst failure mode: silent.

Requested behavior

The emitted Cursor install.mjs (and matching uninstall path, see #400 / #101) should produce a fully working plugin out of the box:

  1. Register hooks at the user level. Merge the pack's hook entries into ~/.cursor/hooks.json (create if absent; preserve other owners' entries; version: 1). Entries must be self-contained because user-level hooks do not receive ${CURSOR_PLUGIN_ROOT}:
    "command": "CURSOR_PLUGIN_ROOT=<dest> node <dest>/hooks/event-route-tool-before.cursor.mjs", plus matcher and timeout as compiled. Absolute paths, since user hooks run from ~/.cursor/.
  2. Own the entries. Tag each entry (e.g. "_agentBundle": {"plugin":"cargo-hauler","version":"0.3.2"} or a stable comment/marker the framework already uses) so reinstall replaces exactly its own entries and uninstall removes only them, leaving the file valid (or deleting it when it becomes empty and the installer created it).
  3. Avoid double delivery. If/when Cursor starts honoring manifest hooks, both channels would fire. Either strip hooks from the installed .cursor-plugin/plugin.json when user-level registration succeeds, or document that tool/before routes must be idempotent — but pick one and make the emitted pack consistent.
  4. Keep the manifest declaration for marketplace installs (where the installer does not run), and note the limitation in INSTALL.md.
  5. Prove liveness, not just shape. Post-install (or agent-bundle doctor --host cursor), verify Cursor is actually delivering events — e.g. write the merged file, wait for the documented hot-reload, and check for a sessionStart/preToolUse canary or the pack's hook flight log — and print a loud warning when nothing arrives. The contract matrix (runPackedContractMatrix: coverage check requires a fixture for app routes, contradicting the docs #401) proves the route contract in-process; nothing today proves the host calls it.

Repro

# any pack with an events/tool/before route, e.g. cargo-hauler 0.3.2
node artifact/plugin/install.mjs           # Cursor: loadUserLocalPlugin <name> loaded
# in a Cursor agent chat, run any Shell command matched by the hook
# → no hook process spawned, no updated_input applied
cat > ~/.cursor/hooks.json <<'J'
{"version":1,"hooks":{"preToolUse":[{"command":"CURSOR_PLUGIN_ROOT=$HOME/.cursor/plugins/local/<name> node $HOME/.cursor/plugins/local/<name>/hooks/event-route-tool-before.cursor.mjs","matcher":"Shell","timeout":10}]}}
J
# run the same command again → hook fires, updated_input applied

Environment

Cursor 3.16.21 (remote server, linux-x64), agent-bundle preview 105c65d8f, pack: https://github.com/ScriptedAlchemy/cargo-hauler @ 0.3.2.

Related: #189 (Cursor contract refresh), #400 (installer replace path), #401 (contract matrix coverage), #387 (routed CLI not in host artifacts).

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions