Where does the bug appear (feature/product)?
Cursor IDE
Describe the Bug
Hook response regression bug is still present - the output from running hooks still does not arrive in the agent context. Cursor is NOT obeying its own spec as documented here: Hooks | Cursor Docs
Steps to Reproduce
Make a hook return something, ask agent if it can see the return.
Expected Behaviour
Should see it.
Screenshots / Screen Recordings
Operating System
Windows 10/11
Current Cursor Version (Menu → About Cursor → Copy)
Version: 2.1.6 (system setup)
VSCode Version: 1.105.1
Commit: 92340560ea81cb6168e2027596519d68af6c90a0
Date: 2025-11-20T03:38:22.386Z
Electron: 37.7.0
Chromium: 138.0.7204.251
Node.js: 22.20.0
V8: 13.8.258.32-electron.0
OS: Windows_NT x64 10.0.17763
For AI issues: which model did you use?
Claude 4.5
Additional Information
the “stop” hooks’ followup_message does work OK.
Tests:-
[cnd@RoG:~/cnd/.cursor]$ cat log_cursor_hook.py
#!/usr/bin/env python3
import sys
import os
import json
import time
import traceback
from pathlib import Path
# Platform specific locking
if os.name == "nt":
import msvcrt
def lock_file( f ):
# Lock 1 byte at the beginning of the file
msvcrt.locking( f.fileno( ), msvcrt.LK_LOCK, 1 )
def unlock_file( f ):
msvcrt.locking( f.fileno( ), msvcrt.LK_UNLCK, 1 )
else:
import fcntl
def lock_file( f ):
fcntl.flock( f.fileno( ), fcntl.LOCK_EX )
def unlock_file( f ):
fcntl.flock( f.fileno( ), fcntl.LOCK_UN )
def sanitize_for_utf8( obj ):
"""
Recursively sanitize an object to remove lone surrogates while preserving valid Unicode.
Lone surrogates are replaced with � (U+FFFD REPLACEMENT CHARACTER).
"""
if isinstance( obj, str ):
# Encode to UTF-8 with 'replace' to fix surrogates, then decode back
return obj.encode( "utf-8", errors = "replace" ).decode( "utf-8" )
elif isinstance( obj, dict ):
return { k: sanitize_for_utf8( v ) for k, v in obj.items( ) }
elif isinstance( obj, list ):
return [ sanitize_for_utf8( item ) for item in obj ]
else:
return obj
def log_event( event_name, payload, script_path ):
"""Log the hook event to hook_calls.txt"""
log_path = script_path.parent / "hook_calls.txt"
timestamp = time.strftime( "%Y-%m-%d %H:%M:%S", time.localtime( ) )
log_record = {
"timestamp": timestamp,
"event": event_name,
"payload": sanitize_for_utf8( payload ), # Clean payload before JSON encoding
}
# Now safe to use ensure_ascii=False to preserve emoji and valid Unicode
log_line = json.dumps( log_record, ensure_ascii = False )
# Append with a simple lock so concurrent hooks do not interleave
with open( log_path, "a", encoding = "utf-8" ) as f:
try:
lock_file( f )
except Exception:
pass
f.write( log_line + "\n" )
f.flush( )
try:
unlock_file( f )
except Exception:
pass
def create_response_for_event( event_name, payload, script_path ):
"""
Create spec-compliant response for each hook event type.
See: https://cursor.com/docs/agent/hooks
"""
# beforeShellExecution and beforeMCPExecution - return permission decision
if event_name in ( "beforeShellExecution", "beforeMCPExecution" ):
return {
"permission": "allow",
"user_message": f"{event_name} permitted",
"agent_message": f"Hook intercepted {event_name}. Operation allowed."
}
# beforeReadFile - return permission decision
elif event_name == "beforeReadFile":
return {
"permission": "allow",
"user_message": f"{event_name} allowed",
"agent_message": f"Hook intercepted {event_name}. Operation allowed."
}
# beforeSubmitPrompt - can prevent submission
elif event_name == "beforeSubmitPrompt":
return {
"continue": True,
"user_message": f"{event_name} continued",
"agent_message": f"Hook intercepted {event_name}. Operation allowed."
}
# stop - can optionally auto-submit follow-up message
elif event_name == "stop":
# Use a sentinel file to prevent infinite loops
# If file exists: no response (already sent followup once)
# If file doesn't exist: create it and send followup_message
sentinel_file = script_path.parent / "stop_hook_tested.sentinel"
if sentinel_file.exists( ):
# Already tested, don't send followup_message
return None
else:
# First time - create sentinel file and send followup_message
try:
sentinel_file.touch( )
except Exception:
pass
return {
"followup_message": "Hook test: The stop hook just auto-submitted this message. Can you see it AI?"
}
# afterShellExecution, afterMCPExecution, afterFileEdit, afterAgentResponse
# These hooks are observe-only and don't require a response per spec
elif event_name in ( "afterShellExecution", "afterMCPExecution", "afterFileEdit", "afterAgentResponse" ):
return None
# Unknown event - allow by default
else:
return {
"continue": True,
"permission": "allow",
"user_message": f"{event_name} weird",
"agent_message": f"Hook intercepted {event_name}. Operation allowed."
}
def main( ):
raw = None
payload = None
event_name = "unknown"
try:
raw = sys.stdin.read( )
if not raw.strip( ):
return
try:
payload = json.loads( raw )
except json.JSONDecodeError:
payload = { "raw": raw }
# Extract event name if present
event_name = payload.get( "hook_event_name", "unknown_event" )
# Log file in same directory as this script
script_path = Path( __file__ ).resolve( )
# Log the event
log_event( event_name, payload, script_path )
# Create spec-compliant response
response = create_response_for_event( event_name, payload, script_path )
# Only output response if one is required
if response is not None:
print( json.dumps( response, ensure_ascii = True ) )
sys.stdout.flush( )
except Exception as e:
# Log errors to help debug but still return valid JSON
try:
script_path = Path( __file__ ).resolve( )
error_log_path = script_path.parent / "hook_errors.txt"
timestamp = time.strftime( "%Y-%m-%d %H:%M:%S", time.localtime( ) )
# Get full stack trace
tb = traceback.format_exc( )
# Write with errors='replace' to handle any encoding issues
with open( error_log_path, "a", encoding = "utf-8", errors = "replace" ) as f:
f.write( f"\n{'='*70}\n" )
f.write( f"[{timestamp}] Error in event '{event_name}': {str(e)}\n" )
f.write( f"Stack trace:\n{tb}\n" )
# Log the problematic input data if we captured it
if raw is not None:
# Safely represent the input, handling encoding issues
try:
safe_input = repr( raw[:1000] ) # First 1000 chars
f.write( f"Raw input (first 1000 chars):\n{safe_input}\n\n" )
except Exception as repr_error:
f.write( f"Raw input: <could not repr: {repr_error}>\n\n" )
if payload is not None:
# Try to log payload keys at least
try:
keys = list( payload.keys( ) ) if isinstance( payload, dict ) else "<not a dict>"
f.write( f"Payload keys: {keys}\n" )
except:
f.write( f"Payload: <could not inspect>\n" )
f.write( f"{'='*70}\n\n" )
# Return safe default response
error_response = {
"continue": True,
"permission": "allow"
}
print( json.dumps( error_response, ensure_ascii = True ) )
sys.stdout.flush( )
except:
# Complete failure - try to output valid JSON and exit
try:
print( '{"continue":true,"permission":"allow"}' )
sys.stdout.flush( )
except:
pass
if __name__ == "__main__":
main( )
[cnd@RoG:~/cnd/.cursor]$ cat hooks.json
{
"version": 1,
"hooks": {
"beforeSubmitPrompt": [
{
"command": "C:\\Users\\cnd\\AppData\\Roaming\\AuraFriday\\mcp-link-server\\bin\\python .\\log_cursor_hook.py"
}
],
"beforeShellExecution": [
{
"command": "C:\\Users\\cnd\\AppData\\Roaming\\AuraFriday\\mcp-link-server\\bin\\python .\\log_cursor_hook.py"
}
],
"beforeMCPExecution": [
{
"command": "C:\\Users\\cnd\\AppData\\Roaming\\AuraFriday\\mcp-link-server\\bin\\python .\\log_cursor_hook.py"
}
],
"beforeReadFile": [
{
"command": "C:\\Users\\cnd\\AppData\\Roaming\\AuraFriday\\mcp-link-server\\bin\\python .\\log_cursor_hook.py"
}
],
"afterAgentResponse": [
{
"command": "C:\\Users\\cnd\\AppData\\Roaming\\AuraFriday\\mcp-link-server\\bin\\python .\\log_cursor_hook.py"
}
],
"afterFileEdit": [
{
"command": "C:\\Users\\cnd\\AppData\\Roaming\\AuraFriday\\mcp-link-server\\bin\\python .\\log_cursor_hook.py"
}
],
"stop": [
{
"command": "C:\\Users\\cnd\\AppData\\Roaming\\AuraFriday\\mcp-link-server\\bin\\python .\\log_cursor_hook.py"
}
]
}
}
Does this stop you from using Cursor
No - Cursor works, but with this issue
