From 444ac31736fbb503e3c797b7f24b6169ecf938d4 Mon Sep 17 00:00:00 2001 From: Wei Zhao Date: Mon, 1 Jun 2026 11:57:26 +0800 Subject: [PATCH 1/3] fix: ship Cursor-format hooks and a translation adapter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. Co-Authored-By: Claude Opus 4.7 (1M context) --- .cursor-plugin/plugin.json | 2 +- hooks/cursor-adapter.py | 230 +++++++++++++++++++++++ hooks/hooks-cursor.json | 39 ++++ tests/unit/test_cursor_adapter.py | 293 ++++++++++++++++++++++++++++++ 4 files changed, 563 insertions(+), 1 deletion(-) create mode 100755 hooks/cursor-adapter.py create mode 100644 hooks/hooks-cursor.json create mode 100644 tests/unit/test_cursor_adapter.py diff --git a/.cursor-plugin/plugin.json b/.cursor-plugin/plugin.json index 111a0f4..e99add7 100644 --- a/.cursor-plugin/plugin.json +++ b/.cursor-plugin/plugin.json @@ -5,5 +5,5 @@ "repository": "https://github.com/Tencent-RTC/agent-skills", "rules": ".cursor/rules/", "skills": "./skills/", - "hooks": "./hooks/hooks.json" + "hooks": "./hooks/hooks-cursor.json" } diff --git a/hooks/cursor-adapter.py b/hooks/cursor-adapter.py new file mode 100755 index 0000000..113916e --- /dev/null +++ b/hooks/cursor-adapter.py @@ -0,0 +1,230 @@ +#!/usr/bin/env python3 +"""cursor-adapter.py — Translate Cursor hook protocol to existing Claude-format guardrails. + +Wired from hooks-cursor.json. Each Cursor hook event runs: + + python3 ./hooks/cursor-adapter.py + +This script: + 1. Reads the Cursor hook envelope from stdin (camelCase fields). + 2. Normalizes it to the {tool_name, tool_input} shape the existing + guardrail scripts in skills/*/guardrails/ expect. + 3. Sets CLAUDE_PLUGIN_ROOT and CLAUDE_PROJECT_DIR env vars so the + existing scripts find sessions and shared libs unchanged. + 4. Spawns the underlying script and propagates stdout/stderr. + 5. Translates exit codes: + Claude Code: 0=allow, 1=fail, 2=block + Cursor: 0=success, 2=deny (with JSON), other=fail-open + We map Claude exit 1 and exit 2 -> Cursor deny (exit 2 + JSON on stdout). + +Fail-open principle: if anything goes wrong inside the adapter (missing +script, malformed stdin, dispatch key unknown), we exit 0 silently so a +broken adapter never blocks the user's workflow. + +Cursor event mapping (see 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 does not actually fire + as of 2026-06; sessionEnd is the closest reliable + signal that a Cursor agent session has truly ended.) + +To enable verbose tracing during development, set the env var +TRTC_HOOK_DEBUG_LOG=/path/to/file.log; see _dbg() below. The default is +silent (no log file written) so production installs don't accumulate logs. +""" +from __future__ import annotations + +import json +import os +import subprocess +import sys +from pathlib import Path + +ADAPTER_DIR = Path(__file__).resolve().parent +PLUGIN_ROOT = ADAPTER_DIR.parent # plugin install root + + +# Optional debug logging — only writes when TRTC_HOOK_DEBUG_LOG is set to a +# path. Useful for diagnosing "why didn't my hook fire" without modifying +# the script. +def _dbg(msg: str) -> None: + log_path = os.environ.get("TRTC_HOOK_DEBUG_LOG") + if not log_path: + return + try: + import datetime as _dt + with open(log_path, "a") as f: + f.write(f"[{_dt.datetime.now().isoformat(timespec='milliseconds')}] {msg}\n") + except Exception: + pass + + +# Dispatch table: dispatch_key -> relative script path under PLUGIN_ROOT. +DISPATCH = { + "trtc-prepare-ui": "skills/trtc/room-builder/guardrails/trtc_prepare_ui.py", + "gate-slice-read": "skills/trtc-topic/guardrails/gate_slice_read.py", + "gate-slice-write": "skills/trtc-topic/guardrails/gate_slice_write.py", + "verify-ui-post-write": "skills/trtc/room-builder/guardrails/verify_ui_post_write.sh", + "verify-slice-must": "skills/trtc-apply/guardrails/verify_slice_must_rules.py", + "stop-apply-evidence": "skills/trtc-topic/guardrails/stop_require_apply_evidence.py", + "trtc-verify-ui": "skills/trtc/room-builder/guardrails/trtc_verify_ui.py", + "verify-apply-project": "skills/trtc-apply/guardrails/verify_apply_project.py", +} + +# Dispatch keys whose underlying scripts read JSON from stdin. +# Other keys are CLI-only and will receive empty stdin. +STDIN_KEYS = { + "gate-slice-read", + "gate-slice-write", + "verify-ui-post-write", + "verify-slice-must", +} + + +def _silent_allow() -> None: + """Fail-open: never block the user because the adapter itself failed.""" + _dbg(" EXIT=0 (silent allow)") + sys.exit(0) + + +def _read_cursor_payload() -> dict: + raw = sys.stdin.read() + if not raw or not raw.strip(): + return {} + try: + data = json.loads(raw) + return data if isinstance(data, dict) else {} + except (ValueError, TypeError): + return {} + + +def _translate_payload(key: str, cursor: dict) -> dict: + """Map Cursor's per-event payload to the {tool_name, tool_input} envelope + the existing guardrails expect. + + The existing scripts read either: + - {"tool_name": "Read", "tool_input": {"file_path": "..."}} + - {"tool_name": "Write|Edit", "tool_input": {"file_path": "..."}} + """ + if key == "gate-slice-read": + # Cursor's beforeReadFile payload: {file_path, content, attachments, ...} + file_path = cursor.get("file_path") or cursor.get("filePath") + return {"tool_name": "Read", "tool_input": {"file_path": file_path}} + + if key == "gate-slice-write": + # Cursor's preToolUse payload: {tool_name, tool_input, tool_use_id, cwd, ...} + # Cursor only emits "Write" (no separate "Edit"); existing script + # accepts both. Pass through unchanged when tool_name is Write/Edit; + # silent allow for unrelated tools. + tool_name = cursor.get("tool_name") or cursor.get("toolName") + if tool_name not in ("Write", "Edit"): + return {} # caller will silent-allow + tool_input = cursor.get("tool_input") or cursor.get("toolInput") or {} + return {"tool_name": tool_name, "tool_input": tool_input} + + if key in ("verify-ui-post-write", "verify-slice-must"): + # Cursor's afterFileEdit payload: {file_path, edits} + file_path = cursor.get("file_path") or cursor.get("filePath") + return {"tool_name": "Edit", "tool_input": {"file_path": file_path}} + + # CLI-only events (sessionStart, sessionEnd) get empty stdin — they read + # state from session files and env vars, not stdin. + return {} + + +def _emit_cursor_deny(message: str) -> None: + """Cursor deny protocol: JSON on stdout + exit 2.""" + _dbg(f" EXIT=2 (deny) message={message[:160]!r}") + payload = { + "permission": "deny", + "agent_message": message or "Blocked by TRTC guardrail.", + } + sys.stdout.write(json.dumps(payload)) + sys.stdout.flush() + sys.exit(2) + + +def main(argv: list[str]) -> None: + key = argv[1] if len(argv) >= 2 else None + _dbg( + f"ENTER key={key!r} " + f"cursor_project_dir={os.environ.get('CURSOR_PROJECT_DIR', '?')!r}" + ) + if not key: + _silent_allow() + + # Read stdin first so debug logs see Cursor's hook_event_name even when + # the dispatch key is unknown or the script is missing. + cursor_payload = _read_cursor_payload() + _dbg( + f" hook_event_name={cursor_payload.get('hook_event_name', '?')!r} " + f"keys={sorted(cursor_payload.keys()) if cursor_payload else []}" + ) + + rel_script = DISPATCH.get(key) + if not rel_script: + # Unknown dispatch key — never block on adapter misconfiguration. + _silent_allow() + + script = PLUGIN_ROOT / rel_script + if not script.exists(): + # Script missing (e.g., skill not installed). Same convention the + # existing hooks.json uses ("[ ! -f X ] || python3 X"). + _silent_allow() + + claude_payload = _translate_payload(key, cursor_payload) + + # gate-slice-write returns {} when tool isn't Write/Edit -> silent allow. + if key == "gate-slice-write" and not claude_payload: + _silent_allow() + + # Build env: surface Cursor's project dir as CLAUDE_PROJECT_DIR so + # existing scripts that key off CLAUDE_PROJECT_DIR keep working. + env = dict(os.environ) + cursor_project_dir = env.get("CURSOR_PROJECT_DIR") or cursor_payload.get("cwd") + if cursor_project_dir and not env.get("CLAUDE_PROJECT_DIR"): + env["CLAUDE_PROJECT_DIR"] = cursor_project_dir + env["CLAUDE_PLUGIN_ROOT"] = str(PLUGIN_ROOT) + + runner = ["bash", str(script)] if script.suffix == ".sh" else ["python3", str(script)] + stdin_data = json.dumps(claude_payload) if key in STDIN_KEYS else "" + + try: + proc = subprocess.run( + runner, + input=stdin_data, + text=True, + env=env, + capture_output=True, + timeout=30, + ) + except (subprocess.TimeoutExpired, OSError): + _silent_allow() + + # Forward whatever the inner script wrote. + if proc.stdout: + sys.stderr.write(proc.stdout) # inner stdout -> stderr so we don't + # corrupt our Cursor JSON channel + if proc.stderr: + sys.stderr.write(proc.stderr) + + rc = proc.returncode + _dbg(f" inner_rc={rc}") + if rc == 0: + sys.exit(0) + if rc in (1, 2): + # Translate fail/block into Cursor's deny envelope. Use stderr + # content as the agent-facing message so the existing scripts' + # human-readable explanations reach the user. + msg = (proc.stderr or proc.stdout or "").strip() + _emit_cursor_deny(msg) + + # Unknown exit code — fail open to mimic Claude Code's permissive default. + _silent_allow() + + +if __name__ == "__main__": + main(sys.argv) diff --git a/hooks/hooks-cursor.json b/hooks/hooks-cursor.json new file mode 100644 index 0000000..9af5a96 --- /dev/null +++ b/hooks/hooks-cursor.json @@ -0,0 +1,39 @@ +{ + "version": 1, + "hooks": { + "sessionStart": [ + { + "command": "python3 ./hooks/cursor-adapter.py trtc-prepare-ui" + } + ], + "beforeReadFile": [ + { + "command": "python3 ./hooks/cursor-adapter.py gate-slice-read" + } + ], + "preToolUse": [ + { + "command": "python3 ./hooks/cursor-adapter.py gate-slice-write" + } + ], + "afterFileEdit": [ + { + "command": "python3 ./hooks/cursor-adapter.py verify-ui-post-write" + }, + { + "command": "python3 ./hooks/cursor-adapter.py verify-slice-must" + } + ], + "sessionEnd": [ + { + "command": "python3 ./hooks/cursor-adapter.py stop-apply-evidence" + }, + { + "command": "python3 ./hooks/cursor-adapter.py trtc-verify-ui" + }, + { + "command": "python3 ./hooks/cursor-adapter.py verify-apply-project" + } + ] + } +} diff --git a/tests/unit/test_cursor_adapter.py b/tests/unit/test_cursor_adapter.py new file mode 100644 index 0000000..ad03cd7 --- /dev/null +++ b/tests/unit/test_cursor_adapter.py @@ -0,0 +1,293 @@ +"""Unit tests for hooks/cursor-adapter.py. + +Strategy: replace each guardrail script with a tiny stub in a fake plugin +root, then run the adapter and assert the right script ran with the right +stdin/env. Same strategy used in tests/unit/test_trtc_*.py — direct +subprocess invocation, no test-framework magic. + +Run with: + python3 -m unittest tests.unit.test_cursor_adapter + +Pytest also picks these up if available. +""" +from __future__ import annotations + +import json +import os +import shutil +import subprocess +import tempfile +import textwrap +import unittest +from pathlib import Path + + +REPO_ROOT = Path(__file__).resolve().parent.parent.parent +ADAPTER_SRC = REPO_ROOT / "hooks" / "cursor-adapter.py" + + +# Mirror of the DISPATCH dict inside cursor-adapter.py — keep in sync. +DISPATCH = { + "trtc-prepare-ui": "skills/trtc/room-builder/guardrails/trtc_prepare_ui.py", + "gate-slice-read": "skills/trtc-topic/guardrails/gate_slice_read.py", + "gate-slice-write": "skills/trtc-topic/guardrails/gate_slice_write.py", + "verify-ui-post-write": "skills/trtc/room-builder/guardrails/verify_ui_post_write.sh", + "verify-slice-must": "skills/trtc-apply/guardrails/verify_slice_must_rules.py", + "stop-apply-evidence": "skills/trtc-topic/guardrails/stop_require_apply_evidence.py", + "trtc-verify-ui": "skills/trtc/room-builder/guardrails/trtc_verify_ui.py", + "verify-apply-project": "skills/trtc-apply/guardrails/verify_apply_project.py", +} + + +class CursorAdapterTestBase(unittest.TestCase): + """Builds a fake plugin tree at /plugin/ containing a copy of the + real adapter, so adapter's __file__-based PLUGIN_ROOT resolution lands + on our temp directory. + """ + + def setUp(self) -> None: + # macOS resolves /var/folders → /private/var/folders; realpath the + # tmp dir so what we observe matches what the adapter sees. + self.tmp = Path(tempfile.mkdtemp(prefix="trtc-adapter-")).resolve() + (self.tmp / "hooks").mkdir(parents=True, exist_ok=True) + self.adapter = self.tmp / "hooks" / "cursor-adapter.py" + shutil.copy2(ADAPTER_SRC, self.adapter) + + def tearDown(self) -> None: + shutil.rmtree(self.tmp, ignore_errors=True) + + # ---- helpers -------------------------------------------------------- + + def plant_stub(self, rel_path: str, *, exit_code: int = 0, stderr_text: str = "") -> None: + """Write a stub at / that records its stdin/env to + /hook-trace.json, prints stderr_text, and exits exit_code. + """ + abs_path = self.tmp / rel_path + abs_path.parent.mkdir(parents=True, exist_ok=True) + trace_path = self.tmp / "hook-trace.json" + + if abs_path.suffix == ".sh": + # Match the .sh script wrapping pattern used in verify_ui_post_write.sh. + body = textwrap.dedent(f"""\ + #!/usr/bin/env bash + data=$(cat) + python3 - <&2 + fi + exit {exit_code} + """) + else: + body = textwrap.dedent(f"""\ + #!/usr/bin/env python3 + import json, os, sys + trace = {{ + "stdin": sys.stdin.read(), + "env_CLAUDE_PLUGIN_ROOT": os.environ.get("CLAUDE_PLUGIN_ROOT", ""), + "env_CLAUDE_PROJECT_DIR": os.environ.get("CLAUDE_PROJECT_DIR", ""), + "argv": sys.argv, + }} + with open({json.dumps(str(trace_path))}, "w") as f: + json.dump(trace, f) + if {json.dumps(stderr_text)}: + sys.stderr.write({json.dumps(stderr_text)}) + sys.exit({exit_code}) + """) + + abs_path.write_text(body) + abs_path.chmod(0o755) + + def run_adapter(self, dispatch_key: str, stdin: str = "", extra_env: dict | None = None): + env = os.environ.copy() + if extra_env: + env.update(extra_env) + return subprocess.run( + ["python3", str(self.adapter), dispatch_key], + input=stdin, + capture_output=True, + text=True, + env=env, + ) + + def read_trace(self) -> dict | None: + p = self.tmp / "hook-trace.json" + if not p.exists(): + return None + return json.loads(p.read_text()) + + +class TestSilentAllow(CursorAdapterTestBase): + + def test_unknown_dispatch_key_is_silent_allow(self): + result = self.run_adapter("totally-bogus-key") + self.assertEqual(result.returncode, 0) + + def test_missing_target_script_is_silent_allow(self): + # Plant nothing — script doesn't exist. + result = self.run_adapter( + "gate-slice-read", + stdin=json.dumps({"file_path": "/x"}), + ) + self.assertEqual(result.returncode, 0) + + def test_malformed_stdin_is_silent_allow(self): + self.plant_stub(DISPATCH["gate-slice-read"], exit_code=0) + result = self.run_adapter("gate-slice-read", stdin="not json at all") + # Adapter still dispatches with empty payload; inner stub exits 0; net 0. + self.assertEqual(result.returncode, 0) + + def test_pretooluse_with_non_write_tool_short_circuits(self): + # Stub plants exit 2; if adapter wrongly invoked it, we'd see deny. + self.plant_stub(DISPATCH["gate-slice-write"], exit_code=2, stderr_text="should not fire") + result = self.run_adapter( + "gate-slice-write", + stdin=json.dumps({"tool_name": "Shell", "tool_input": {"command": "ls"}}), + ) + self.assertEqual(result.returncode, 0) + # Trace file should NOT exist — adapter short-circuited before subprocess. + self.assertIsNone(self.read_trace()) + + +class TestPayloadTranslation(CursorAdapterTestBase): + + def test_before_read_file_translates_to_tool_name_read(self): + self.plant_stub(DISPATCH["gate-slice-read"], exit_code=0) + result = self.run_adapter( + "gate-slice-read", + stdin=json.dumps({ + "file_path": "/repo/knowledge-base/slices/conference/web/login-auth.md", + "content": "...", + "hook_event_name": "beforeReadFile", + }), + ) + self.assertEqual(result.returncode, 0, msg=result.stderr) + + trace = self.read_trace() + self.assertIsNotNone(trace, "stub did not run") + seen = json.loads(trace["stdin"]) + self.assertEqual(seen["tool_name"], "Read") + self.assertEqual( + seen["tool_input"]["file_path"], + "/repo/knowledge-base/slices/conference/web/login-auth.md", + ) + + def test_pretooluse_with_write_dispatches_with_normalized_payload(self): + self.plant_stub(DISPATCH["gate-slice-write"], exit_code=0) + result = self.run_adapter( + "gate-slice-write", + stdin=json.dumps({ + "tool_name": "Write", + "tool_input": {"file_path": "/proj/src/main.ts"}, + }), + ) + self.assertEqual(result.returncode, 0, msg=result.stderr) + + trace = self.read_trace() + self.assertIsNotNone(trace) + seen = json.loads(trace["stdin"]) + self.assertEqual(seen["tool_name"], "Write") + self.assertEqual(seen["tool_input"]["file_path"], "/proj/src/main.ts") + + def test_after_file_edit_translates_for_verify_slice_must(self): + self.plant_stub(DISPATCH["verify-slice-must"], exit_code=0) + result = self.run_adapter( + "verify-slice-must", + stdin=json.dumps({ + "file_path": "/proj/src/Room.vue", + "edits": [{"old_string": "a", "new_string": "b"}], + }), + ) + self.assertEqual(result.returncode, 0, msg=result.stderr) + + trace = self.read_trace() + self.assertIsNotNone(trace) + seen = json.loads(trace["stdin"]) + self.assertEqual(seen["tool_name"], "Edit") + self.assertEqual(seen["tool_input"]["file_path"], "/proj/src/Room.vue") + + +class TestEnvForwarding(CursorAdapterTestBase): + + def test_claude_plugin_root_exported_to_inner_script(self): + self.plant_stub(DISPATCH["trtc-prepare-ui"], exit_code=0) + result = self.run_adapter("trtc-prepare-ui", stdin="") + self.assertEqual(result.returncode, 0, msg=result.stderr) + + trace = self.read_trace() + self.assertIsNotNone(trace) + self.assertEqual(trace["env_CLAUDE_PLUGIN_ROOT"], str(self.tmp)) + + def test_cursor_project_dir_forwarded_as_claude_project_dir(self): + self.plant_stub(DISPATCH["trtc-prepare-ui"], exit_code=0) + result = self.run_adapter( + "trtc-prepare-ui", + stdin="", + extra_env={"CURSOR_PROJECT_DIR": "/some/project", "CLAUDE_PROJECT_DIR": ""}, + ) + self.assertEqual(result.returncode, 0) + + trace = self.read_trace() + self.assertIsNotNone(trace) + self.assertEqual(trace["env_CLAUDE_PROJECT_DIR"], "/some/project") + + +class TestExitCodeMapping(CursorAdapterTestBase): + + def test_inner_exit_2_becomes_cursor_deny_envelope_and_exit_2(self): + self.plant_stub( + DISPATCH["gate-slice-read"], + exit_code=2, + stderr_text="Slice read out of bounds — finish current slice first.", + ) + result = self.run_adapter( + "gate-slice-read", + stdin=json.dumps({"file_path": "/repo/knowledge-base/slices/conference/web/x.md"}), + ) + self.assertEqual(result.returncode, 2) + + envelope = json.loads(result.stdout) + self.assertEqual(envelope["permission"], "deny") + self.assertIn("Slice read out of bounds", envelope["agent_message"]) + + def test_inner_exit_1_also_maps_to_deny(self): + # verify_slice_must_rules.py and verify_apply_project.py exit 1 on + # check failure (not 2). Adapter must still translate to Cursor deny. + self.plant_stub( + DISPATCH["verify-slice-must"], + exit_code=1, + stderr_text="MUST-rule check failed", + ) + result = self.run_adapter( + "verify-slice-must", + stdin=json.dumps({"file_path": "/p/x.vue"}), + ) + self.assertEqual(result.returncode, 2) + self.assertIn('"permission":', result.stdout) + self.assertIn('"deny"', result.stdout) + + +if __name__ == "__main__": + unittest.main() From 38edbc1d1f28e0596100d526a2947d50be96e8e9 Mon Sep 17 00:00:00 2001 From: Wei Zhao Date: Mon, 1 Jun 2026 20:29:09 +0800 Subject: [PATCH 2/3] fix: route Cursor end-of-task hooks to afterAgentResponse MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- hooks/cursor-adapter.py | 56 ++++++++++++++++++++++++------- hooks/hooks-cursor.json | 2 +- tests/unit/test_cursor_adapter.py | 5 +++ 3 files changed, 50 insertions(+), 13 deletions(-) diff --git a/hooks/cursor-adapter.py b/hooks/cursor-adapter.py index 113916e..69f0b63 100755 --- a/hooks/cursor-adapter.py +++ b/hooks/cursor-adapter.py @@ -22,14 +22,28 @@ broken adapter never blocks the user's workflow. Cursor event mapping (see 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 does not actually fire - as of 2026-06; sessionEnd is the closest reliable - signal that a Cursor agent session has truly ended.) + 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 + afterAgentResponse -> stop-apply-evidence, trtc-verify-ui, verify-apply-project + (Claude Code's `Stop` event fires per agent loop + iteration end — the same semantic as Cursor's + `afterAgentResponse`. Cursor's documented `stop` + event was verified to never fire as of 2026-06, + so we use afterAgentResponse instead. + + IMPORTANT: Cursor treats afterAgentResponse as a + pure observer event. Our deny envelope is + received and logged in Cursor's Hooks Output + panel ("Hook 1 blocked action") but does NOT + surface to the user (no chat warning) or to the + AI (no self-correction). Tested with + user_message and followup_message fields — both + ignored. This is a Cursor platform limitation, + not an adapter issue. Until Cursor implements + enforcement on afterAgentResponse, the 3 + end-of-task guardrails act as audit logs only.) To enable verbose tracing during development, set the env var TRTC_HOOK_DEBUG_LOG=/path/to/file.log; see _dbg() below. The default is @@ -130,17 +144,35 @@ def _translate_payload(key: str, cursor: dict) -> dict: file_path = cursor.get("file_path") or cursor.get("filePath") return {"tool_name": "Edit", "tool_input": {"file_path": file_path}} - # CLI-only events (sessionStart, sessionEnd) get empty stdin — they read - # state from session files and env vars, not stdin. + # CLI-only events (sessionStart, afterAgentResponse) get empty stdin — + # they read state from session files and env vars, not stdin. return {} def _emit_cursor_deny(message: str) -> None: - """Cursor deny protocol: JSON on stdout + exit 2.""" + """Cursor deny protocol: JSON on stdout + exit 2. + + Fields populated: + - permission: "deny" — tells Cursor this action should be blocked. + - user_message: surfaced in Cursor's UI when the platform supports it + on the current event. Verified to be ignored on afterAgentResponse + (Cursor only logs the block in the Output panel there); honored on + preToolUse / beforeReadFile. + - agent_message: shown to the agent so it can self-correct on hooks + that run before the agent's loop ends (preToolUse, beforeReadFile). + + Note: `followup_message` was tested and is NOT honored on + afterAgentResponse (Cursor 3.4.20). Per Cursor docs it's only meaningful + on the `stop` event, which itself does not fire in current Cursor + versions — so there's no reliable channel to make Cursor auto-submit a + correction message back to the agent after it has already responded. + """ _dbg(f" EXIT=2 (deny) message={message[:160]!r}") + msg = message or "Blocked by TRTC guardrail." payload = { "permission": "deny", - "agent_message": message or "Blocked by TRTC guardrail.", + "user_message": msg, + "agent_message": msg, } sys.stdout.write(json.dumps(payload)) sys.stdout.flush() diff --git a/hooks/hooks-cursor.json b/hooks/hooks-cursor.json index 9af5a96..40a74bd 100644 --- a/hooks/hooks-cursor.json +++ b/hooks/hooks-cursor.json @@ -24,7 +24,7 @@ "command": "python3 ./hooks/cursor-adapter.py verify-slice-must" } ], - "sessionEnd": [ + "afterAgentResponse": [ { "command": "python3 ./hooks/cursor-adapter.py stop-apply-evidence" }, diff --git a/tests/unit/test_cursor_adapter.py b/tests/unit/test_cursor_adapter.py index ad03cd7..109354c 100644 --- a/tests/unit/test_cursor_adapter.py +++ b/tests/unit/test_cursor_adapter.py @@ -270,7 +270,12 @@ def test_inner_exit_2_becomes_cursor_deny_envelope_and_exit_2(self): envelope = json.loads(result.stdout) self.assertEqual(envelope["permission"], "deny") + # Both user-facing and agent-facing messages should be set so Cursor + # can surface the warning in whichever channel the current event + # supports (preToolUse blocks the AI; afterAgentResponse logs to + # the Output panel and may surface user_message). self.assertIn("Slice read out of bounds", envelope["agent_message"]) + self.assertIn("Slice read out of bounds", envelope["user_message"]) def test_inner_exit_1_also_maps_to_deny(self): # verify_slice_must_rules.py and verify_apply_project.py exit 1 on From 7f5920c4fb789dedceb5fcf7532e3fe2bea5524c Mon Sep 17 00:00:00 2001 From: Wei Zhao Date: Tue, 2 Jun 2026 11:14:02 +0800 Subject: [PATCH 3/3] docs: format example prompts as bullet points in feature table MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each cell in the "Example prompts" column previously inlined multiple prompts with · separators, which rendered as a single dense paragraph. Splitting them onto separate lines (with • bullet markers via
) makes each example prompt scannable at a glance — important since the table is the primary "what can I ask this skill" reference for new users. Same change applied to README.md (English) and README.zh.md (Chinese). No content changes — only the per-cell layout. Co-Authored-By: Claude Opus 4.7 (1M context) --- README.md | 6 +++--- README.zh.md | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index dff4364..0b10a28 100644 --- a/README.md +++ b/README.md @@ -119,9 +119,9 @@ The skill activates automatically when you mention TRTC or describe a real-time | | What it does | Example prompts | |---|---|---| -| **Get started** | Guides you through demo setup, SDK integration, troubleshooting, or adding a new feature — step by step | *"I want to add video conferencing to my web app"* · *"I'm getting error 6206 when users join"* · *"Conference is working — now I want to add screen sharing"* | -| **Scenario walkthrough** | Loads a complete feature scenario and walks you through each capability in order, with code and checkpoints | *"Walk me through building a complete conference room from scratch"* · *"Guide me through a 1-on-1 video consultation end to end"* | -| **Docs & lookup** | Answers factual questions from the official knowledge base with cited sources | *"What does error code 6206 mean?"* · *"How much does Conference cost per participant minute?"* · *"What's the max number of participants?"* | +| **Get started** | Guides you through demo setup, SDK integration, troubleshooting, or adding a new feature — step by step | • *"I want to add video conferencing to my web app"*
• *"I'm getting error 6206 when users join"*
• *"Conference is working — now I want to add screen sharing"* | +| **Scenario walkthrough** | Loads a complete feature scenario and walks you through each capability in order, with code and checkpoints | • *"Walk me through building a complete conference room from scratch"*
• *"Guide me through a 1-on-1 video consultation end to end"* | +| **Docs & lookup** | Answers factual questions from the official knowledge base with cited sources | • *"What does error code 6206 mean?"*
• *"How much does Conference cost per participant minute?"*
• *"What's the max number of participants?"* | The skill saves your progress in the project. If you close the tool and come back later, it picks up where you left off. diff --git a/README.zh.md b/README.zh.md index 7b58f33..b02917d 100644 --- a/README.zh.md +++ b/README.zh.md @@ -119,9 +119,9 @@ codex mcp add tencent-rtc --env SDKAPPID=YOUR_SDKAPPID --env SECRETKEY=YOUR_SECR | | 功能说明 | 示例 | |---|---|---| -| **快速上手** | 引导你跑通 Demo、从零集成、排查错误或添加新功能 | *"我想在 Web 应用里加视频会议"* · *"用户进房报错 6206"* · *"会议已接入,现在想加屏幕共享"* | -| **场景引导** | 加载完整场景,按顺序逐步实现每个能力,每步附代码和验证 | *"我想搭建一个会议应用"* · *"我想用 Conference 搭建一个医疗问诊场景"* | -| **文档查询** | 从官方知识库检索事实性问题,每个答案附来源引用 | *"错误码 6206 是什么意思?"* · *"Conference 按分钟怎么计费?"* · *"会议室最多支持多少人?"* | +| **快速上手** | 引导你跑通 Demo、从零集成、排查错误或添加新功能 | • *"我想在 Web 应用里加视频会议"*
• *"用户进房报错 6206"*
• *"会议已接入,现在想加屏幕共享"* | +| **场景引导** | 加载完整场景,按顺序逐步实现每个能力,每步附代码和验证 | • *"我想搭建一个会议应用"*
• *"我想用 Conference 搭建一个医疗问诊场景"* | +| **文档查询** | 从官方知识库检索事实性问题,每个答案附来源引用 | • *"错误码 6206 是什么意思?"*
• *"Conference 按分钟怎么计费?"*
• *"会议室最多支持多少人?"* | Skill 会在项目中保存你的进度。关掉工具下次回来,可以从上次中断的地方继续,不需要重新复述你在做什么。