fix: ship Cursor-format hooks and a translation adapter#3
Open
syozz-dot wants to merge 2 commits into
Open
Conversation
The plugin's hooks.json is Claude-Code-format (uses ${CLAUDE_PLUGIN_ROOT}
and PascalCase event names). When loaded into Cursor, the hooks were
silently skipped because Cursor's hook schema is different — camelCase
event names, no matcher field, JSON-on-stdout deny envelope, and
CURSOR_PROJECT_DIR instead of CLAUDE_PROJECT_DIR.
This commit makes Cursor get the same guardrail behavior Claude Code
already has, without modifying any of the existing guardrail scripts.
Cursor event mapping (hooks/hooks-cursor.json)
sessionStart -> trtc-prepare-ui
beforeReadFile -> gate-slice-read
preToolUse -> gate-slice-write (filtered to Write/Edit inside)
afterFileEdit -> verify-ui-post-write, verify-slice-must
sessionEnd -> stop-apply-evidence, trtc-verify-ui, verify-apply-project
Cursor's documented `stop` event was verified to never fire in real
Cursor sessions (across multiple Cursor 0.x agent runs as of 2026-06).
sessionEnd is the closest reliable signal that an agent session truly
ended (Delete, window close, user_close), so the 3 end-of-task
guardrails attach there instead.
How it works (hooks/cursor-adapter.py)
- Reads Cursor's hook envelope from stdin and normalizes it to the
{tool_name, tool_input} shape the existing scripts expect.
- Surfaces CURSOR_PROJECT_DIR as CLAUDE_PROJECT_DIR and sets
CLAUDE_PLUGIN_ROOT, so existing scripts find sessions and shared
libs unchanged.
- Translates exit codes: Claude exit 1 or 2 -> Cursor deny envelope
({"permission":"deny","agent_message": <stderr>}) on stdout + exit 2.
- Fail-open by default: missing scripts, malformed stdin, unknown
dispatch keys all silent-allow so a broken adapter never blocks the
user's workflow.
- Optional debug logging via TRTC_HOOK_DEBUG_LOG env var (off by
default; production installs don't accumulate logs).
Verification
- 11 unit tests in tests/unit/test_cursor_adapter.py covering payload
translation, env forwarding, exit-code mapping, silent-allow paths,
and dispatch dispatching to the right script.
- Real Cursor smoke test: opening a chat in a project with a
not_started session produces a deny envelope on the first Write,
agent self-corrects.
Claude Code is unaffected — hooks/hooks.json is unchanged.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The 3 end-of-task guardrails were originally attached to Cursor's sessionEnd event. That was a semantic mismatch: in Claude Code, these guardrails fire on the Stop event, which triggers per agent loop iteration end (every assistant response). Cursor's afterAgentResponse fires with the same cadence; sessionEnd only fires when the user actively deletes the chat or closes the window. With sessionEnd, the guardrails effectively never ran during normal Cursor usage — users would have to delete the chat to trigger them. Switching to afterAgentResponse makes them fire every time the agent finishes a response, matching the Claude Code behavior. Also: populate user_message in addition to agent_message in the deny envelope (per Cursor's documented protocol). Cursor platform limitation observed during smoke testing - Cursor receives our deny on afterAgentResponse and logs it as "Hook 1 blocked action" in the Output panel, but does NOT surface it to the user (no chat warning) or to the AI (no self-correction). - Tested user_message and followup_message — both ignored on this event in Cursor 3.4.20. afterAgentResponse is a pure observer event in current Cursor versions. - Net effect: the 3 end-of-task guardrails act as audit logs only on Cursor; pre-action guardrails (preToolUse, beforeReadFile) continue to enforce blocks normally. - This is a Cursor platform issue. Once Cursor implements enforcement on afterAgentResponse (or fires `stop` per its docs), the same code will start blocking properly without changes on our side. Verification - 11 unit tests in tests/unit/test_cursor_adapter.py pass. - Layer-1 simulation: block scenario still produces correct Cursor deny envelope on stdout + exit 2. - Real-Cursor smoke: all 3 stop guardrails fire on afterAgentResponse, stop-apply-evidence correctly identifies the code_written state, Cursor logs "blocked action" in the Hooks Output panel as expected. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The plugin's hooks.json is Claude-Code-format (uses ${CLAUDE_PLUGIN_ROOT} and PascalCase event names). When loaded into Cursor, the hooks were silently skipped because Cursor's hook schema is different — camelCase event names, no matcher field, JSON-on-stdout deny envelope, and CURSOR_PROJECT_DIR instead of CLAUDE_PROJECT_DIR.
This commit makes Cursor get the same guardrail behavior Claude Code already has, without modifying any of the existing guardrail scripts.
Cursor event mapping (hooks/hooks-cursor.json)
sessionStart -> trtc-prepare-ui
beforeReadFile -> gate-slice-read
preToolUse -> gate-slice-write (filtered to Write/Edit inside)
afterFileEdit -> verify-ui-post-write, verify-slice-must
sessionEnd -> stop-apply-evidence, trtc-verify-ui, verify-apply-project
Cursor's documented
stopevent was verified to never fire in real Cursor sessions (across multiple Cursor 0.x agent runs as of 2026-06). sessionEnd is the closest reliable signal that an agent session truly ended (Delete, window close, user_close), so the 3 end-of-task guardrails attach there instead.How it works (hooks/cursor-adapter.py)
Verification
Claude Code is unaffected — hooks/hooks.json is unchanged.