Skip to content

fix: ship Cursor-format hooks and a translation adapter#3

Open
syozz-dot wants to merge 2 commits into
Tencent-RTC:mainfrom
syozz-dot:fix/bug-fix
Open

fix: ship Cursor-format hooks and a translation adapter#3
syozz-dot wants to merge 2 commits into
Tencent-RTC:mainfrom
syozz-dot:fix/bug-fix

Conversation

@syozz-dot
Copy link
Copy Markdown
Collaborator

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": }) 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.

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>
@syozz-dot syozz-dot requested a review from surifyy June 1, 2026 04:04
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant