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

Closed

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.json → hooks/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

  1. ScriptedAlchemy commented on Sep 3, 2026

    @ScriptedAlchemy
    OwnerAuthor

    Resolved by #414 (merged as 9c91a8697c). Full evidence note: docs/audits/2026-09-03-cursor-plugin-hooks-registration.md (URL + quote for every doc claim).

    Per-item evidence

    Item Status Evidence
    Read the live Cursor docs (plugins, plugins/reference, marketplace, hooks, agent, cli, mcp deeplink, changelog, template repos) done Audit note §1 and §4–5, dated 2026-09-03. Hooks are a Cursor Plugin component (.cursor-plugin/plugin.json, hooks/ or manifest path) and are not part of the Agent Plugins root plugin.json format. No plugin/marketplace install deeplink exists; cursor-agent plugin marketplace add needs a hosted Git URL.
    Root cause: layout installed when hooks did not fire done The report was against the cursor target on 3.16.21 in ~/.cursor-server and observed the same gap on tracedecay, which fires today. Cursor's CLI changelog (2026-08-11) records "Plugin hooks run from installed plugins … now execute"; in 3.18.25 plugin hooks are gated by thirdPartyExtensibilityEnabled / enable_cc_plugin_import. Vendor-side build gap, not an emitted-shape defect.
    Diff tracedecay-local vs emitted pack (manifest, hooks.json, mcp.json, command form, matchers, loop_limit, exec bit/shebang, cwd) done Audit §2 table. Only deltas: ./hooks/hooks.json vs hooks/hooks.json (same resolution), ${CURSOR_PLUGIN_ROOT} commands vs absolute binary, ^Shell$ matchers. Plugin hook commands run with cwd = plugin root and ${CURSOR_PLUGIN_ROOT} substituted (observed); .mjs scripts need no exec bit/shebang because node is the command. Emitter unchanged: both shapes fire.
    Evidence matrix (format × hook location × surface × event → fired?) incl. tracedecay-local vs emitted pack done Audit §3: on an isolated Cursor 3.18.25 instance the emitted pack's preToolUse/postToolUse (^Shell$) and stop fired from the plugin manifest with no ~/.cursor/hooks.json, identical to the hand-written probe, user and project hooks. Portable (Agent Plugins) has no hooks component by spec. Agent CLI not observed (auth); vendor changelog states plugin hooks run there since 2026-08-11.
    Marketplace layout study (read-only ~/.cursor/plugins/marketplaces, plugins/cache, copied state.vscdb) done Audit §4: cache is plugins/cache/<marketplace>/<plugin>/<commit-or-version>/… + .cache-complete receipt; enabled-plugin identity is server-side numeric ids → replicating state by hand (strategy 3) is not viable.
    Installer: marketplace-style delivery, official surfaces first, non-interactive where possible done agent-bundle install cursor --mode marketplace, the package-relative installer bin, and emitted install.mjs --mode marketplace stage a committed local marketplace repo (~/.cursor/agent-bundle/marketplaces/<name>, .cursor-plugin/marketplace.json per pinned schema) and print the exact Customize → Plugins → "Add Plugins from Local Repository" step (the one UI step Cursor requires; native folder picker could not be driven under Xvfb). Byte-faithful commit (.git/info/attributes, core.autocrlf=false, blob-id proof, AB7004 on drift), nested-.git and non-Cursor-Plugin bundles refused (AB7003), idempotent reruns, AB7005 collisions incl. dirty/ignored trees.
    --mode local fallback done Default (loads without UI, hooks proven); trade-offs documented in docs/framework-mode.md, package README, emitted INSTALL.md.
    No ~/.cursor/hooks.json registration decided, documented Would double-fire on fixed builds ("All matching hooks from every source run"), needs absolute paths, unavailable to cloud agents. Instead Doctor proves the manifest registration.
    Doctor hooks-registration state with AB codes done AB7322 registered/stale/missing (schema-validated hooks doc, resolves the executed script incl. env, NAME=value, bun run, pwsh -File, ${CURSOR_PLUGIN_ROOT}\), AB7323 duplicate user-level delivery / unparsable ~/.cursor/hooks.json (component-boundary, Windows case-folded), AB7324 staged marketplace registered/unregistered/corrupt (git cat-file/status verified, .cache-complete for the exact commit). Codes renumbered from AB7321–23 after #420 took AB7321.
    Capability table / adapterRevision done cursor-2026-08-28.json hooks row carries the dated 2026-09-03 evidence; install.mjs changed → cursor 1.11.0, plugin 1.27.0, portable 1.8.0 with pins in tests/adapter-metadata.test.ts.
    Automated tests done tests/install.test.ts (staging, schema-valid manifest, idempotency, version/manifest/dirty/ignored collisions, byte drift, nested git, missing git, mode rejection, CLI pass-through), tests/doctor.test.ts (AB7322–AB7324 incl. FIFO documents, interpreter forms, win32 case), tests/install-surface.test.ts, host-install proofs asserting hooks registration + marketplace staging (pnpm test:host-install:build 19/19).
    Docs + changeset done docs/framework-mode.md, package README, emitted INSTALL.md, docs/diagnostics.md, .changeset/cursor-install-mode-hooks-registration.md (patch).
    Cursor product feedback recorded Audit §7 (3.16.x gap, no marketplace install deeplink/CLI for local repos, folder-picker-only local import).

    Evidence matrix (condensed; full table in audit §3)

    Format Hook location Surface Event Fired?
    user hooks.json ~/.cursor/hooks.json IDE Agents window preToolUse/postToolUse/stop/workspaceOpen yes
    project hooks.json <ws>/.cursor/hooks.json IDE Agents window preToolUse/postToolUse/stop yes
    Cursor Plugin (hand-written probe) plugin manifest IDE Agents window preToolUse/postToolUse/stop yes
    Cursor Plugin (agent-bundle emitted cursor target) plugin manifest, node "${CURSOR_PLUGIN_ROOT}/hooks/*.mjs" IDE Agents window preToolUse (^Shell$) / postToolUse (^Shell$) / stop yes — no ~/.cursor/hooks.json
    Cursor Plugin (tracedecay, local) plugin manifest, absolute binary maintainer's IDE tool events, stop, sessionStart yes (ground truth)
    Agent Plugins (portable, root plugin.json) n/a any any no — no hooks component in the format
    any any Agent CLI any not observed (auth); vendor changelog 2026-08-11: plugin hooks now execute

    Delta that "fixed" it: none in the emitted shape — the 3.16.21 server build did not deliver plugin hooks; 3.18.25 does. Doctor now makes that silent state visible (AB7322 stale/missing, AB7323 duplicates).

    Review note: Codex reviewed through 8788311b12 (all threads fixed); 807299ab8 and f113002a7 were not reviewed (usage limits) and are listed in the PR body for a follow-up review.

  2. ScriptedAlchemy commented on Sep 4, 2026

    @ScriptedAlchemy
    OwnerAuthor

    2026-09-04 follow-up (evidence, no install-path change): the record was re-read against the question "do marketplace-installed plugins fire hooks while user-local installs do not?", and a non-interactive probe was run on this machine's live Cursor. Written up as §7 of docs/audits/2026-09-03-cursor-plugin-hooks-registration.md (PR #500).

    Desktop windows (3.18.25): user-local plugin hooks fire. Beyond the isolated-instance matrix in §3, Cursor's own hooks log for the maintainer's daily instance (~/.config/Cursor/logs/20260903T041607/window1_wb5/…/cursor.hooks.workspaceId-19017dde…log, 2026-09-03 20:43–20:56Z) records 220 Executing hook … from claude-plugin config lines, 121 with Running script in directory: /home/zack/.cursor/plugins/local/tracedecay and 77 with …/plugins/local/cargo-hauler (an agent-bundle emitted cursor pack), across preToolUse (124 steps), postToolUse (78), afterShellExecution (40), sessionStart, stop, workspaceOpen, with No user hooks configuration found. Marketplace hooks were never observed instead of local ones, and §4–§5 still stand: the marketplace-installed state is server-assigned and cannot be produced by an installer.

    Remote-SSH windows (3.19.3): this issue reproduces on a current build. #407 was filed from ~/.cursor-server; the same configuration was live here today (client and server 3.19.3, server commit 3b4eb8dd53). The remote extension host loads local plugins from the remote home (Cursor Plugins.log: loadUserLocalPlugin cargo-hauler loaded, … tracedecay loaded, 17 marketplace plugins from ~/.cursor/plugins/cache, total=20 plugins, failures=0). A throwaway ~/.cursor/plugins/local/ab-hooks-probe (hand-written .cursor-plugin/plugin.json + hooks/hooks.json, preToolUse/postToolUse ^Shell$, stop, sessionStart → sh "${CURSOR_PLUGIN_ROOT}/hooks/log.sh") was loaded within 10 s (loadUserLocalPlugin ab-hooks-probe loaded in 327.2ms, 21 plugins loaded … 0 failures) and then produced 0 events over two minutes of Shell traffic from seven concurrent conversations. The identical commands placed in a remote ~/.cursor/hooks.json produced 28 events (15 preToolUse, 13 postToolUse) from 7 conversations within 20 s, executed on the remote host (cwd /home/zack/.cursor, payload cursor_version: "3.19.3", workspace_roots: ["/fast/projects/agent-bundle"]). Both probes were removed afterwards (loadUserLocalPlugins completed … (3 plugins loaded)). tracedecay's and cargo-hauler's own telemetry agree: last cursor hook delivery 2026-09-03 20:56Z, the desktop instance's final minute, none during the Remote-SSH hours since.

    Not observed: whether hooks of marketplace plugins execute in a Remote-SSH window, and on which machine. A marketplace plugin exists in the client's ~/.cursor/plugins/cache as well as the remote's (the enabled set is account state), whereas a local install exists only on the host where the installer ran; if the client-side hooks service resolves plugin hooks against the client's copies, that asymmetry alone would make marketplace installs appear to fire and local ones not in Remote-SSH windows. That is a hypothesis; §7.3 lists the two-host, three-placement matrix (marker files on both filesystems, Cursor Plugins.log on the remote, cursor.hooks.*.log on the client) that would settle it.

    Install default stays --mode local; --mode marketplace stays the staged Customize hand-off; Doctor AB7322 keeps proving registration rather than assuming it.

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