[BUG] ralph-loop: Stop hook fails to detect a correct <promise> when the transcript window contains a control character
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
What's Wrong?
Plugin
ralph-loop 1.0.0 (claude-plugins-official marketplace)
Summary
A ralph loop can fail to terminate even when the worker emits the exact, correct<promise>…</promise> completion token. The Stop hook keeps re-injecting the
prompt. I hit this on a finished work package whose PR was open and CI green — it
looped 6 times before I cancelled it manually.
Root cause
hooks/stop-hook.sh detects completion by slurping the last 100 assistant
transcript lines through jq and reading the final text block:
LAST_LINES=$(grep '"role":"assistant"' "$TRANSCRIPT_PATH" | tail -n 100)
LAST_OUTPUT=$(echo "$LAST_LINES" | jq -rs 'map(.message.content[]? | select(.type=="text") | .text) | last // ""')
The jq -rs slurp parses the whole window as one stream, so a single
transcript line containing a raw control character (U+0000–U+001F) — e.g. an
embedded newline from a large tool-output block or a multi-line heredoc command —
makes the entire slurp fail:
jq: parse error: Invalid string: control characters from U+0000 through U+001F must be escaped
When that happens the extracted promise is empty, never matches$COMPLETION_PROMISE, and the loop continues. (Ironically, iterating while
debugging the loop adds more such tool output, keeping the window unparseable.)
Reproduce
- Start a ralph loop with a completion promise.
- In a turn, emit a large tool output / multi-line command whose transcript
record contains an unescaped control char.
- Emit the correct
<promise>TOKEN</promise>. Observe the loop re-inject the
prompt instead of stopping.
You can confirm the parse failure directly:grep '"role":"assistant"' <transcript>.jsonl | tail -100 | jq -rs '.'
Suggested fix
- Parse the window line-by-line and skip unparseable lines instead of slurping,
e.g. jq -R 'fromjson? // empty' per line, then take the last text block — so
one bad line can't nuke detection.
- Verify the
JQ_EXIT != 0graceful-stop branch actually triggers on a slurp
parse error (in the observed case the loop continued rather than stopping, so
either jq returned empty-with-exit-0 or that branch isn't reached).
Workaround
Keep the promise-emitting turn text-only (no large tool output), and use/ralph-loop:cancel-ralph if a loop won't stop despite genuine completion.
What Should Happen?
Describe the expected behavior
When the worker's final message contains <promise>$COMPLETION_PROMISE</promise>
matching the configured promise, the Stop hook should detect it and end the loop
(remove the state file, allow exit) — regardless of any control characters
elsewhere in the transcript window. Promise detection should be robust to
individual unparseable/garbled transcript lines (skip them) rather than failing
closed and silently continuing. And if jq genuinely cannot parse the window, the
documented JQ_EXIT != 0 fallback should stop the loop rather than re-injecting
the prompt (in the observed case it continued instead).
Actual behavior
The loop ignored the correct promise and re-injected the prompt for 6 consecutive
iterations on a work package that was complete (PR open, CI green). It only
stopped via manual /ralph-loop:cancel-ralph.
Error Messages/Logs
Steps to Reproduce
- Start a ralph loop with a completion promise, e.g.
/ralph-loop:ralph-loop <task> --completion-promise MY-TOKEN --max-iterations 15.
- During the loop, run a turn that emits large or multi-line tool output — e.g.
gh run watch, a multi-line git commit/gh pr create heredoc, or any
command whose captured output contains an unescaped control character
(U+0000–U+001F). This gets written into the session .jsonl transcript.
- Complete the task and emit the exact promise
<promise>MY-TOKEN</promise>as
your final message.
- Observe: the Stop hook re-injects the same prompt instead of ending the loop,
even though the token exactly matches --completion-promise.
Direct confirmation of the underlying parse failure (same slurp the hook uses):
grep '"role":"assistant"' <session>.jsonl | tail -100 | jq -rs '.'
# => jq: parse error: Invalid string: control characters from U+0000 through U+001F must be escaped
Claude Model
Opus
Is this a regression?
I don't know
Last Working Version
_No response_
Claude Code Version
2.1.141 (Claude Code)
Platform
Anthropic API
Operating System
macOS
Terminal/Shell
VS Code integrated terminal
Additional Information
_No response_
This issue has 1 comment on GitHub. Read the full discussion on GitHub ↗