Stop hook followup_message not captured on Windows – Execution Log shows {} despite valid JSON on stdout

Where does the bug appear (feature/product)?

Cursor IDE

Describe the Bug

On Windows, the stop hook prints valid JSON with followup_message to stdout and exits with code 0, but the Execution Log shows {} and the agent does not continue with the injected prompt.

Steps to Reproduce

Configure ~/.cursor/hooks.json:
{
“version”: 1,
“hooks”: {
“afterFileEdit”: [
{ “command”: “python C:/Users/kyzer/.cursor/hooks/review-implementation-mark-edit.py” }
],
“stop”: [
{ “command”: “python C:/Users/kyzer/.cursor/hooks/review-implementation-stop.py” }
]
}
}

In Agent Chat, ask the agent to make a small edit to a project file.
Let the agent finish and stop.
Open the Hooks Execution Log / Output panel.

Expected Behavior

The stop hook should return {“followup_message”: “…”} when edits are detected.
Cursor should use that output and inject the follow-up prompt so the agent continues.

Screenshots / Screen Recordings

Operating System

Windows 10/11

Version Information

Version: 2.7.0-pre.78.patch.0 (system setup)
VSCode Version: 1.105.1
Commit: e90bbd67c3217c349237212784101d24188be4e0
Date: 2026-03-17T05:55:42.484Z
Build Type: Stable
Release Track: Nightly
Electron: 39.8.1
Chromium: 142.0.7444.265
Node.js: 22.22.1
V8: 14.2.231.22-electron.0
OS: Windows_NT x64 10.0.26200

Does this stop you from using Cursor

Sometimes - I can sometimes use Cursor

Hey, thanks for the report and the screenshot. Both hooks stop and afterFileEdit are running, but they’re returning {}. This is a known issue with hooks on Windows.

To narrow it down, a few things:

  1. Can you share the code of review-implementation-stop.py? {} could mean the script itself returns an empty object, or stdout is getting cut off by PowerShell.

  2. Go to View > Output in the top menu, then select Hooks from the dropdown in the output panel. Copy the full log from there. It shows service-level diagnostics like config loading and errors that don’t show up in the Execution Log.

  3. Try running the script manually in PowerShell:

echo '{"hook_event_name":"stop","status":"completed","loop_count":0}' | python -u C:/Users/kyzer/.cursor/hooks/review-implementation-stop.py

If you see valid JSON with followup_message in the terminal, the bug is on our side. If it prints {}, the issue is in the script logic.

1. Script code

Here’s the full review-implementation-stop.py:

#!/usr/bin/env python3
"""
Cursor stop hook: when AI made edits, inject follow-up to review before stopping.
Original: github.com/anthnykr/agent-setups | kr0der.com/newsletter/...
"""
import json
import sys
import time
from pathlib import Path

# Debug logging (writes to file, not stdout)
_DEBUG_LOG = Path.home() / ".cursor" / "hooks" / "state" / "debug-26892d.log"
def _workspace_log_path(payload: dict | None) -> Path | None:
    if not payload:
        return None
    roots = payload.get("workspace_roots")
    if isinstance(roots, list) and roots and isinstance(roots[0], str) and roots[0]:
        r = roots[0].replace("\\", "/").strip()
        if len(r) > 2 and r[0] == "/" and r[2] == ":":
            r = r[1:]
        return Path(r) / "debug-26892d.log"
    return None

def _log(msg: str, data: dict, hyp: str, payload: dict | None = None) -> None:
    line = json.dumps({"sessionId":"26892d","message":msg,"data":data,"hypothesisId":hyp,"timestamp":time.time()*1000}) + "\n"
    for p in (_workspace_log_path(payload), _DEBUG_LOG):
        if p:
            try:
                with open(p, "a", encoding="utf-8") as f:
                    f.write(line)
            except OSError:
                pass

from review_implementation_helpers import (
    consume_edit_marker,
    extract_session_id,
    extract_turn_id,
    noop,
    session_has_edits_from_transcript,
)

REVIEW_PROMPT = (
    "Review your implementation before stopping. Check whether there is a better or simpler approach, "
    "whether any redundant code remains, whether duplicate logic was introduced, and whether any dead or unused "
    "code was left behind. If you find issues, fix them now; if not, briefly confirm the implementation is clean."
)

def main() -> int:
    raw = sys.stdin.read().strip()
    if not raw:
        return noop()

    try:
        payload = json.loads(raw)
    except json.JSONDecodeError:
        return noop()

    if not isinstance(payload, dict):
        return noop()

    status = payload.get("status")
    loop_count_raw = payload.get("loop_count", 0)
    try:
        loop_count = int(loop_count_raw)
    except (TypeError, ValueError):
        loop_count = 1

    if status != "completed" or loop_count != 0:
        return noop()

    session_id = extract_session_id(payload=payload)
    turn_id = extract_turn_id(payload=payload)
    if not session_id:
        return noop()

    consumed = consume_edit_marker(session_id=session_id, turn_id=turn_id, payload=payload)
    if consumed:
        out = json.dumps({"followup_message": REVIEW_PROMPT}) + "\n"
        sys.stdout.write(out)
        sys.stdout.flush()
        return 0

    # Fallback: stop runs before afterFileEdit, detect edits from transcript
    transcript_path = payload.get("transcript_path")
    workspace_roots = payload.get("workspace_roots")
    if isinstance(transcript_path, str) and isinstance(workspace_roots, list) and workspace_roots:
        has_edits = session_has_edits_from_transcript(transcript_path, workspace_roots)
        if has_edits:
            out = json.dumps({"followup_message": REVIEW_PROMPT}) + "\n"
            sys.stdout.write(out)
            sys.stdout.flush()
            return 0
    return noop()

if __name__ == "__main__":
    raise SystemExit(main())

2. Hooks output log

[2026-03-18T05:29:10.081Z] Found 1 hook(s) to execute for step: afterFileEdit
[2026-03-18T05:29:10.081Z] Executing hook 1/1 from user config
[2026-03-18T05:29:10.081Z] Running script in directory: c:\Users\kyzer\.cursor
[2026-03-18T05:29:10.084Z] Running hook 1 with timeout 60000ms
═══════════════════════════════════════════════════════════════════════════════════════
afterFileEdit
═══════════════════════════════════════════════════════════════════════════════════════
Command: python C:/Users/kyzer/.cursor/hooks/review-implementation-mark-edit.py (589ms) exit code: 0

INPUT:
{
  "conversation_id": "2019b19c-bce0-4d60-9ffa-9fc017ff7526",
  "generation_id": "97a55a6d-23bb-4649-8c43-56693323fd33",
  "model": "composer-1.5",
  "file_path": "c:\\Users\\kyzer\\OneDrive\\Documents\\CS_USM\\pixel2024\\frontend\\src\\components\\samples\\UseEffectSample.tsx",
  "edits": [
    {
      "old_string": "",
      "new_string": "import React, { useEffect, useState } from \"react\";\n\n/**\n * Sample component demonstrating common useEffect patterns:\n * 1. Run on mount (fetch/init)\n * 2. Run with cleanup (subscriptions, intervals)\n * 3. Run when dependencies change\n */\nconst UseEffectSample: React.FC = () => {\n  const [count, setCount] = useState(0);\n  const [mountedAt, setMountedAt] = useState<string | null>(null);\n  const [windowWidth, setWindowWidth] = useState<number | null>(null);\n\n  // 1. Run once on mount (empty dependency array)\n  useEffect(() => {\n    setMountedAt(new Date().toISOString());\n  }, []);\n\n  // 2. Run with cleanup - subscribe to window resize, clean up on unmount\n  useEffect(() => {\n    const handleResize = () => setWindowWidth(window.innerWidth);\n    setWindowWidth(window.innerWidth);\n    window.addEventListener(\"resize\", handleResize);\n\n    return () => {\n      window.removeEventListener(\"resize\", handleResize);\n    };\n  }, []);\n\n  // 3. Run when `count` changes\n  useEffect(() => {\n    document.title = `Count: ${count}`;\n\n    return () => {\n      document.title = \"Pixel 2024\";\n    };\n  }, [count]);\n\n  return (\n    <div className=\"rounded-lg border border-grey-3 p-4 space-y-4 max-w-md\">\n      <h3 className=\"font-bold text-lg text-primary-6\">useEffect Sample</h3>\n\n      <div className=\"space-y-2 text-sm\">\n        <p>\n          <span className=\"font-medium\">Mounted at:</span>{\" \"}\n          {mountedAt ?? \"—\"}\n        </p>\n        <p>\n          <span className=\"font-medium\">Window width:</span>{\" \"}\n          {windowWidth ?? \"—\"}px\n        </p>\n      </div>\n\n      <div className=\"flex items-center gap-3\">\n        <button\n          type=\"button\"\n          className=\"daisy-btn daisy-btn-primary daisy-btn-sm\"\n          onClick={() => setCount((c) => c + 1)}\n        >\n          Increment ({count})\n        </button>\n        <button\n          type=\"button\"\n          className=\"daisy-btn daisy-btn-ghost daisy-btn-sm\"\n          onClick={() => setCount(0)}\n        >\n          Reset\n        </button>\n      </div>\n\n      <p className=\"text-xs text-grey-4\">\n        Check the browser tab title — it updates when count changes.\n      </p>\n    </div>\n  );\n};\n\nexport default UseEffectSample;\n"
    }
  ],
  "hook_event_name": "afterFileEdit",
  "cursor_version": "2.7.0-pre.78.patch.0",
  "workspace_roots": [
    "/c:/Users/kyzer/OneDrive/Documents/CS_USM/pixel2024"
  ],
  "user_email": "[email protected]",
  "transcript_path": "c:\\Users\\kyzer\\.cursor\\projects\\c-Users-kyzer-OneDrive-Documents-CS-USM-pixel2024\\agent-transcripts\\2019b19c-bce0-4d60-9ffa-9fc017ff7526\\2019b19c-bce0-4d60-9ffa-9fc017ff7526.jsonl"
}

OUTPUT:
{}
═══════════════════════════════════════════════════════════════════════════════════════
[2026-03-18T05:29:10.671Z] Hook 1 executed successfully and returned valid response
[2026-03-18T05:29:10.672Z] Merged 1 valid response(s) for step afterFileEdit
[2026-03-18T05:29:14.857Z] Hook step requested: stop
[2026-03-18T05:29:14.872Z] Found 1 hook(s) to execute for step: stop
[2026-03-18T05:29:14.872Z] Executing hook 1/1 from user config
[2026-03-18T05:29:14.877Z] Running script in directory: c:\Users\kyzer\.cursor
[2026-03-18T05:29:14.882Z] Running hook 1 with timeout 60000ms
═══════════════════════════════════════════════════════════════════════════════════════
stop
═══════════════════════════════════════════════════════════════════════════════════════
Command: python C:/Users/kyzer/.cursor/hooks/review-implementation-stop.py (586ms) exit code: 0

INPUT:
{
  "conversation_id": "2019b19c-bce0-4d60-9ffa-9fc017ff7526",
  "generation_id": "97a55a6d-23bb-4649-8c43-56693323fd33",
  "model": "composer-1.5",
  "status": "completed",
  "loop_count": 0,
  "input_tokens": 191551,
  "output_tokens": 1789,
  "cache_read_tokens": 176032,
  "cache_write_tokens": 0,
  "hook_event_name": "stop",
  "cursor_version": "2.7.0-pre.78.patch.0",
  "workspace_roots": [
    "/c:/Users/kyzer/OneDrive/Documents/CS_USM/pixel2024"
  ],
  "user_email": "[email protected]",
  "transcript_path": "c:\\Users\\kyzer\\.cursor\\projects\\c-Users-kyzer-OneDrive-Documents-CS-USM-pixel2024\\agent-transcripts\\2019b19c-bce0-4d60-9ffa-9fc017ff7526\\2019b19c-bce0-4d60-9ffa-9fc017ff7526.jsonl"
}

OUTPUT:
{}
═══════════════════════════════════════════════════════════════════════════════════════
[2026-03-18T05:29:15.464Z] Hook 1 executed successfully and returned valid response
[2026-03-18T05:29:15.466Z] Merged 1 valid response(s) for step stop

3. Manual test results

Minimal payload (as suggested):

echo '{"hook_event_name":"stop","status":"completed","loop_count":0}' | python -u C:/Users/kyzer/.cursor/hooks/review-implementation-stop.py

Result: {} — expected, because the script needs transcript_path and workspace_roots to detect edits. Without them it returns noop(){}.

Full payload (with transcript_path and workspace_roots):

$payload = '{"hook_event_name":"stop","status":"completed","loop_count":0,"transcript_path":"c:\\Users\\kyzer\\.cursor\\projects\\c-Users-kyzer-OneDrive-Documents-CS-USM-pixel2024\\agent-transcripts\\2019b19c-bce0-4d60-9ffa-9fc017ff7526\\2019b19c-bce0-4d60-9ffa-9fc017ff7526.jsonl","workspace_roots":["/c:/Users/kyzer/OneDrive/Documents/CS_USM/pixel2024"]}'

$payload | python -u C:/Users/kyzer/.cursor/hooks/review-implementation-stop.py

Result: full JSON with followup_message printed to the terminal.

So when the script receives the full payload (as Cursor does), it outputs valid JSON with followup_message. The problem appears to be that Cursor’s hook launcher on Windows does not capture that stdout correctly.

Thanks for the detailed follow-up. The manual test with the full payload confirmed everything. The script is working correctly, so the bug is on our side.

The issue is how Cursor captures stdout from hook processes on Windows. Because of an extra buffering layer through PowerShell, the script output might not reach Cursor before the process is treated as finished. This is a known class of problems with hooks on Windows.

I’ve passed this to the team. No specific ETA yet, but your report, especially the manual test, helps with prioritization. Let me know if anything else comes up.

Thanks for confirming and for passing this to the team. I’ll keep the hooks disabled for now and will re-enable them once the fix is out.

Would it be possible to get a heads-up when the issue is fixed? That would make it easier to test and re-enable the hooks.

Thanks again for the follow-up.

Sure, as soon as the fix ships in a release, we’ll update this thread.