Hooks are now completely broken!
The IDE 2.1.42 says it’s calling the hook (see screenshot), however, it does NOT perform the call at all (my hook code is never run, and does not log anything), and does not give any error.
Version: 2.1.42 (system setup)
VSCode Version: 1.105.1
Commit: 2e353c5f5b30150ff7b874dee5a87660693d9de0
Date: 2025-12-01T02:18:26.377Z
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
It was running and logging fine yesterday morning, but I did upgrade to 2.1.41 yesterday as well, and it looks like that version also broke hooks entirely
Here’s the last 2 lines of my logfile my hook writes out:
{“timestamp”: “2025-12-01 10:43:08”, “event”: “stop”, “payload”: {“conversation_id”: “2207479a-b15b-4125-8b0c-***”, “generation_id”: “b64669d9-eebf-4421-b15f-***”, “model”: “claude-4.5-sonnet”, “status”: “completed”, “loop_count”: 0, “hook_event_name”: “stop”, “cursor_version”: “2.1.15”, “workspace_roots”:[“/C:/Users/cnd/Downloads/cursor/fusion_ai”], “user_email”: “***”}}
{“timestamp”: “2025-12-02 06:13:29”, “event”: “unknown_event”, “payload”: {“raw”: “test\n”}}
(that last line was me manually running the hook to check it still runs and logs OK - it does).
I log all hooks for auditing - here’s counts of some working ones on my desktop:
$ cat hook_calls.txt | perl -ne ‘$=~s/.cursor_version(…)./cursor_version$1/;print "$" if(/cursor_version/)’ | countp
1188 cursor_version": “2.1.15”,
999 cursor_version": “2.0.77”,
248 cursor_version": “2.1.6”,
81 cursor_version": “2.0.69”,
Here’s some working ones on my laptop:
$ cat hook_calls.txt | perl -ne ‘$=~s/.cursor_version(…)./cursor_version$1/;print "$" if(/cursor_version/)’ | countp
156 cursor_version": “2.1.25”,
26 cursor_version": “2.0.77”,
104 cursor_version": “2.1.6”,
So the last version of cursor I know of that executed hooks properly is 2.1.25
This is my hook file:
$ 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"
}
]
}
}
This is my logging script:
$ 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 per spec, but we'll return messages to test if they reach the AI
elif event_name in ( "afterShellExecution", "afterMCPExecution", "afterFileEdit", "afterAgentResponse" ):
return {
"user_message": f"[Hook] {event_name} completed",
"agent_message": f"Hook observed {event_name}. Can you see this message?"
}
# 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( )
