Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 33 additions & 19 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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?

Expand All @@ -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:
Expand All @@ -25,20 +23,39 @@ 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 <trace_id>` if you cannot set `loop_limit: null`. Install **`xdotool`** (X11) or **`ydotool`** / **`wtype`** (Wayland).

### 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

Expand Down Expand Up @@ -83,15 +100,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 <trace_id>` 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

Expand All @@ -103,30 +119,28 @@ Loop state is stored in `/tmp/cursor-ralph-loop-<trace_id>.json`:
"max_iterations": 20,
"completion_promise": "COMPLETE",
"iterations": 7,
"session_iterations": 2,
"stop": false,
"last_output": "Added 3 test files, coverage now at 74%"
}
```

## 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
Expand Down
51 changes: 14 additions & 37 deletions commands/ralph.md
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -12,7 +12,7 @@ Iterative refinement loop that keeps working until complete. The **stop hook** c
- **`<prompt>`** (required) — Task description
- **`--max-iterations <n>`** — Safety limit (default: 20)
- **`--completion-promise "<text>"`** — Completion signal (default: "COMPLETE")
- **`--continue <trace_id>`** — Resume from existing state (used by osascript auto-continuation)
- **`--continue <trace_id>`** — Resume from existing state (legacy fallback when keyboard continuation is used)

**If no prompt provided**: Print error with usage and stop.

Expand All @@ -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": ""
}
Expand All @@ -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

Expand All @@ -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 <trace_id>`
- 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

Expand All @@ -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.
53 changes: 53 additions & 0 deletions hooks/continue-linux.sh
Original file line number Diff line number Diff line change
@@ -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 <trace_id>"
#
# 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
11 changes: 11 additions & 0 deletions hooks/hooks.json.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"version": 1,
"hooks": {
"stop": [
{
"command": "./hooks/ralph-loop-stop.sh",
"loop_limit": null
}
]
}
}
67 changes: 21 additions & 46 deletions hooks/ralph-loop-stop.sh
100644 → 100755
Original file line number Diff line number Diff line change
@@ -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": "<text>" } 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" \
Expand Down