From adfc3796639cd0c5d892fb05303b4b2cddfe6e12 Mon Sep 17 00:00:00 2001 From: sfishman Date: Mon, 2 Mar 2026 20:39:50 +0000 Subject: [PATCH] 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