[BUG] ralph-loop never loops on 2.1.x: Stop hook reads the transcript before the current turn's assistant message is written, then deletes its own state file
Preflight Checklist
- [x] I have searched existing issues and this hasn't been reported yet
- [x] This is a single bug report (please file separate reports for different bugs)
- [x] I am using the latest version of Claude Code (2.1.220)
Plugin
ralph-loop 1.0.0 (claude-plugins-official marketplace). Also reproduced under the Desktop app's bundled runtime 2.1.219.
Summary
/ralph-loop never iterates. The first Stop hook invocation deletes the loop's own state file and allows the exit, so the session ends after a single turn. Nothing is surfaced in the TUI — the observable symptom is "nothing happened".
hooks/stop-hook.sh derives the last assistant message by parsing the transcript JSONL. At the moment the Stop hook fires, the transcript does not yet contain the current turn's assistant message. On iteration 1 the file therefore contains zero "role":"assistant" lines, which the script treats as fatal:
if ! grep -q '"role":"assistant"' "$TRANSCRIPT_PATH"; then
echo "⚠️ Ralph loop: No assistant messages found in transcript" >&2
...
rm "$RALPH_STATE_FILE"
exit 0
fi
Measurement — what the harness actually presents at Stop time
A pass-through Stop hook (observes only; blocks twice so that several rounds are visible), in a scratch directory, on 2.1.220:
Stop #1 | assistant lines in transcript = 0 | .last_assistant_message = "ITERMARK"
Stop #2 | assistant lines in transcript = 1 | .last_assistant_message = "ITERMARK"
Stop #3 | assistant lines in transcript = 2 | .last_assistant_message = "ITERMARK"
Two consequences:
- The transcript lags by exactly one turn. On iteration 1 there is nothing to find; from iteration 2 onward the script reads the previous turn's text, so promise detection is also off by one.
- The hook input already carries the text as
last_assistant_message. The plugin never reads it.
Deterministic reproduction (no API calls)
PLUGIN=~/.claude/plugins/marketplaces/claude-plugins-official/plugins/ralph-loop
cd "$(mktemp -d)"; mkdir .claude
printf -- '---\niteration: 1\nsession_id:\nmax_iterations: 20\ncompletion_promise: null\n---\n\ndo the task\n' > .claude/ralph-loop.local.md
printf '{"type":"user","message":{"role":"user","content":"hi"}}\n' > t.jsonl
echo "{\"session_id\":\"s1\",\"transcript_path\":\"$PWD/t.jsonl\"}" | bash "$PLUGIN/hooks/stop-hook.sh"
ls -a .claude/
Observed:
⚠️ Ralph loop: No assistant messages found in transcript
Transcript: /var/folders/.../t.jsonl
This is unusual and may indicate a transcript format issue
Ralph loop is stopping.
…and .claude/ralph-loop.local.md is gone.
End to end with the plugin enabled and max_iterations: 3: claude -p reports num_turns = 1.
Suggested fix
Read the harness-provided field; fall back to the transcript only for builds that do not send it; and stop treating "no assistant text this turn" as fatal — the script's own comments already acknowledge that a turn ending in tool calls legitimately has no text block.
LAST_OUTPUT=$(echo "$HOOK_INPUT" | jq -r '.last_assistant_message // ""')
if [[ -z "$LAST_OUTPUT" ]]; then
# Fallback for builds that do not send the field. An empty result here is NOT an
# error: it just means "no <promise> this turn", so the loop simply continues.
TRANSCRIPT_PATH=$(echo "$HOOK_INPUT" | jq -r '.transcript_path // ""')
if [[ -n "$TRANSCRIPT_PATH" ]] && [[ -f "$TRANSCRIPT_PATH" ]]; then
LAST_LINES=$(grep '"role":"assistant"' "$TRANSCRIPT_PATH" | tail -n 100 || true)
if [[ -n "$LAST_LINES" ]]; then
set +e
PARSED=$(echo "$LAST_LINES" | jq -rs 'map(.message.content[]? | select(.type == "text") | .text) | last // ""' 2>/dev/null)
JQ_EXIT=$?
set -e
if [[ $JQ_EXIT -eq 0 ]]; then LAST_OUTPUT="$PARSED"; fi
fi
fi
fi
Verified two ways:
- In isolation, on a copy of the shipped script with only this hunk changed, fed a transcript with zero assistant lines plus
last_assistant_messagepresent: upstream deletes the state file, the patched copy keeps it and advances toiteration: 2. - End to end, with the patch applied in a local build:
max_iterations: 3yieldsnum_turns = 3inclaude -p, in the interactive TUI, and under the Desktop app's bundled 2.1.219 runtime. (That build also carries fixes for the four downstream defects mentioned below; none of them is reachable in this scenario — no completion promise is set, the cap is a plain decimal, and the counter is written with a space.)
Related
- #68665 (closed as not planned) reports a different trigger for a failure in the same transcript-slurp code path (a raw control character making
jq -rsfail). Sourcing the text fromlast_assistant_messageremoves that failure class as well. - Once this is fixed, four further defects in the same file become reachable for the first time — they are currently masked because the loop never runs. Filed separately, per the single-bug-per-report policy.
3 Comments
Hi @robinhou729, I’ve implemented and tested a fix for this issue here: sylvesterkaczmarek/claude-code@7d90842.
The stop hook now reads
last_assistant_messagefrom the current Stop payload and falls back to the transcript only when that field is absent, preserving compatibility with older payloads without reusing a stale previous-turn message.Verified with a deterministic harness covering: a lagging transcript with current assistant output, current-turn completion-promise detection, legacy transcript fallback, an empty current message with a stale promise in the transcript, and current output with no transcript path.
Partially reproduced on Linux with Claude Code 2.1.233 (and also checked 2.1.220) with ralph-loop 1.0.0 from the official marketplace.
What reproduces: the deterministic reproduction from the report. Fed a transcript that does not yet contain the current turn's assistant message, the plugin's Stop hook reports "No assistant messages found in transcript", deletes its own loop state file, and allows the session to exit — silently ending the loop on the spot.
What did not reproduce here: the transcript lag itself. On Linux, with an observing Stop hook on both 2.1.220 and 2.1.233, the transcript already contained the current turn's assistant message when the Stop hook fired, and an end-to-end run of the plugin with a 3-iteration cap looped correctly all 3 turns on both versions. Your measurements (0/1/2 assistant lines across consecutive stops) were taken on macOS, so the lag appears to be timing- or platform-dependent rather than universal.
That said, the documentation is explicit that the transcript file is written asynchronously and may not yet include the current turn's messages when a hook fires, and that Stop hooks needing the final assistant text should read the
last_assistant_messageinput field instead. So the plugin's approach of parsing the transcript — and treating an empty result as fatal and deleting its own state — is incorrect per the documented contract, and your suggested fix (preferlast_assistant_message, with a non-fatal transcript fallback) is exactly the documented pattern.This is a bug in the ralph-loop plugin rather than in the Claude Code CLI; flagging it for the plugin maintainers.
🤖 Generated with Claude Code
For your information: the label reproduced on this issue does not prevent it from becoming stale and eventually auto-closed. See https://github.com/anthropics/claude-code/issues/87647 for details, and consider upvoting that for visibility.