Hooks still not working properly in 2.1.6

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

Hey, thanks for the report. This is a known regression bug that’s been affecting hooks since v2.0.64.

You’ve confirmed that:

  • Hooks are executing (followup_message works)
  • The JSON output is valid and correctly formatted
  • agent_message is not reaching the AI context

This matches other reports.

I’m escalating this to the engineering team. Could you share a Request ID from one of your test interactions (Chat context menu → Copy Request ID)? That will help us track down and fix the issue faster.

This topic was automatically closed 22 days after the last reply. New replies are no longer allowed.