From adfc3796639cd0c5d892fb05303b4b2cddfe6e12 Mon Sep 17 00:00:00 2001 From: sfishman Date: Mon, 2 Mar 2026 20:39:50 +0000 Subject: [PATCH 1/3] fix(ralph-loop): stop hook fails when last assistant block is tool_use MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude Code writes each assistant content block (text/tool_use/thinking) as its own JSONL line. The hook's `grep role:assistant | tail -1` would grab whichever block happened to be last — often tool_use — then jq's text filter returned empty string, triggering the 'no text content' path which deletes the state file and exits without blocking. Net effect: the loop silently never fires. In one observed session, 62% of assistant lines were tool_use-only; the hook deleted state on the very first Stop event every time. Fix: slurp all assistant lines with jq -rs, flatten to text blocks only, take the last. Empty result is now non-fatal — no text means no tag, so the loop continues. Also absorbs jq parse errors (control chars in text) via || fallback instead of aborting under set -e. --- plugins/ralph-loop/hooks/stop-hook.sh | 46 ++++++++------------------- 1 file changed, 13 insertions(+), 33 deletions(-) diff --git a/plugins/ralph-loop/hooks/stop-hook.sh b/plugins/ralph-loop/hooks/stop-hook.sh index 955a664..bf3a1ff 100755 --- a/plugins/ralph-loop/hooks/stop-hook.sh +++ b/plugins/ralph-loop/hooks/stop-hook.sh @@ -77,39 +77,19 @@ if ! grep -q '"role":"assistant"' "$TRANSCRIPT_PATH"; then exit 0 fi -# Extract last assistant message with explicit error handling -LAST_LINE=$(grep '"role":"assistant"' "$TRANSCRIPT_PATH" | tail -1) -if [[ -z "$LAST_LINE" ]]; then - echo "⚠️ Ralph loop: Failed to extract last assistant message" >&2 - echo " Ralph loop is stopping." >&2 - rm "$RALPH_STATE_FILE" - exit 0 -fi - -# Parse JSON with error handling -LAST_OUTPUT=$(echo "$LAST_LINE" | jq -r ' - .message.content | - map(select(.type == "text")) | - map(.text) | - join("\n") -' 2>&1) - -# Check if jq succeeded -if [[ $? -ne 0 ]]; then - echo "⚠️ Ralph loop: Failed to parse assistant message JSON" >&2 - echo " Error: $LAST_OUTPUT" >&2 - echo " This may indicate a transcript format issue" >&2 - echo " Ralph loop is stopping." >&2 - rm "$RALPH_STATE_FILE" - exit 0 -fi - -if [[ -z "$LAST_OUTPUT" ]]; then - echo "⚠️ Ralph loop: Assistant message contained no text content" >&2 - echo " Ralph loop is stopping." >&2 - rm "$RALPH_STATE_FILE" - exit 0 -fi +# Extract the most recent assistant text block. +# +# Claude Code writes each content block (text/tool_use/thinking) as its own +# JSONL line, all with role=assistant. `tail -1` alone would often grab a +# tool_use or thinking block, leaving no text to check. Instead, slurp all +# assistant lines, flatten to text blocks only, and take the last one. +# +# `last // ""` yields empty string when no text blocks exist (e.g. a turn +# that is all tool calls). That's fine: empty text means no tag, +# so the loop simply continues. +LAST_OUTPUT=$(grep '"role":"assistant"' "$TRANSCRIPT_PATH" | jq -rs ' + map(.message.content[]? | select(.type == "text") | .text) | last // "" +' 2>/dev/null) || LAST_OUTPUT="" # Check for completion promise (only if set) if [[ "$COMPLETION_PROMISE" != "null" ]] && [[ -n "$COMPLETION_PROMISE" ]]; then From 8644df9ad51fe1dadc5d067e806c3c686efeafff Mon Sep 17 00:00:00 2001 From: sfishman Date: Mon, 2 Mar 2026 22:36:12 +0000 Subject: [PATCH 2/3] fix(ralph-loop): isolate loop state to the session that started it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The state file lives at .claude/ralph-loop.local.md — project-scoped, not session-scoped. The plugin's Stop hook fires in every Claude Code session open in that project directory. So if session A starts a loop, session B's Stop events also find the state file and block, feeding A's prompt into B and consuming A's iteration budget. This was masked by the transcript-parsing bug fixed in the previous commit: that bug deleted the state file on the first Stop in any session, so neither session looped. Fixing it exposed the leak. Fix: setup writes CLAUDE_CODE_SESSION_ID into the frontmatter; the hook compares against .session_id from its stdin JSON and exits silently on mismatch. State files without session_id (written by old setup scripts) fall through to preserve existing behavior. --- plugins/ralph-loop/hooks/stop-hook.sh | 10 ++++++++++ plugins/ralph-loop/scripts/setup-ralph-loop.sh | 1 + 2 files changed, 11 insertions(+) diff --git a/plugins/ralph-loop/hooks/stop-hook.sh b/plugins/ralph-loop/hooks/stop-hook.sh index bf3a1ff..ef83af4 100755 --- a/plugins/ralph-loop/hooks/stop-hook.sh +++ b/plugins/ralph-loop/hooks/stop-hook.sh @@ -24,6 +24,16 @@ MAX_ITERATIONS=$(echo "$FRONTMATTER" | grep '^max_iterations:' | sed 's/max_iter # Extract completion_promise and strip surrounding quotes if present COMPLETION_PROMISE=$(echo "$FRONTMATTER" | grep '^completion_promise:' | sed 's/completion_promise: *//' | sed 's/^"\(.*\)"$/\1/') +# Session isolation: the state file is project-scoped, but the Stop hook +# fires in every Claude Code session in that project. If another session +# started the loop, this session must not block (or touch the state file). +# Legacy state files without session_id fall through (preserves old behavior). +STATE_SESSION=$(echo "$FRONTMATTER" | grep '^session_id:' | sed 's/session_id: *//' || true) +HOOK_SESSION=$(echo "$HOOK_INPUT" | jq -r '.session_id // ""') +if [[ -n "$STATE_SESSION" ]] && [[ "$STATE_SESSION" != "$HOOK_SESSION" ]]; then + exit 0 +fi + # Validate numeric fields before arithmetic operations if [[ ! "$ITERATION" =~ ^[0-9]+$ ]]; then echo "⚠️ Ralph loop: State file corrupted" >&2 diff --git a/plugins/ralph-loop/scripts/setup-ralph-loop.sh b/plugins/ralph-loop/scripts/setup-ralph-loop.sh index 3d41db4..c0897d4 100755 --- a/plugins/ralph-loop/scripts/setup-ralph-loop.sh +++ b/plugins/ralph-loop/scripts/setup-ralph-loop.sh @@ -141,6 +141,7 @@ cat > .claude/ralph-loop.local.md < Date: Wed, 4 Mar 2026 00:25:42 +0000 Subject: [PATCH 3/3] address review: bound grep to tail -n 100; restore explicit error paths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Split pipeline into two steps (extract lines, then parse) mirroring the original structure. - set +e around the jq call so failures reach the $? check instead of aborting under set -e. - The "no text content" branch remains removed (that was the original bug — all-tool-use turns now correctly yield empty text and the loop continues). --- plugins/ralph-loop/hooks/stop-hook.sh | 34 +++++++++++++++++++++++---- 1 file changed, 29 insertions(+), 5 deletions(-) diff --git a/plugins/ralph-loop/hooks/stop-hook.sh b/plugins/ralph-loop/hooks/stop-hook.sh index ef83af4..7edf13b 100755 --- a/plugins/ralph-loop/hooks/stop-hook.sh +++ b/plugins/ralph-loop/hooks/stop-hook.sh @@ -90,16 +90,40 @@ fi # Extract the most recent assistant text block. # # Claude Code writes each content block (text/tool_use/thinking) as its own -# JSONL line, all with role=assistant. `tail -1` alone would often grab a -# tool_use or thinking block, leaving no text to check. Instead, slurp all -# assistant lines, flatten to text blocks only, and take the last one. +# JSONL line, all with role=assistant. So slurp the last N assistant lines, +# flatten to text blocks only, and take the last one. # +# Capped at the last 100 assistant lines to keep jq's slurp input bounded +# for long-running sessions. +LAST_LINES=$(grep '"role":"assistant"' "$TRANSCRIPT_PATH" | tail -n 100) +if [[ -z "$LAST_LINES" ]]; then + echo "⚠️ Ralph loop: Failed to extract assistant messages" >&2 + echo " Ralph loop is stopping." >&2 + rm "$RALPH_STATE_FILE" + exit 0 +fi + +# Parse the recent lines and pull out the final text block. # `last // ""` yields empty string when no text blocks exist (e.g. a turn # that is all tool calls). That's fine: empty text means no tag, # so the loop simply continues. -LAST_OUTPUT=$(grep '"role":"assistant"' "$TRANSCRIPT_PATH" | jq -rs ' +# (Briefly disable errexit so a jq failure can be caught by the $? check.) +set +e +LAST_OUTPUT=$(echo "$LAST_LINES" | jq -rs ' map(.message.content[]? | select(.type == "text") | .text) | last // "" -' 2>/dev/null) || LAST_OUTPUT="" +' 2>&1) +JQ_EXIT=$? +set -e + +# Check if jq succeeded +if [[ $JQ_EXIT -ne 0 ]]; then + echo "⚠️ Ralph loop: Failed to parse assistant message JSON" >&2 + echo " Error: $LAST_OUTPUT" >&2 + echo " This may indicate a transcript format issue." >&2 + echo " Ralph loop is stopping." >&2 + rm "$RALPH_STATE_FILE" + exit 0 +fi # Check for completion promise (only if set) if [[ "$COMPLETION_PROMISE" != "null" ]] && [[ -n "$COMPLETION_PROMISE" ]]; then