From 64389e62f9435897ffc4d6333a3a482eb119ace7 Mon Sep 17 00:00:00 2001 From: Tal Salmona <222969+talsalmona@users.noreply.github.com> Date: Tue, 13 Oct 2026 09:02:34 +0300 Subject: [PATCH] Don't notify for turns Claude Code starts on its own Claude Code starts turns that no person asked for: a teammate or another session messages this one, a channel delivers an event, a background task reports back. Each such turn ends with a Stop, so Warp raised a "task completed" notification titled with the raw or envelope. Read who started the turn from the transcript (turnOrigin / origin.kind, plus the "Another Claude session sent a message" prefix that Agent Teams messages carry without an origin field) and send nothing to Warp for those turns: no prompt_submit, tool_complete, stop or idle_prompt. A turn Warp was told about (a prompt_submit was sent, or a permission request needed the user) always gets its stop, so the session status never sticks. Fixes #72. Covers the notification half of #67. Co-Authored-By: Claude Opus 5.5 (1M context) --- .claude-plugin/marketplace.json | 2 +- README.md | 2 + plugins/warp/.claude-plugin/plugin.json | 2 +- plugins/warp/scripts/on-notification.sh | 5 + plugins/warp/scripts/on-permission-request.sh | 4 + plugins/warp/scripts/on-post-tool-use.sh | 3 + plugins/warp/scripts/on-prompt-submit.sh | 5 + plugins/warp/scripts/on-stop-failure.sh | 3 + plugins/warp/scripts/on-stop.sh | 7 ++ plugins/warp/scripts/turn-origin.sh | 106 ++++++++++++++++ plugins/warp/tests/test-hooks.sh | 113 +++++++++++++++++- 11 files changed, 249 insertions(+), 3 deletions(-) create mode 100755 plugins/warp/scripts/turn-origin.sh diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index ffd8069..a40b1a7 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -10,7 +10,7 @@ "name": "warp", "description": "Native Warp notifications when Claude completes tasks or needs input", "source": "./plugins/warp", - "version": "2.4.0", + "version": "2.4.1", "category": "productivity", "tags": ["notifications", "terminal", "warp"] }, diff --git a/README.md b/README.md index fa0930a..2579d80 100644 --- a/README.md +++ b/README.md @@ -55,6 +55,8 @@ The plugin registers lifecycle hooks including: - **UserPromptSubmit** — fires when you submit a prompt, signaling the session is active again - **PostToolUse** — fires when a tool call completes, signaling the session is no longer blocked +Turns that Claude Code starts on its own (a teammate or another session messaging this one, a channel event, a background task reporting back) send no events, so they never raise a notification or change the session status. If you answer a permission request or type a prompt during such a turn, the turn is reported as usual. + ### Legacy Support Older Warp clients that predate the structured notification protocol are still supported — they receive plain-text notifications for SessionStart, Stop, and Notification hooks. diff --git a/plugins/warp/.claude-plugin/plugin.json b/plugins/warp/.claude-plugin/plugin.json index dd8f2ba..a4fa554 100644 --- a/plugins/warp/.claude-plugin/plugin.json +++ b/plugins/warp/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "warp", "description": "Warp terminal integration for Claude Code - native notifications, and more to come", - "version": "2.4.0", + "version": "2.4.1", "author": { "name": "Warp", "url": "https://warp.dev" diff --git a/plugins/warp/scripts/on-notification.sh b/plugins/warp/scripts/on-notification.sh index 6243aaf..858b46f 100755 --- a/plugins/warp/scripts/on-notification.sh +++ b/plugins/warp/scripts/on-notification.sh @@ -18,6 +18,11 @@ INPUT=$(cat) # Extract notification-specific fields NOTIF_TYPE=$(echo "$INPUT" | jq -r '.notification_type // "unknown"' 2>/dev/null) + +# agent_needs_input always reaches the user; idle_prompt after a turn they did not start does not +source "$SCRIPT_DIR/turn-origin.sh" +[ "$NOTIF_TYPE" = "idle_prompt" ] && turn_is_silent "$INPUT" && exit 0 + MSG=$(echo "$INPUT" | jq -r '.message // "Input needed"' 2>/dev/null) [ -z "$MSG" ] && MSG="Input needed" if [ ${#MSG} -gt 120 ]; then diff --git a/plugins/warp/scripts/on-permission-request.sh b/plugins/warp/scripts/on-permission-request.sh index 7d46ed2..a356047 100755 --- a/plugins/warp/scripts/on-permission-request.sh +++ b/plugins/warp/scripts/on-permission-request.sh @@ -15,6 +15,10 @@ source "$SCRIPT_DIR/build-payload.sh" # Read hook input from stdin INPUT=$(cat) +# The user has to answer this, so the rest of the turn is theirs too +source "$SCRIPT_DIR/turn-origin.sh" +turn_mark_engaged "$INPUT" + # Extract permission-request-specific fields TOOL_NAME=$(echo "$INPUT" | jq -r '.tool_name // "unknown"' 2>/dev/null) TOOL_INPUT=$(echo "$INPUT" | jq -c '.tool_input // {}' 2>/dev/null) diff --git a/plugins/warp/scripts/on-post-tool-use.sh b/plugins/warp/scripts/on-post-tool-use.sh index 568e5b3..92b24ce 100755 --- a/plugins/warp/scripts/on-post-tool-use.sh +++ b/plugins/warp/scripts/on-post-tool-use.sh @@ -16,6 +16,9 @@ source "$SCRIPT_DIR/build-payload.sh" # Read hook input from stdin INPUT=$(cat) +source "$SCRIPT_DIR/turn-origin.sh" +turn_is_silent "$INPUT" && exit 0 + TOOL_NAME=$(echo "$INPUT" | jq -r '.tool_name // empty' 2>/dev/null) BODY=$(build_payload "$INPUT" "tool_complete" \ diff --git a/plugins/warp/scripts/on-prompt-submit.sh b/plugins/warp/scripts/on-prompt-submit.sh index 0a8a55e..62f4a18 100755 --- a/plugins/warp/scripts/on-prompt-submit.sh +++ b/plugins/warp/scripts/on-prompt-submit.sh @@ -16,6 +16,11 @@ source "$SCRIPT_DIR/build-payload.sh" # Read hook input from stdin INPUT=$(cat) +# Turns Claude Code injects stay invisible to Warp; a turn Warp hears about always gets its stop +source "$SCRIPT_DIR/turn-origin.sh" +turn_prompt_is_injected "$INPUT" && exit 0 +turn_mark_engaged "$INPUT" + # Extract the user's prompt QUERY=$(echo "$INPUT" | jq -r '.prompt // empty' 2>/dev/null) if [ -n "$QUERY" ] && [ ${#QUERY} -gt 200 ]; then diff --git a/plugins/warp/scripts/on-stop-failure.sh b/plugins/warp/scripts/on-stop-failure.sh index b170326..b21143e 100755 --- a/plugins/warp/scripts/on-stop-failure.sh +++ b/plugins/warp/scripts/on-stop-failure.sh @@ -16,6 +16,9 @@ source "$SCRIPT_DIR/build-payload.sh" # Read hook input from stdin INPUT=$(cat) +source "$SCRIPT_DIR/turn-origin.sh" +turn_end "$INPUT" + # Extract the error type (e.g. "rate_limit") and human-readable message. ERROR_TYPE=$(echo "$INPUT" | jq -r '.error // empty' 2>/dev/null) ERROR_MESSAGE=$(echo "$INPUT" | jq -r '.last_assistant_message // empty' 2>/dev/null) diff --git a/plugins/warp/scripts/on-stop.sh b/plugins/warp/scripts/on-stop.sh index 34e06f7..3ebd671 100755 --- a/plugins/warp/scripts/on-stop.sh +++ b/plugins/warp/scripts/on-stop.sh @@ -22,6 +22,13 @@ if [ "$STOP_HOOK_ACTIVE" = "true" ]; then exit 0 fi +# A turn another session, a channel or a background task started is not the user's task finishing +source "$SCRIPT_DIR/turn-origin.sh" +SILENT_TURN=false +turn_is_silent "$INPUT" && SILENT_TURN=true +turn_end "$INPUT" +[ "$SILENT_TURN" = "true" ] && exit 0 + # Extract the last user prompt and assistant response from the transcript. # Small delay to allow Claude Code to flush the current turn to the transcript file. # The Stop hook fires before the transcript is fully written. diff --git a/plugins/warp/scripts/turn-origin.sh b/plugins/warp/scripts/turn-origin.sh new file mode 100755 index 0000000..a907208 --- /dev/null +++ b/plugins/warp/scripts/turn-origin.sh @@ -0,0 +1,106 @@ +#!/bin/bash +# Decides whether the current turn should reach Warp at all. +# +# Claude Code starts turns of its own: a teammate or another session sends a message, +# a channel delivers an event, a background task reports back. None of these is the +# user's task finishing, so they send no prompt_submit, tool_complete, stop or +# idle_prompt to Warp. +# +# A turn Warp was told about stays reported to the end, so its status never sticks: +# once prompt_submit or permission_request was sent for a turn, its stop is sent too. +# +# The origin is read from the transcript (turnOrigin / origin.kind; Agent Teams messages +# carry neither and are matched by their prefix). The transcript is scanned +# incrementally: the byte offset, the last turn origin and the engaged flag are cached +# per session, so each hook reads only the new bytes. +# +# Usage: +# source "$SCRIPT_DIR/turn-origin.sh" +# turn_is_silent "$INPUT" && exit 0 +# turn_prompt_is_injected "$INPUT" && exit 0 +# turn_mark_engaged "$INPUT" +# turn_end "$INPUT" + +TURN_STATE_DIR="${TURN_STATE_DIR:-${TMPDIR:-/tmp}/claude-code-warp-turns}" + +# Prompt prefixes of the turns Claude Code injects: Agent Teams and cross-session +# messages, channel events, background task reports. +TURN_INJECTED_PREFIX='^(Another Claude session sent a message|]||]|])' + +_turn_state_file() { + local session_id + session_id=$(echo "$1" | jq -r '.session_id // empty' 2>/dev/null) + [ -z "$session_id" ] && return 1 + echo "$TURN_STATE_DIR/$session_id" +} + +_turn_write_state() { + mkdir -p "$TURN_STATE_DIR" 2>/dev/null + echo "$2 $3 $4" > "$1" 2>/dev/null +} + +# Prints " " for the current turn after scanning new transcript bytes. +_turn_scan() { + local input="$1" state transcript offset=0 origin="" engaged=0 size latest + state=$(_turn_state_file "$input") || return 1 + [ -f "$state" ] && read -r offset origin engaged < "$state" + transcript=$(echo "$input" | jq -r '.transcript_path // empty' 2>/dev/null) + if [ -z "$transcript" ] || [ ! -f "$transcript" ]; then + echo "unknown ${engaged:-0}" + return 0 + fi + + size=$(wc -c < "$transcript" | tr -d ' ') + [ "${offset:-0}" -gt "$size" ] 2>/dev/null && { offset=0; origin=""; } + + latest=$(tail -c +"$((${offset:-0} + 1))" "$transcript" 2>/dev/null \ + | grep -E '"turnOrigin"|"origin"|"queued_command"|Another Claude session sent a message' \ + | jq -Rr ' + fromjson? | + if .type == "attachment" and .attachment.type == "queued_command" then + # A prompt the user typed mid-turn makes the turn theirs; a queued channel event does not. + if ((.attachment.origin // .origin).kind // "human") == "human" then "human" else empty end + elif .type == "user" and (.isMeta | not) then + (.message.content | if type == "string" then . else ([.[]? | select(.type == "text") | .text] | join(" ")) end) as $text | + if (.turnOrigin // .origin.kind) then (.turnOrigin // .origin.kind) + elif ($text | startswith("Another Claude session sent a message")) then "peer" + else empty end + else empty end' 2>/dev/null \ + | tail -1) + [ -n "$latest" ] && origin="$latest" + + # A half-written last line is rescanned next time rather than skipped. + [ "$(tail -c 1 "$transcript" | od -An -c | tr -d ' ')" = '\n' ] || size="${offset:-0}" + + _turn_write_state "$state" "$size" "${origin:-unknown}" "${engaged:-0}" + echo "${origin:-unknown} ${engaged:-0}" +} + +# Returns 0 when Warp should not hear about this turn. +turn_is_silent() { + local origin engaged + read -r origin engaged < <(_turn_scan "$1") || return 1 + [ "$origin" != "human" ] && [ "$origin" != "unknown" ] && [ "$engaged" != "1" ] +} + +# Returns 0 when UserPromptSubmit fired for a turn Claude Code injected. +turn_prompt_is_injected() { + echo "$1" | jq -e --arg re "$TURN_INJECTED_PREFIX" '(.prompt // "") | test($re)' > /dev/null 2>&1 +} + +# The user has been told about this turn or has to act in it, so its stop must be sent. +turn_mark_engaged() { + local state origin size + state=$(_turn_state_file "$1") || return 0 + read -r origin _ < <(_turn_scan "$1") || return 0 + read -r size _ < "$state" 2>/dev/null + _turn_write_state "$state" "${size:-0}" "$origin" 1 +} + +turn_end() { + local state offset origin + state=$(_turn_state_file "$1") || return 0 + [ -f "$state" ] || return 0 + read -r offset origin _ < "$state" + _turn_write_state "$state" "$offset" "$origin" 0 +} diff --git a/plugins/warp/tests/test-hooks.sh b/plugins/warp/tests/test-hooks.sh index e2d3ea7..9fa8501 100755 --- a/plugins/warp/tests/test-hooks.sh +++ b/plugins/warp/tests/test-hooks.sh @@ -56,7 +56,7 @@ MARKETPLACE_VERSION=$(jq -r '.plugins[] | select(.name == "warp") | .version' \ "$REPO_DIR/.claude-plugin/marketplace.json") NOTIFICATION_MATCHER=$(jq -r '.hooks.Notification[0].matcher' "$PLUGIN_DIR/hooks/hooks.json") -assert_eq "plugin version bumped" "2.4.0" "$PLUGIN_VERSION" +assert_eq "plugin version bumped" "2.4.1" "$PLUGIN_VERSION" assert_eq "marketplace version matches plugin" "$PLUGIN_VERSION" "$MARKETPLACE_VERSION" assert_eq "notification matcher includes input-required events" \ "idle_prompt|agent_needs_input" \ @@ -320,6 +320,117 @@ 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 "=== turn-origin.sh ===" + +source "$SCRIPT_DIR/turn-origin.sh" +TURN_TMP=$(mktemp -d) +TURN_STATE_DIR="$TURN_TMP/state" +TRANSCRIPT="$TURN_TMP/transcript.jsonl" +: > "$TRANSCRIPT" +TURN_INPUT="{\"session_id\":\"sess-turn\",\"transcript_path\":\"$TRANSCRIPT\"}" + +append() { printf '%s\n' "$1" >> "$TRANSCRIPT"; } +assert_turn() { + local actual=notify + turn_is_silent "${3:-$TURN_INPUT}" && actual=silent + assert_eq "$1" "$2" "$actual" +} + +echo "" +echo "--- Turn started by the user ---" +append '{"type":"user","message":{"content":"fix the bug"},"origin":{"kind":"human"},"turnOrigin":"human"}' +append '{"type":"user","message":{"content":[{"type":"tool_result","content":"Another Claude session sent a message"}]}}' +assert_turn "typed prompt notifies" notify +append '{"type":"attachment","attachment":{"type":"queued_command","origin":{"kind":"channel"},"prompt":""}}' +assert_turn "channel event queued mid-turn keeps the user's turn" notify + +echo "" +echo "--- Turns another session, a channel or a background task started ---" +append '{"type":"user","message":{"content":"Another Claude session sent a message:\n\nTask completed."}}' +assert_turn "Agent Teams teammate message is silent" silent +append '{"type":"user","message":{"content":""},"origin":{"kind":"channel"},"turnOrigin":"peer"}' +assert_turn "channel message is silent" silent +append '{"type":"user","message":{"content":""},"origin":{"kind":"task-notification"},"turnOrigin":"task_notification"}' +assert_turn "background task notification is silent" silent +append '{"type":"user","isMeta":true,"message":{"content":[{"type":"text","text":"skill body"}]}}' +assert_turn "meta entries do not change the turn" silent + +echo "" +echo "--- The user engages with a turn they did not start ---" +append '{"type":"attachment","attachment":{"type":"queued_command","origin":{"kind":"human"},"prompt":"also check X"}}' +assert_turn "prompt typed mid-turn notifies" notify +append '{"type":"user","message":{"content":""},"origin":{"kind":"task-notification"},"turnOrigin":"task_notification"}' +assert_turn "next background turn is silent again" silent +turn_mark_engaged "$TURN_INPUT" +assert_turn "permission request makes the turn notify" notify +turn_end "$TURN_INPUT" +assert_turn "engagement ends with the turn" silent +append '{"type":"user","message":{"content":""},"origin":{"kind":"channel"},"turnOrigin":"peer"}' +assert_turn "next channel turn is silent" silent + +echo "" +echo "--- Injected prompts ---" +assert_injected() { + local actual=typed + turn_prompt_is_injected "$(jq -nc --arg p "$2" '{prompt: $p}')" && actual=injected + assert_eq "$1" "$3" "$actual" +} +assert_injected "teammate message" $'Another Claude session sent a message:\n' injected +assert_injected "channel event" '' injected +assert_injected "background task report" $'\nb1' injected +assert_injected "cross-session message" '' injected +assert_injected "typed prompt" 'fix the parser' typed +assert_injected "empty prompt" '' typed + +echo "" +echo "--- Fails open ---" +append '{"type":"user","message":{"content":"next"},"origin":{"kind":"human"},"turnOrigin":"human"}' +printf '%s' '{"type":"user","message":{"content":"Another Claude session sent a message: x' >> "$TRANSCRIPT" +assert_turn "half-written last line is not trusted" notify +printf '%s\n' '"}}' >> "$TRANSCRIPT" +assert_turn "the same line once complete is read" silent +assert_turn "missing transcript notifies" notify "{\"session_id\":\"sess-gone\",\"transcript_path\":\"$TURN_TMP/missing.jsonl\"}" +assert_turn "missing session id notifies" notify "{\"transcript_path\":\"$TRANSCRIPT\"}" +printf '%s\n' '{"type":"user","message":{"content":"prompt from a Claude Code without origin fields"}}' > "$TURN_TMP/old.jsonl" +assert_turn "transcript without origin fields notifies" notify "{\"session_id\":\"sess-old\",\"transcript_path\":\"$TURN_TMP/old.jsonl\"}" + +echo "" +echo "--- Hooks skip silent turns ---" + +# Runs a hook as Claude Code would on a structured-capable Warp and prints the JSON payload it sent +run_structured_hook() { + local seq + seq=$(echo "$2" | TMPDIR="$TURN_TMP" WARP_CLI_AGENT_PROTOCOL_VERSION=1 \ + WARP_CLIENT_VERSION="v0.2026.09.01.00.00.dev_00" CLAUDE_CODE_VERSION="2.1.200" \ + bash "$HOOK_DIR/$1" 2>/dev/null | jq -r '.terminalSequence // empty') + seq="${seq#*warp://cli-agent;}" + printf '%s' "${seq%$'\a'}" +} + +HOOK_TRANSCRIPT="$TURN_TMP/hook.jsonl" +HOOK_INPUT="{\"session_id\":\"sess-hook\",\"cwd\":\"/tmp/p\",\"transcript_path\":\"$HOOK_TRANSCRIPT\",\"notification_type\":\"idle_prompt\",\"tool_name\":\"Bash\"}" +printf '%s\n' '{"type":"user","message":{"content":"Another Claude session sent a message:\ndone"}}' > "$HOOK_TRANSCRIPT" +for HOOK in on-stop.sh on-post-tool-use.sh on-notification.sh; do + assert_eq "$HOOK sends nothing for a teammate turn" "" "$(run_structured_hook "$HOOK" "$HOOK_INPUT")" +done +PAYLOAD=$(run_structured_hook on-notification.sh "${HOOK_INPUT/idle_prompt/agent_needs_input}") +assert_json_field "agent_needs_input still notifies in a teammate turn" "$PAYLOAD" ".event" "agent_needs_input" +printf '%s\n' '{"type":"user","message":{"content":"fix the bug"},"origin":{"kind":"human"},"turnOrigin":"human"}' >> "$HOOK_TRANSCRIPT" +PAYLOAD=$(run_structured_hook on-stop.sh "$HOOK_INPUT") +assert_json_field "on-stop.sh notifies for the user's turn" "$PAYLOAD" ".query" "fix the bug" + +INJECTED_INPUT=$(echo "$HOOK_INPUT" | jq -c '.prompt = "\nb1"') +assert_eq "on-prompt-submit.sh sends nothing for an injected prompt" "" "$(run_structured_hook on-prompt-submit.sh "$INJECTED_INPUT")" +TYPED_INPUT=$(echo "$HOOK_INPUT" | jq -c '.prompt = "every hour: check the deploy"') +PAYLOAD=$(run_structured_hook on-prompt-submit.sh "$TYPED_INPUT") +assert_json_field "on-prompt-submit.sh reports a typed-looking prompt" "$PAYLOAD" ".event" "prompt_submit" +printf '%s\n' '{"type":"user","message":{"content":"every hour: check the deploy"},"origin":{"kind":"scheduled"},"turnOrigin":"scheduled"}' >> "$HOOK_TRANSCRIPT" +PAYLOAD=$(run_structured_hook on-stop.sh "$HOOK_INPUT") +assert_json_field "a turn Warp was told about still gets its stop" "$PAYLOAD" ".event" "stop" +assert_eq "idle_prompt after that turn is silent" "" "$(run_structured_hook on-notification.sh "$HOOK_INPUT")" +rm -rf "$TURN_TMP" + # --- Summary --- echo ""