From 57583e858452a36e22f2750d3dbd4aa1cf954b9a Mon Sep 17 00:00:00 2001 From: Carlos Farah Date: Thu, 13 Aug 2026 17:25:41 -0300 Subject: [PATCH] Don't show Claude Code's injected turns as the notification query MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Stop and StopFailure hooks build the notification title from the last transcript entry of type "user". Not every such entry is something a human typed: Claude Code injects turns of its own with the same type — background task notifications, skill loads, slash-command expansions, compaction summaries. When a turn ends because one of those arrived, the notification shows raw markup instead of the prompt: bpzyv8z2j Monitor event: "pipeline for MR !1151 rea... Claude Code already distinguishes the two: real prompts carry origin.kind == "human", injected turns carry another origin kind (or no origin plus isMeta: true). Key on that instead of on type alone. The extraction was duplicated in on-stop.sh and on-stop-failure.sh, so it moves to a shared extract-query.sh. Transcripts predating the origin field fall back to the last non-meta turn whose text isn't injected markup. Co-Authored-By: Claude Opus 5 (1M context) --- plugins/warp/scripts/extract-query.sh | 45 +++++++++++++++++++ plugins/warp/scripts/on-stop-failure.sh | 15 +------ plugins/warp/scripts/on-stop.sh | 24 +++------- plugins/warp/tests/test-hooks.sh | 60 +++++++++++++++++++++++++ 4 files changed, 112 insertions(+), 32 deletions(-) create mode 100644 plugins/warp/scripts/extract-query.sh diff --git a/plugins/warp/scripts/extract-query.sh b/plugins/warp/scripts/extract-query.sh new file mode 100644 index 0000000..bd830b2 --- /dev/null +++ b/plugins/warp/scripts/extract-query.sh @@ -0,0 +1,45 @@ +#!/bin/bash +# Extracts the last human-authored prompt from a Claude Code transcript. +# +# Usage: source this file, then call extract_query with the transcript path. +# +# source "$(dirname "${BASH_SOURCE[0]}")/extract-query.sh" +# QUERY=$(extract_query "$TRANSCRIPT_PATH") +# +# Not every "user" entry in the transcript is something a human typed. Claude +# Code injects turns of its own — background task notifications, skill loads, +# slash-command expansions, compaction summaries — and stores them with +# type "user" too. Those carry an origin.kind other than "human" (or no origin +# at all, plus isMeta: true), and their content is raw markup like +# . Selecting purely on type "user" surfaces that markup in +# the notification, so we key on origin.kind instead. + +# jq program: prefer the last origin.kind == "human" message; if the transcript +# predates the origin field, fall back to the last non-meta message whose text +# doesn't start with an injected-markup tag. +EXTRACT_QUERY_JQ=' + [ + .[] + | select(.type == "user" and .isMeta != true) + | { + origin: .origin.kind, + text: ( + if .message.content | type == "string" then .message.content + else [.message.content[]? | select(.type == "text") | .text] | join(" ") + end + ) + } + | select(.text != null and .text != "") + ] as $msgs + | ( + ([$msgs[] | select(.origin == "human")] | last) + // ([$msgs[] | select(.origin == null and (.text | test("^[[:space:]]*<[a-zA-Z-]+>") | not))] | last) + ) + | .text // empty +' + +extract_query() { + local transcript_path="$1" + [ -n "$transcript_path" ] && [ -f "$transcript_path" ] || return 0 + jq -rs "$EXTRACT_QUERY_JQ" "$transcript_path" 2>/dev/null +} diff --git a/plugins/warp/scripts/on-stop-failure.sh b/plugins/warp/scripts/on-stop-failure.sh index b170326..c28eafe 100755 --- a/plugins/warp/scripts/on-stop-failure.sh +++ b/plugins/warp/scripts/on-stop-failure.sh @@ -12,6 +12,7 @@ if ! should_use_structured; then fi source "$SCRIPT_DIR/build-payload.sh" +source "$SCRIPT_DIR/extract-query.sh" # Read hook input from stdin INPUT=$(cat) @@ -25,19 +26,7 @@ ERROR_MESSAGE=$(echo "$INPUT" | jq -r '.last_assistant_message // empty' 2>/dev/ TRANSCRIPT_PATH=$(echo "$INPUT" | jq -r '.transcript_path // empty' 2>/dev/null) QUERY="" if [ -n "$TRANSCRIPT_PATH" ] && [ -f "$TRANSCRIPT_PATH" ]; then - QUERY=$(jq -rs ' - [ - .[] | select(.type == "user") | - if .message.content | type == "string" then . - elif [.message.content[] | select(.type == "text")] | length > 0 then . - else empty - end - ] | last | - if .message.content | type == "array" - then [.message.content[] | select(.type == "text") | .text] | join(" ") - else .message.content // empty - end - ' "$TRANSCRIPT_PATH" 2>/dev/null) + QUERY=$(extract_query "$TRANSCRIPT_PATH") if [ -n "$QUERY" ] && [ ${#QUERY} -gt 200 ]; then QUERY="${QUERY:0:197}..." diff --git a/plugins/warp/scripts/on-stop.sh b/plugins/warp/scripts/on-stop.sh index 4163bb9..87f9dc4 100755 --- a/plugins/warp/scripts/on-stop.sh +++ b/plugins/warp/scripts/on-stop.sh @@ -12,6 +12,7 @@ if ! should_use_structured; then fi source "$SCRIPT_DIR/build-payload.sh" +source "$SCRIPT_DIR/extract-query.sh" # Read hook input from stdin INPUT=$(cat) @@ -30,25 +31,10 @@ sleep 0.3 QUERY="" RESPONSE="" if [ -n "$TRANSCRIPT_PATH" ] && [ -f "$TRANSCRIPT_PATH" ]; then - # Get the last human prompt from the transcript. - # "user" type messages include both human prompts and tool-result messages. - # Human prompts have content that is either a plain string or an array - # containing {type:"text"} blocks. Tool-result messages have content arrays - # containing only {type:"tool_result"} blocks. We filter to messages that - # have at least one "text" block (or are a plain string). - QUERY=$(jq -rs ' - [ - .[] | select(.type == "user") | - if .message.content | type == "string" then . - elif [.message.content[] | select(.type == "text")] | length > 0 then . - else empty - end - ] | last | - if .message.content | type == "array" - then [.message.content[] | select(.type == "text") | .text] | join(" ") - else .message.content // empty - end - ' "$TRANSCRIPT_PATH" 2>/dev/null) + # Get the last human prompt from the transcript. See extract-query.sh — + # "user" entries also cover tool results and Claude Code's own injected + # turns, which must not reach the notification. + QUERY=$(extract_query "$TRANSCRIPT_PATH") # Get the last assistant response RESPONSE=$(jq -rs ' diff --git a/plugins/warp/tests/test-hooks.sh b/plugins/warp/tests/test-hooks.sh index 754bdd0..03d3a4d 100755 --- a/plugins/warp/tests/test-hooks.sh +++ b/plugins/warp/tests/test-hooks.sh @@ -287,6 +287,66 @@ for HOOK in on-permission-request.sh on-prompt-submit.sh on-post-tool-use.sh; do assert_eq "$HOOK exits 0 without protocol version" "0" "$?" done +echo "" +echo "=== extract-query.sh ===" + +source "$SCRIPT_DIR/extract-query.sh" + +FIXTURE_DIR=$(mktemp -d) +trap 'rm -rf "$FIXTURE_DIR"' EXIT + +# A turn that ended because a background task reported back: the human's real +# prompt came earlier, and Claude Code injected the turn. +cat > "$FIXTURE_DIR/task-notification.jsonl" <<'FIXTURE' +{"type":"user","origin":{"kind":"human"},"promptSource":"typed","message":{"role":"user","content":"review the pipeline for MR !1151"}} +{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"Watching it now."}]}} +{"type":"user","origin":{"kind":"task-notification"},"promptSource":"system","message":{"role":"user","content":"\nbpzyv8z2j\nMonitor event\n"}} +FIXTURE +assert_eq "task-notification does not become the query" \ + "review the pipeline for MR !1151" \ + "$(extract_query "$FIXTURE_DIR/task-notification.jsonl")" + +# Skill loads, slash-command expansions and compaction summaries are injected +# with isMeta: true and no origin. +cat > "$FIXTURE_DIR/meta.jsonl" <<'FIXTURE' +{"type":"user","origin":{"kind":"human"},"promptSource":"typed","message":{"role":"user","content":"fix the hook"}} +{"type":"user","isMeta":true,"message":{"role":"user","content":"Base directory for this skill: /home/x/.claude/skills/y"}} +{"type":"user","isMeta":true,"message":{"role":"user","content":"[Image: source: /home/x/Pictures/shot.png]"}} +FIXTURE +assert_eq "isMeta turns do not become the query" \ + "fix the hook" \ + "$(extract_query "$FIXTURE_DIR/meta.jsonl")" + +# Tool results are "user" entries too, but carry no text block. +cat > "$FIXTURE_DIR/tool-result.jsonl" <<'FIXTURE' +{"type":"user","origin":{"kind":"human"},"promptSource":"typed","message":{"role":"user","content":[{"type":"text","text":"run the tests"}]}} +{"type":"user","message":{"role":"user","content":[{"type":"tool_result","content":"ok"}]}} +FIXTURE +assert_eq "text blocks in an array are joined, tool results skipped" \ + "run the tests" \ + "$(extract_query "$FIXTURE_DIR/tool-result.jsonl")" + +# Transcripts written before the origin field existed still need a usable query. +cat > "$FIXTURE_DIR/no-origin.jsonl" <<'FIXTURE' +{"type":"user","message":{"role":"user","content":"older prompt with no origin field"}} +{"type":"user","message":{"role":"user","content":"Added allow rule"}} +FIXTURE +assert_eq "falls back to the last non-markup turn when origin is absent" \ + "older prompt with no origin field" \ + "$(extract_query "$FIXTURE_DIR/no-origin.jsonl")" + +# A transcript with nothing human in it must yield an empty query, not markup. +cat > "$FIXTURE_DIR/only-injected.jsonl" <<'FIXTURE' +{"type":"user","origin":{"kind":"task-notification"},"promptSource":"system","message":{"role":"user","content":"\nabc\n"}} +FIXTURE +assert_eq "no human turn yields an empty query" \ + "" \ + "$(extract_query "$FIXTURE_DIR/only-injected.jsonl")" + +assert_eq "missing transcript yields an empty query" \ + "" \ + "$(extract_query "$FIXTURE_DIR/does-not-exist.jsonl")" + # --- Summary --- echo ""