diff --git a/README.md b/README.md index c38d3db..3b330b7 100644 --- a/README.md +++ b/README.md @@ -2,9 +2,7 @@ Agentic looping for Cursor IDE. Keeps the agent working on a task until it's done (or hits a safety limit). -This is a quick port of the "Not-quite-Ralph" loop from the [ralph-wiggum plugin](https://github.com/anthropics/claude-code/tree/main/plugins/ralph-wiggum) for Claude Code. Uses `osascript` on macOS to work around Cursor's 5-iteration stop hook limit. - -> **macOS only.** Linux/Windows PRs welcome. +This is a quick port of the "Not-quite-Ralph" loop from the [ralph-wiggum plugin](https://github.com/anthropics/claude-code/tree/main/plugins/ralph-wiggum) for Claude Code. ## What's a Ralph Loop? @@ -16,7 +14,7 @@ This implementation isn't the "true" Ralph loop (which uses more sophisticated s 1. Clone this repo somewhere: ```bash - git clone https://github.com/youruser/cursor-ralph.git ~/.cursor-ralph + git clone https://github.com/hexsprite/cursor-ralph.git ~/.cursor-ralph ``` 2. Symlink the command into your Cursor commands directory: @@ -25,20 +23,49 @@ This implementation isn't the "true" Ralph loop (which uses more sophisticated s ln -s ~/.cursor-ralph/commands/ralph.md ~/.cursor/commands/ralph.md ``` -3. Add the stop hook to your Cursor settings (`~/.cursor/settings.json`): +3. Register the stop hook in **`.cursor/hooks.json`** (project root) or your user hooks config. **You must set `loop_limit`: null** so Cursor does not stop the loop after 5 iterations: + ```json { + "version": 1, "hooks": { "stop": [ { - "command": "~/.cursor-ralph/hooks/ralph-loop-stop.sh" + "command": "~/.cursor-ralph/hooks/ralph-loop-stop.sh", + "loop_limit": null } ] } } ``` -4. Grant Accessibility permissions to Cursor (System Settings → Privacy & Security → Accessibility). Required for the `osascript` workaround. + See [`hooks/hooks.json.example`](hooks/hooks.json.example) for a copy-paste template. + +4. Make hook scripts executable: + ```bash + chmod +x ~/.cursor-ralph/hooks/ralph-loop-stop.sh + ``` + +### Linux notes + +- Works on Linux with **`loop_limit`: null** — no macOS `osascript` required. +- Requires **`bash`**, **`jq`**, and **`grep`** on PATH. +- Optional legacy fallback: [`hooks/continue-linux.sh`](hooks/continue-linux.sh) simulates typing `/ralph-loop --continue ` if you cannot set `loop_limit: null`. Install **`xdotool`** (X11) or **`ydotool`** / **`wtype`** (Wayland). + + +### Windows notes + +- Works on Windows with **`loop_limit`: null** — no macOS `osascript` required. +- Hook scripts are **bash**; use **Git Bash** (bundled with Git for Windows) or ensure bash is on PATH when Cursor runs hooks. +- Use forward slashes or escaped backslashes in `hooks.json` command paths, e.g. `"C:/Users/you/.cursor-ralph/hooks/ralph-loop-stop.sh"`. +- Requires **`jq`** in Git Bash (`winget install jqlang.jq` or download from [jqlang.org](https://jqlang.org/)). +- Optional legacy fallback: [`hooks/continue-windows.ps1`](hooks/continue-windows.ps1) uses PowerShell `SendKeys` when `loop_limit: null` cannot be set. Run via Git Bash: `powershell.exe -File hooks/continue-windows.ps1 "/ralph-loop --continue "`. +- Known Cursor quirk: stop-hook JSON may show as `{}` in Hooks Execution Log on Windows even when valid ([forum thread](https://forum.cursor.com/t/stop-hook-followup-message-not-captured-on-windows-execution-log-shows-despite-valid-json-on-stdout/)); verify behavior in chat, not only the log. + +### macOS notes + +- Grant Accessibility permissions to Cursor if you use the optional keyboard fallback (System Settings → Privacy & Security → Accessibility). +- With `loop_limit: null`, keyboard simulation is not needed. ## Usage @@ -83,15 +110,14 @@ This implementation isn't the "true" Ralph loop (which uses more sophisticated s │ Stop hook runs after agent response │ │ ├─ Check for completion promise → Done? Clean up & exit │ │ ├─ Check max iterations → Hit limit? Clean up & exit │ -│ ├─ Session < 5? → Return followup_message to continue │ -│ └─ Session = 5? → osascript types new /ralph-loop command │ +│ └─ Return followup_message to continue (loop_limit: null) │ └─────────────────────────────────────────────────────────────┘ │ ▼ (loop continues) ``` -The key trick: Cursor limits `followup_message` chains to 5 iterations. When we hit that limit, the stop hook spawns an `osascript` process that waits 1.5 seconds then simulates typing `/ralph-loop --continue ` into Cursor. This starts a "new" user message, resetting Cursor's internal counter while preserving our loop state. +Cursor limits `followup_message` chains to **5 iterations by default**. Setting **`loop_limit`: null** on the stop hook removes that cap ([Cursor hooks docs](https://cursor.com/docs/hooks.md)). The previous macOS-only `osascript` workaround is no longer required when this is configured. ## State File @@ -103,7 +129,6 @@ Loop state is stored in `/tmp/cursor-ralph-loop-.json`: "max_iterations": 20, "completion_promise": "COMPLETE", "iterations": 7, - "session_iterations": 2, "stop": false, "last_output": "Added 3 test files, coverage now at 74%" } @@ -111,22 +136,21 @@ Loop state is stored in `/tmp/cursor-ralph-loop-.json`: ## Requirements -- **macOS** (uses `osascript` for keyboard simulation) -- **jq** (`brew install jq`) -- **Cursor** with Accessibility permissions -- Cursor window must be focused when the session limit is hit +- **Cursor** with project or user **`hooks.json`** +- **`loop_limit`: null** on the Ralph stop hook entry +- **`bash`**, **`jq`**, **`grep`** +- On Windows: **Git Bash** (or another environment where bash hooks run) ## Limitations -- macOS only — `osascript` doesn't exist on Linux/Windows -- Requires Cursor to be focused when session limit hits -- If `osascript` fails, loop stops at iteration 5 (you can manually continue) -- The 1.5s delay between sessions is a bit janky but necessary for reliability +- Without `loop_limit: null`, the loop still stops after 5 iterations on every OS. +- Keyboard fallback scripts require the Cursor window to be focused and OS-specific tooling (optional; not needed with `loop_limit: null`). ## Credits - Original Ralph Wiggum technique by [Geoffrey Huntley](https://ghuntley.com/ralph/) - Based on the [ralph-wiggum plugin](https://github.com/anthropics/claude-code/tree/main/plugins/ralph-wiggum) for Claude Code +- Cross-platform hook fix inspired by [ericzakariasson/ralph-loop-plugin](https://github.com/ericzakariasson/ralph-loop-plugin) - This port by Jordan Baker ## License diff --git a/commands/ralph.md b/commands/ralph.md index c0deae7..53fcc87 100644 --- a/commands/ralph.md +++ b/commands/ralph.md @@ -1,6 +1,6 @@ # Ralph Wiggum Loop Command -Iterative refinement loop that keeps working until complete. The **stop hook** controls iteration and uses `osascript` to bypass Cursor's 5-iteration limit. +Iterative refinement loop that keeps working until complete. The **stop hook** controls iteration via Cursor's `followup_message` API. ## Usage @@ -12,7 +12,7 @@ Iterative refinement loop that keeps working until complete. The **stop hook** c - **``** (required) — Task description - **`--max-iterations `** — Safety limit (default: 20) - **`--completion-promise ""`** — Completion signal (default: "COMPLETE") -- **`--continue `** — Resume from existing state (used by osascript auto-continuation) +- **`--continue `** — Resume from existing state (legacy fallback when keyboard continuation is used) **If no prompt provided**: Print error with usage and stop. @@ -29,7 +29,6 @@ Iterative refinement loop that keeps working until complete. The **stop hook** c "max_iterations": 20, "completion_promise": "COMPLETE", "iterations": 0, - "session_iterations": 0, "stop": false, "last_output": "" } @@ -41,7 +40,6 @@ Iterative refinement loop that keeps working until complete. The **stop hook** c 1. **Read existing state file** using provided trace_id 2. **Resume working** on the saved prompt -3. Session iteration counter is already reset by the stop hook ## During Work @@ -57,38 +55,16 @@ jq --arg out "summary of what you did" '.last_output = $out' "$STATE_FILE" > tmp ## How the Loop Works -The stop hook (`scripts/ralph-loop-stop.sh`) runs after **every** agent response: +The stop hook (`hooks/ralph-loop-stop.sh`) runs after **every** agent response: -1. Increments iteration counter and session counter +1. Increments iteration counter 2. Checks if completion promise found in `last_output` 3. Checks if max iterations reached -4. If session hits 5 (Cursor's limit) but total < max: - - Resets session counter - - Spawns `osascript` to type `/ralph-loop --continue ` - - New "user message" resets Cursor's internal limit -5. If not at session limit → returns `followup_message` to continue -6. If done → cleans up state file and exits +4. Otherwise returns `followup_message` to continue the loop -**User clicking Stop** sets `stop: true` in state file — hook detects this and exits. - -## Example - -``` -User: /ralph-loop "Add tests until 80% coverage" --max-iterations 15 - -Iterations 1-4: Normal followup_message continuation -Iteration 5: Session limit hit - → osascript types: /ralph-loop --continue abc123 - → New session starts +**Required hook config:** set `"loop_limit": null` on the stop hook entry in `hooks.json` (see `hooks/hooks.json.example`). Without this, Cursor caps automatic follow-ups at 5 iterations on all platforms. -Iterations 6-9: Normal continuation -Iteration 10: Session limit hit again - → osascript continues... - -Iteration 12: Coverage reaches 82% - → Agent outputs: COMPLETE - → Hook cleans up, loop ends -``` +**User clicking Stop** sets `stop: true` in state file — hook detects this and exits. ## Key Rules @@ -98,11 +74,12 @@ Iteration 12: Coverage reaches 82% 4. **Don't loop yourself** — The hook handles iteration automatically 5. **For `--continue`** — Read state from provided trace_id, not CURSOR_TRACE_ID -## macOS Requirement +## Linux / Windows + +Works on Linux and Windows when: -The osascript auto-continuation requires: -- macOS (osascript is Mac-only) -- Accessibility permissions for System Events -- Cursor window must be focused when session limit hits +- The stop hook is registered with `"loop_limit": null` (primary fix) +- **`bash`**, **`jq`**, and **`grep`** are on PATH (Git Bash on Windows) +- Hook scripts are executable (`chmod +x hooks/*.sh`) -If osascript fails, the loop stops at iteration 5 and you can manually continue. +Optional keyboard fallback scripts (`continue-linux.sh`, `continue-windows.ps1`) exist for legacy installs that cannot set `loop_limit: null`. Prefer the hooks.json fix. diff --git a/hooks/continue-linux.sh b/hooks/continue-linux.sh new file mode 100755 index 0000000..0bebf6a --- /dev/null +++ b/hooks/continue-linux.sh @@ -0,0 +1,53 @@ +#!/usr/bin/env bash +# Fallback continuation for Linux when stop-hook followup_message hits Cursor's +# default loop_limit (5). Prefer setting "loop_limit": null in hooks.json instead. +# +# Usage: continue-linux.sh "/ralph-loop --continue " +# +# Requires one of: xdotool (X11), ydotool (Wayland/uinput), or wtype (Wayland). + +set -euo pipefail + +COMMAND_TEXT="${1:-}" +if [[ -z "$COMMAND_TEXT" ]]; then + echo "continue-linux.sh: missing command text" >&2 + exit 1 +fi + +sleep 1.5 + +type_text() { + local text="$1" + if command -v xdotool >/dev/null 2>&1; then + xdotool type --delay 12 -- "$text" + xdotool key Return + return 0 + fi + if command -v ydotool >/dev/null 2>&1; then + ydotool type --delay 12 -- "$text" + ydotool key enter + return 0 + fi + if command -v wtype >/dev/null 2>&1; then + wtype -d 12 -- "$text" + wtype -k enter + return 0 + fi + return 1 +} + +focus_cursor() { + if command -v xdotool >/dev/null 2>&1; then + xdotool search --onlyvisible --class cursor windowactivate 2>/dev/null \ + || xdotool search --onlyvisible --class Cursor windowactivate 2>/dev/null \ + || true + fi +} + +focus_cursor +sleep 0.3 + +if ! type_text "$COMMAND_TEXT"; then + echo "continue-linux.sh: install xdotool (X11), ydotool, or wtype (Wayland), or set loop_limit: null in hooks.json" >&2 + exit 1 +fi diff --git a/hooks/continue-windows.ps1 b/hooks/continue-windows.ps1 new file mode 100644 index 0000000..0e6d5ab --- /dev/null +++ b/hooks/continue-windows.ps1 @@ -0,0 +1,44 @@ +#Requires -Version 5.1 +<# +.SYNOPSIS + Fallback continuation for Windows when stop-hook followup_message hits Cursor's + default loop_limit (5). Prefer setting "loop_limit": null in hooks.json instead. + +.PARAMETER CommandText + Full slash command to type, e.g. "/ralph-loop --continue abc123" +#> +param( + [Parameter(Mandatory = $true, Position = 0)] + [string]$CommandText +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +Start-Sleep -Seconds 1.5 + +Add-Type @" +using System; +using System.Runtime.InteropServices; +public static class RalphWin32 { + [DllImport("user32.dll")] + public static extern bool SetForegroundWindow(IntPtr hWnd); + [DllImport("user32.dll", CharSet = CharSet.Unicode)] + public static extern IntPtr FindWindow(string lpClassName, string lpWindowName); +} +"@ + +$cursor = [RalphWin32]::FindWindow([string]::Empty, 'Cursor') +if ($cursor -ne [IntPtr]::Zero) { + [void][RalphWin32]::SetForegroundWindow($cursor) + Start-Sleep -Milliseconds 300 +} + +$wshell = New-Object -ComObject WScript.Shell +if (-not $wshell.AppActivate('Cursor')) { + Write-Error 'Could not focus Cursor. Bring Cursor to the foreground or set loop_limit: null in hooks.json.' +} + +Start-Sleep -Milliseconds 300 +$wshell.SendKeys($CommandText) +$wshell.SendKeys('{ENTER}') diff --git a/hooks/hooks.json.example b/hooks/hooks.json.example new file mode 100644 index 0000000..973d9aa --- /dev/null +++ b/hooks/hooks.json.example @@ -0,0 +1,11 @@ +{ + "version": 1, + "hooks": { + "stop": [ + { + "command": "./hooks/ralph-loop-stop.sh", + "loop_limit": null + } + ] + } +} diff --git a/hooks/ralph-loop-stop.sh b/hooks/ralph-loop-stop.sh old mode 100644 new mode 100755 index 256fe31..75853c1 --- a/hooks/ralph-loop-stop.sh +++ b/hooks/ralph-loop-stop.sh @@ -1,86 +1,61 @@ -#!/bin/bash -# Ralph loop controller - runs after every agent response -# If in a ralph loop: increment iteration, check completion, optionally continue -# Uses osascript to bypass Cursor's 5-iteration limit on followup_message +#!/usr/bin/env bash +# Ralph loop controller - runs after every agent response. +# When the loop is active, increment counters, check completion, and either stop +# or return followup_message for the next iteration. +# +# Cursor stop hook API (stdin JSON): +# { "status": "completed"|"aborted"|"error", "loop_count": N, ... } +# stdout: { "followup_message": "" } to continue, or agent_message / empty to stop +# +# REQUIRED: Register this hook with "loop_limit": null in hooks.json so Cursor +# does not cap followup_message chains at 5 iterations. See hooks/hooks.json.example. set -euo pipefail -CURSOR_ITERATION_LIMIT=5 # Cursor's built-in limit on followup_message chaining +HOOK_INPUT=$(cat) TRACE_ID="${CURSOR_TRACE_ID:-}" -# If no trace ID, nothing to do -if [ -z "$TRACE_ID" ]; then +if [[ -z "$TRACE_ID" ]]; then exit 0 fi STATE_FILE="/tmp/cursor-ralph-loop-${TRACE_ID}.json" -# If no state file, not in a ralph loop -if [ ! -f "$STATE_FILE" ]; then +if [[ ! -f "$STATE_FILE" ]]; then exit 0 fi -# Read state STATE=$(cat "$STATE_FILE") ITERATIONS=$(echo "$STATE" | jq -r '.iterations // 0') -SESSION_ITERATIONS=$(echo "$STATE" | jq -r '.session_iterations // 0') MAX_ITERATIONS=$(echo "$STATE" | jq -r '.max_iterations // 20') COMPLETION_PROMISE=$(echo "$STATE" | jq -r '.completion_promise // "COMPLETE"') PROMPT=$(echo "$STATE" | jq -r '.prompt // ""') STOP=$(echo "$STATE" | jq -r '.stop // false') LAST_OUTPUT=$(echo "$STATE" | jq -r '.last_output // ""') -# If user clicked stop, exit silently (don't continue loop) -if [ "$STOP" = "true" ]; then +if [[ "$STOP" = "true" ]]; then rm -f "$STATE_FILE" exit 0 fi -# Increment both counters NEW_ITERATIONS=$((ITERATIONS + 1)) -NEW_SESSION_ITERATIONS=$((SESSION_ITERATIONS + 1)) -jq --argjson iter "$NEW_ITERATIONS" --argjson sess "$NEW_SESSION_ITERATIONS" \ - '.iterations = $iter | .session_iterations = $sess' "$STATE_FILE" > "${STATE_FILE}.tmp" \ +jq --argjson iter "$NEW_ITERATIONS" '.iterations = $iter' "$STATE_FILE" > "${STATE_FILE}.tmp" \ && mv "${STATE_FILE}.tmp" "$STATE_FILE" -# Check if max iterations reached -if [ "$NEW_ITERATIONS" -ge "$MAX_ITERATIONS" ]; then +if [[ "$NEW_ITERATIONS" -ge "$MAX_ITERATIONS" ]]; then rm -f "$STATE_FILE" - echo "{\"agent_message\": \"Max iterations ($MAX_ITERATIONS) reached. Stopping ralph loop.\"}" + jq -n --arg msg "Max iterations ($MAX_ITERATIONS) reached. Stopping ralph loop." \ + '{agent_message: $msg}' exit 0 fi -# Check if completion promise was found in last output -if [ -n "$LAST_OUTPUT" ] && echo "$LAST_OUTPUT" | grep -qF "$COMPLETION_PROMISE"; then +if [[ -n "$LAST_OUTPUT" ]] && echo "$LAST_OUTPUT" | grep -qF "$COMPLETION_PROMISE"; then rm -f "$STATE_FILE" - echo "{\"agent_message\": \"Task completed after $NEW_ITERATIONS iterations.\"}" + jq -n --arg msg "Task completed after $NEW_ITERATIONS iterations." '{agent_message: $msg}' exit 0 fi -# Check if we're hitting Cursor's session limit -if [ "$NEW_SESSION_ITERATIONS" -ge "$CURSOR_ITERATION_LIMIT" ]; then - # Reset session counter for next session - jq '.session_iterations = 0' "$STATE_FILE" > "${STATE_FILE}.tmp" && mv "${STATE_FILE}.tmp" "$STATE_FILE" - - # Spawn osascript to continue the loop in a new session - # This bypasses Cursor's 5-iteration limit by starting a "new" user message - osascript -e " - delay 1.5 - tell application \"Cursor\" to activate - delay 0.3 - tell application \"System Events\" - keystroke \"/ralph-loop --continue ${TRACE_ID}\" - keystroke return - end tell - " &>/dev/null & - - # Don't return followup_message - let osascript handle continuation - echo "{\"agent_message\": \"Session limit reached. Continuing automatically... (iteration $NEW_ITERATIONS of $MAX_ITERATIONS)\"}" - exit 0 -fi - -# Continue the loop normally - return followup_message (use jq for proper JSON escaping) jq -n \ --arg prompt "$PROMPT" \ --arg iter "$NEW_ITERATIONS" \