Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .claude-plugin/marketplace.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
},
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion plugins/warp/.claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
5 changes: 5 additions & 0 deletions plugins/warp/scripts/on-notification.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions plugins/warp/scripts/on-permission-request.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
3 changes: 3 additions & 0 deletions plugins/warp/scripts/on-post-tool-use.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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" \
Expand Down
5 changes: 5 additions & 0 deletions plugins/warp/scripts/on-prompt-submit.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions plugins/warp/scripts/on-stop-failure.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
7 changes: 7 additions & 0 deletions plugins/warp/scripts/on-stop.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
106 changes: 106 additions & 0 deletions plugins/warp/scripts/turn-origin.sh
Original file line number Diff line number Diff line change
@@ -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|<channel[ >]|<task-notification>|<teammate-message[ >]|<cross-session-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 "<origin> <engaged>" 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
}
113 changes: 112 additions & 1 deletion plugins/warp/tests/test-hooks.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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" \
Expand Down Expand Up @@ -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":"<channel>"}}'
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<teammate-message teammate_id=\"reviewer\">\nTask completed."}}'
assert_turn "Agent Teams teammate message is silent" silent
append '{"type":"user","message":{"content":"<channel source=\"ci\">"},"origin":{"kind":"channel"},"turnOrigin":"peer"}'
assert_turn "channel message is silent" silent
append '{"type":"user","message":{"content":"<task-notification>"},"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":"<task-notification>"},"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":"<channel source=\"ci\">"},"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<teammate-message teammate_id="a">' injected
assert_injected "channel event" '<channel source="ci" kind="pr-checks">' injected
assert_injected "background task report" $'<task-notification>\n<task-id>b1</task-id>' injected
assert_injected "cross-session message" '<cross-session-message from="uds:/tmp/x.sock">' injected
assert_injected "typed prompt" 'fix the <channel> 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:\n<teammate-message teammate_id=\"reviewer\">done"}}' > "$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 = "<task-notification>\n<task-id>b1</task-id>"')
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 ""
Expand Down