Cursor CLI hooks

Feature request for product/service

Cursor CLI

Describe the request

It appears hooks (~/.cursor/hooks.json) only work with the IDE, not the CLI - even with the latest changelog which makes it sound like hooks came to CLI:

The CLI is where I need hooks most, to e.g. wire up a Discord notification for the stop event so I can flip to that terminal and tend to it when needed (I’ve hacked something here: GitHub - dnielbowen/cursor-notifier: Sends a Discord notification when Cursor CLI agent instances go idle).

The ~/.cursor/hooks.json I’ve configured works great for the Cursor IDE, but I don’t need it there since it already notifies me on idle (which is my CLI hooks use-case).

Also, assuming hooks support is eventually added for Cursor CLI, there should be a parameter or some way to distinguish that the event came from CLI, not the IDE, so I can filter out the IDE events.

I think the CLI does run the hooks just fine (bugs included)

[cnd@RoG:~/.cursor]$ dir
total 400
lrwxrwxrwx 1 cnd cnd     30 2025-10-01 10:21 User -> ../AppData/Roaming/Cursor/User/
lrwxrwxrwx 1 cnd cnd     37 2025-10-10 07:58 versions -> ../.local/share/cursor-agent/versions/
-rw-rw-r-- 1 cnd cnd   3222 2025-10-18 09:28 all_cursor_files.txt
-rwxrwxrwx 1 cnd cnd   1403 2025-12-24 11:38 hooks.json
-rwxrwxrwx 1 cnd cnd  10580 2025-12-24 11:39 log_cursor_hook.py
-rw------- 1 cnd cnd    441 2026-01-01 22:19 mcp.json
-rw-rw-rw- 1 cnd cnd   1263 2026-01-04 17:40 ide_state.json
-rw-rw-r-- 1 cnd cnd    523 2026-01-04 20:53 cli-config.json.bad
drwxrwxr-x 1 cnd cnd    512 2026-01-06 15:28 projects/
-rw-rw-r-- 1 cnd cnd     53 2026-01-09 20:39 agent-cli-state.json
drwxrwxr-x 1 cnd cnd    512 2026-01-09 20:39 chats/
-rw-rw-r-- 1 cnd cnd  31538 2026-01-10 00:44 python_debug.log
-rw-rw-r-- 1 cnd cnd 342475 2026-01-10 00:44 hook_calls.txt
-rw-rw-r-- 1 cnd cnd    549 2026-01-12 12:50 cli-config.json
[cnd@RoG:~/.cursor]$ cat hooks.json 
{
  "version": 1,
  "hooks": {
    "beforeSubmitPrompt": [
      {
        "command": "python /home/cnd/.cursor/log_cursor_hook.py"
      }
    ],
    "beforeShellExecution": [
      {
        "command": "python /home/cnd/.cursor/log_cursor_hook.py"
      }
    ],
    "beforeMCPExecution": [
      {
        "command": "python /home/cnd/.cursor/log_cursor_hook.py"
      }
    ],
    "beforeReadFile": [
      {
        "command": "python /home/cnd/.cursor/log_cursor_hook.py"
      }
    ],
    "beforeTabFileRead": [
      {
        "command": "python /home/cnd/.cursor/log_cursor_hook.py"
      }
    ],
    "afterAgentResponse": [
      {
        "command": "python /home/cnd/.cursor/log_cursor_hook.py"
      }
    ],
    "afterAgentThought": [
      {
        "command": "python /home/cnd/.cursor/log_cursor_hook.py"
      }
    ],
    "afterFileEdit": [
      {
        "command": "python /home/cnd/.cursor/log_cursor_hook.py"
      }
    ],
    "afterTabFileEdit": [
      {
        "command": "python /home/cnd/.cursor/log_cursor_hook.py"
      }
    ],
    "afterShellExecution": [
      {
        "command": "python /home/cnd/.cursor/log_cursor_hook.py"
      }
    ],
    "afterMCPExecution": [
      {
        "command": "python /home/cnd/.cursor/log_cursor_hook.py"
      }
    ],
    "stop": [
      {
        "command": "python /home/cnd/.cursor/log_cursor_hook.py"
      }
    ]
  }
}
[cnd@RoG:~/.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_hook_call( event_name, payload, response, script_path, error = None ):
    """
    Log both the incoming hook event and outgoing response on a single line.
    Always succeeds - never raises exceptions.
    """
    try:
        # Use hardcoded path for consistency
        log_path = Path( r"/home/cnd/.cursor/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 ),
            "response": sanitize_for_utf8( response ) if response is not None else None,
            "error": str( error ) if error is not None else None
        }

        # Single line JSON with ensure_ascii=False to preserve emoji
        log_line = json.dumps( log_record, ensure_ascii = False )

        # Append with locking to prevent interleaving
        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

    except Exception as log_error:
        # If logging fails, try to write to a fallback error log
        try:
            error_log_path = Path( r"/home/cnd/.cursor/hook_log_errors.txt" )
            timestamp = time.strftime( "%Y-%m-%d %H:%M:%S", time.localtime( ) )
            with open( error_log_path, "a", encoding = "utf-8", errors = "replace" ) as f:
                f.write( f"[{timestamp}] Failed to log {event_name}: {log_error}\n" )
        except:
            # Complete failure - nothing we can do
            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 (Agent only)
    elif event_name == "beforeReadFile":
        return {
            "permission": "allow",
            "user_message": f"{event_name} allowed",
            "agent_message": f"Hook intercepted {event_name}. Operation allowed."
        }

    # beforeTabFileRead - return permission decision (Tab only)
    elif event_name == "beforeTabFileRead":
        return {
            "permission": "allow",
            "user_message": f"{event_name} allowed",
            "agent_message": f"Hook intercepted {event_name}. Tab file read 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, afterAgentThought
    # 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", "afterAgentThought" ):
        return {
            "user_message": f"[Hook] {event_name} completed",
            "agent_message": f"Hook observed {event_name}. Can you see this message?"
        }

    # afterTabFileEdit - observe-only (Tab only, no output fields supported per spec)
    elif event_name == "afterTabFileEdit":
        # Per spec: No output fields currently supported, but we'll return messages to test
        return {
            "user_message": f"[Hook] {event_name} completed",
            "agent_message": f"Hook observed {event_name}. Tab file edit completed."
        }

    # 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"
    response = None
    script_path = None
    
    # Debug: Log that Python was called
    try:
        with open( r"/home/cnd/.cursor/python_debug.log", "a", encoding = "utf-8" ) as f:
            f.write( f"\n[PYTHON] Called at {time.strftime('%Y-%m-%d %H:%M:%S')}\n" )
            f.write( f"[PYTHON] sys.stdin.isatty(): {sys.stdin.isatty()}\n" )
    except:
        pass
    
    try:
        raw = sys.stdin.read( )
        
        # Debug: Log what we received
        try:
            with open( r"/home/cnd/.cursor/python_debug.log", "a", encoding = "utf-8" ) as f:
                f.write( f"[PYTHON] Received {len(raw)} bytes\n" )
                f.write( f"[PYTHON] First 200 chars: {raw[:200]}\n" )
        except:
            pass

        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" )

        # Get script path
        script_path = Path( __file__ ).resolve( )

        # Create spec-compliant response
        response = create_response_for_event( event_name, payload, script_path )

        # Log both the incoming event and outgoing response
        log_hook_call( event_name, payload, response, 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 the error along with whatever we captured
        try:
            if script_path is None:
                script_path = Path( __file__ ).resolve( )
            
            # Log this failed call with error info
            log_hook_call( event_name, payload, response, script_path, error = e )
            
            # Also write detailed error to separate error log
            error_log_path = Path( r"/home/cnd/.cursor/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:
                    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:
                        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" )

        except:
            # Even error logging failed - just continue to output valid JSON
            pass

        # Always return safe default response so Cursor doesn't hang
        try:
            error_response = {
                "continue": True,
                "permission": "allow"
            }
            print( json.dumps( error_response, ensure_ascii = True ) )
            sys.stdout.flush( )
        except:
            # Absolute last resort
            try:
                print( '{"continue":true,"permission":"allow"}' )
                sys.stdout.flush( )
            except:
                pass


if __name__ == "__main__":
    main( )

As you can see - it’s writing to my hook_calls.txt file at least.

Update - looks like it’s never calling any stop hook though?

cat hook_calls.txt | nowrap 
{"timestamp": "2025-12-24 11:40:02", "event": "unknown_event", "payload": {"raw": "asd\n"}, 
{"timestamp": "2025-12-24 12:25:02", "event": "afterShellExecution", "payload": {"conversati
{"timestamp": "2025-12-24 12:25:04", "event": "beforeShellExecution", "payload": {"conversat
{"timestamp": "2025-12-24 12:25:04", "event": "afterShellExecution", "payload": {"conversati
{"timestamp": "2026-01-04 17:44:05", "event": "afterShellExecution", "payload": {"conversati
{"timestamp": "2026-01-04 17:44:05", "event": "afterShellExecution", "payload": {"conversati
{"timestamp": "2026-01-04 17:46:16", "event": "beforeShellExecution", "payload": {"conversat
{"timestamp": "2026-01-04 17:46:17", "event": "afterShellExecution", "payload": {"conversati
{"timestamp": "2026-01-04 17:46:17", "event": "beforeShellExecution", "payload": {"conversat
{"timestamp": "2026-01-04 17:46:18", "event": "afterShellExecution", "payload": {"conversati
{"timestamp": "2026-01-04 17:46:40", "event": "beforeShellExecution", "payload": {"conversat
{"timestamp": "2026-01-04 17:46:41", "event": "beforeShellExecution", "payload": {"conversat
{"timestamp": "2026-01-04 17:46:41", "event": "afterShellExecution", "payload": {"conversati
{"timestamp": "2026-01-04 17:46:41", "event": "afterShellExecution", "payload": {"conversati
{"timestamp": "2026-01-04 17:54:39", "event": "beforeShellExecution", "payload": {"conversat
{"timestamp": "2026-01-04 17:54:39", "event": "beforeShellExecution", "payload": {"conversat
{"timestamp": "2026-01-04 17:54:40", "event": "afterShellExecution", "payload": {"conversati
{"timestamp": "2026-01-04 17:54:40", "event": "afterShellExecution", "payload": {"conversati
{"timestamp": "2026-01-04 17:58:52", "event": "afterMCPExecution", "payload": {"conversation
{"timestamp": "2026-01-04 17:58:57", "event": "beforeMCPExecution", "payload": {"conversatio
{"timestamp": "2026-01-04 17:58:58", "event": "afterMCPExecution", "payload": {"conversation
{"timestamp": "2026-01-04 19:02:37", "event": "afterShellExecution", "payload": {"conversati
{"timestamp": "2026-01-04 19:02:40", "event": "beforeShellExecution", "payload": {"conversat
{"timestamp": "2026-01-04 19:02:41", "event": "afterShellExecution", "payload": {"conversati
{"timestamp": "2026-01-04 19:02:45", "event": "afterFileEdit", "payload": {"conversation_id"
{"timestamp": "2026-01-09 23:00:59", "event": "afterShellExecution", "payload": {"conversati
{"timestamp": "2026-01-09 23:01:06", "event": "beforeShellExecution", "payload": {"conversat
{"timestamp": "2026-01-09 23:01:53", "event": "afterShellExecution", "payload": {"conversati
{"timestamp": "2026-01-09 23:02:00", "event": "beforeShellExecution", "payload": {"conversat
{"timestamp": "2026-01-09 23:02:01", "event": "beforeShellExecution", "payload": {"conversat
{"timestamp": "2026-01-09 23:02:01", "event": "afterShellExecution", "payload": {"conversati
{"timestamp": "2026-01-09 23:02:02", "event": "afterShellExecution", "payload": {"conversati
{"timestamp": "2026-01-09 23:02:18", "event": "beforeShellExecution", "payload": {"conversat
{"timestamp": "2026-01-09 23:02:19", "event": "beforeShellExecution", "payload": {"conversat
{"timestamp": "2026-01-09 23:02:19", "event": "afterShellExecution", "payload": {"conversati
{"timestamp": "2026-01-09 23:02:20", "event": "afterShellExecution", "payload": {"conversati
{"timestamp": "2026-01-09 23:02:30", "event": "beforeMCPExecution", "payload": {"conversatio
{"timestamp": "2026-01-09 23:02:31", "event": "afterMCPExecution", "payload": {"conversation
{"timestamp": "2026-01-09 23:02:43", "event": "beforeMCPExecution", "payload": {"conversatio
{"timestamp": "2026-01-09 23:02:44", "event": "afterMCPExecution", "payload": {"conversation
{"timestamp": "2026-01-09 23:03:00", "event": "beforeMCPExecution", "payload": {"conversatio
{"timestamp": "2026-01-09 23:03:00", "event": "afterMCPExecution", "payload": {"conversation
{"timestamp": "2026-01-09 23:03:09", "event": "beforeShellExecution", "payload": {"conversat
{"timestamp": "2026-01-09 23:03:10", "event": "afterShellExecution", "payload": {"conversati
{"timestamp": "2026-01-09 23:03:10", "event": "beforeShellExecution", "payload": {"conversat
{"timestamp": "2026-01-09 23:03:11", "event": "afterShellExecution", "payload": {"conversati
{"timestamp": "2026-01-09 23:03:25", "event": "beforeMCPExecution", "payload": {"conversatio
{"timestamp": "2026-01-09 23:04:00", "event": "afterMCPExecution", "payload": {"conversation
{"timestamp": "2026-01-09 23:04:24", "event": "beforeMCPExecution", "payload": {"conversatio
{"timestamp": "2026-01-09 23:04:24", "event": "afterMCPExecution", "payload": {"conversation
{"timestamp": "2026-01-09 23:04:37", "event": "beforeMCPExecution", "payload": {"conversatio
{"timestamp": "2026-01-09 23:04:37", "event": "afterMCPExecution", "payload": {"conversation
{"timestamp": "2026-01-09 23:04:57", "event": "afterFileEdit", "payload": {"conversation_id"
{"timestamp": "2026-01-09 23:05:17", "event": "afterFileEdit", "payload": {"conversation_id"
{"timestamp": "2026-01-09 23:05:44", "event": "beforeMCPExecution", "payload": {"conversatio
{"timestamp": "2026-01-09 23:07:12", "event": "afterFileEdit", "payload": {"conversation_id"
{"timestamp": "2026-01-10 00:31:49", "event": "beforeShellExecution", "payload": {"conversat
{"timestamp": "2026-01-10 00:31:51", "event": "afterShellExecution", "payload": {"conversati
{"timestamp": "2026-01-10 00:32:26", "event": "afterFileEdit", "payload": {"conversation_id"
{"timestamp": "2026-01-10 00:32:36", "event": "beforeShellExecution", "payload": {"conversat
{"timestamp": "2026-01-10 00:32:37", "event": "afterShellExecution", "payload": {"conversati
{"timestamp": "2026-01-10 00:33:03", "event": "afterFileEdit", "payload": {"conversation_id"
{"timestamp": "2026-01-10 00:33:06", "event": "afterFileEdit", "payload": {"conversation_id"
{"timestamp": "2026-01-10 00:33:40", "event": "beforeMCPExecution", "payload": {"conversatio
{"timestamp": "2026-01-10 00:35:06", "event": "afterFileEdit", "payload": {"conversation_id"
{"timestamp": "2026-01-10 00:35:18", "event": "beforeMCPExecution", "payload": {"conversatio
{"timestamp": "2026-01-10 00:35:19", "event": "afterMCPExecution", "payload": {"conversation
{"timestamp": "2026-01-10 00:35:53", "event": "afterFileEdit", "payload": {"conversation_id"
{"timestamp": "2026-01-10 00:36:01", "event": "beforeShellExecution", "payload": {"conversat
{"timestamp": "2026-01-10 00:36:02", "event": "afterShellExecution", "payload": {"conversati
{"timestamp": "2026-01-10 00:36:05", "event": "beforeMCPExecution", "payload": {"conversatio
{"timestamp": "2026-01-10 00:36:06", "event": "afterMCPExecution", "payload": {"conversation
{"timestamp": "2026-01-10 00:36:22", "event": "beforeMCPExecution", "payload": {"conversatio
{"timestamp": "2026-01-10 00:36:23", "event": "afterMCPExecution", "payload": {"conversation
{"timestamp": "2026-01-10 00:36:36", "event": "beforeMCPExecution", "payload": {"conversatio
{"timestamp": "2026-01-10 00:36:37", "event": "afterMCPExecution", "payload": {"conversation
{"timestamp": "2026-01-10 00:36:49", "event": "afterFileEdit", "payload": {"conversation_id"
{"timestamp": "2026-01-10 00:36:50", "event": "afterFileEdit", "payload": {"conversation_id"
{"timestamp": "2026-01-10 00:37:20", "event": "afterFileEdit", "payload": {"conversation_id"
{"timestamp": "2026-01-10 00:37:32", "event": "beforeMCPExecution", "payload": {"conversatio
{"timestamp": "2026-01-10 00:37:35", "event": "afterMCPExecution", "payload": {"conversation
{"timestamp": "2026-01-10 00:38:08", "event": "afterFileEdit", "payload": {"conversation_id"
{"timestamp": "2026-01-10 00:38:40", "event": "afterFileEdit", "payload": {"conversation_id"
{"timestamp": "2026-01-10 00:39:00", "event": "beforeMCPExecution", "payload": {"conversatio
{"timestamp": "2026-01-10 00:40:17", "event": "afterFileEdit", "payload": {"conversation_id"
{"timestamp": "2026-01-10 00:41:12", "event": "afterFileEdit", "payload": {"conversation_id"
{"timestamp": "2026-01-10 00:42:06", "event": "afterFileEdit", "payload": {"conversation_id"
{"timestamp": "2026-01-10 00:42:29", "event": "afterFileEdit", "payload": {"conversation_id"
{"timestamp": "2026-01-10 00:43:01", "event": "afterFileEdit", "payload": {"conversation_id"
{"timestamp": "2026-01-10 00:43:10", "event": "beforeShellExecution", "payload": {"conversat
{"timestamp": "2026-01-10 00:43:12", "event": "afterShellExecution", "payload": {"conversati
{"timestamp": "2026-01-10 00:43:21", "event": "beforeShellExecution", "payload": {"conversat
{"timestamp": "2026-01-10 00:43:22", "event": "afterShellExecution", "payload": {"conversati
{"timestamp": "2026-01-10 00:44:27", "event": "afterFileEdit", "payload": {"conversation_id"
{"timestamp": "2026-01-10 00:44:36", "event": "beforeShellExecution", "payload": {"conversat
{"timestamp": "2026-01-10 00:44:37", "event": "afterShellExecution", "payload": {"conversati

You would add that manually into the hooks.json file - the CLI and the IDE use different files for this (at least on windows - not sure about Mac?)

As of now, the CLI and the IDE differ in the parameters they pass, so you can tell one from the other anyhow. the CLI does not send these things that the IDE does send:

“conversation_id”: “”, “generation_id”: “”, “model”: “unknown”,

@andrewh this confirms that bug I’m waiting for someone to fix - it was documented and implemented in the parameters, but the source code in your product (I asked Claude to look at the CLI install folder) simply discards it:

More Claude research:

Summary of Findings

1. Which hooks are actually implemented and working in the CLI?

Working hooks (these have executor wrappers that call them):

  • beforeShellExecution :white_check_mark: - via ShellExecutorWithHooks / ShellStreamExecutorWithHooks

  • afterShellExecution :white_check_mark: - via same

  • beforeMCPExecution :white_check_mark: - via McpToolExecutorWithHooks

  • afterMCPExecution :white_check_mark: - via same

  • afterFileEdit :white_check_mark: - via WriteExecutorWithHooks

NOT implemented in CLI (types exist but no code calls them):

  • stop :cross_mark: - No caller found

  • beforeSubmitPrompt :cross_mark: - No caller found

  • afterAgentResponse :cross_mark: - No caller found

  • afterAgentThought :cross_mark: - No caller found

  • beforeReadFile :cross_mark: - No caller found

  • beforeTabFileRead :cross_mark: - Tab-specific, not relevant to CLI

  • afterTabFileEdit :cross_mark: - Tab-specific, not relevant to CLI

The hooksConfigLease with hasHookForStep() is returned from the shared services, but it’s only used to check if any hooks are configured - the agent loop itself never calls any hooks directly. It only works through the resource accessor wrappers.

Nice deep dive. For some reason though even when I copy-paste this hooks.json to ~/.cursor along with ~/.cursor/log_cursor_hook.py, I still don’t see any hook_calls.txt file in ~/.cursor when I e.g. ask cursor-agent to generate a simple json file (I would’ve thought afterFileEdit at least).

I’m on cursor-agent version 2026.01.09-231024f on Linux.

In any case even if I did get it working, I would still want this feature request open to add the stop event to the CLI.