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
80 changes: 80 additions & 0 deletions plugins/warp/scripts/extract-transcript.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
#!/bin/bash
# Shared helpers for scraping the Claude Code session transcript.
#
# The Stop and StopFailure hooks send the user's last prompt to Warp, which
# renders it as the notification title. A transcript's `type: "user"` entries
# are not all human prompts though: Claude Code also injects local `!` bash
# output, slash-command wrappers, background subagent completions,
# cross-session messages and system reminders as user-type entries with
# ordinary text content. Without filtering, those envelopes become the title —
# e.g. a completion toast reading `<bash-stdout>arn:aws:ecs:us-east-1:...`
# instead of what the user actually asked for.
#
# The filter only skips patterns it positively recognizes; anything else is
# passed through untouched. An odd-looking title beats a missing notification.

# Envelope tags for entries Claude Code injects on its own. These are stable
# ASCII identifiers, matched at the start of the entry's text so that a human
# prompt merely quoting a tag is left alone.
WARP_SYNTHETIC_TAGS='bash-input|bash-stdout|bash-stderr'
WARP_SYNTHETIC_TAGS="$WARP_SYNTHETIC_TAGS|command-args|command-message|command-name"
WARP_SYNTHETIC_TAGS="$WARP_SYNTHETIC_TAGS|local-command-caveat|local-command-stderr|local-command-stdout"
WARP_SYNTHETIC_TAGS="$WARP_SYNTHETIC_TAGS|persisted-output|system-reminder"
WARP_SYNTHETIC_TAGS="$WARP_SYNTHETIC_TAGS|task-id|task-notification|user-prompt-submit-hook"

# Cross-session and teammate envelopes are preceded by a framing line
# ("Another Claude session sent a message:"), so their tag is not at the start
# of the entry. The framing line is human language and may be localized, so
# anchor on the tag itself and accept it anywhere in the entry.
WARP_SYNTHETIC_TAGS_ANYWHERE='cross-session-message|teammate-message'

# Markers Claude Code injects without an XML envelope. English-only, so this is
# best effort: a localized build falls through to today's behaviour rather than
# breaking.
WARP_SYNTHETIC_PREFIXES='\[Request interrupted by user|\[SYSTEM NOTIFICATION|Stop hook feedback:'

# Prints the most recent human prompt in the transcript, or nothing when the
# transcript is unreadable or holds no human prompt.
extract_last_user_prompt() {
local transcript_path="${1:-}"

if [ -z "$transcript_path" ] || [ ! -f "$transcript_path" ]; then
return 0
fi

jq -rs \
--arg tags "$WARP_SYNTHETIC_TAGS" \
--arg tags_anywhere "$WARP_SYNTHETIC_TAGS_ANYWHERE" \
--arg prefixes "$WARP_SYNTHETIC_PREFIXES" '
[
.[] | select(.type == "user") |
# Human prompts are a plain string or an array with text blocks;
# tool results are arrays of tool_result blocks and drop out here.
(if .message.content | type == "string" then .message.content
elif .message.content | type == "array" then
([.message.content[]? | select(.type == "text") | .text] | join(" "))
else empty
end) as $text |
select(($text | length) > 0) |
select($text | test("^\\s*<(" + $tags + ")\\b") | not) |
select($text | test("<(" + $tags_anywhere + ")\\b") | not) |
select($text | test("^\\s*(" + $prefixes + ")") | not) |
$text
] | last // empty
' "$transcript_path" 2>/dev/null
}

# Prints the text of the most recent assistant message in the transcript, or
# nothing when the transcript is unreadable or holds no assistant message.
extract_last_assistant_response() {
local transcript_path="${1:-}"

if [ -z "$transcript_path" ] || [ ! -f "$transcript_path" ]; then
return 0
fi

jq -rs '
[.[] | select(.type == "assistant" and .message.content)] | last |
[.message.content[] | select(.type == "text") | .text] | join(" ")
' "$transcript_path" 2>/dev/null
}
15 changes: 2 additions & 13 deletions plugins/warp/scripts/on-stop-failure.sh
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ if ! should_use_structured; then
fi

source "$SCRIPT_DIR/build-payload.sh"
source "$SCRIPT_DIR/extract-transcript.sh"

# Read hook input from stdin
INPUT=$(cat)
Expand All @@ -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_last_user_prompt "$TRANSCRIPT_PATH")

if [ -n "$QUERY" ] && [ ${#QUERY} -gt 200 ]; then
QUERY="${QUERY:0:197}..."
Expand Down
28 changes: 3 additions & 25 deletions plugins/warp/scripts/on-stop.sh
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ if ! should_use_structured; then
fi

source "$SCRIPT_DIR/build-payload.sh"
source "$SCRIPT_DIR/extract-transcript.sh"

# Read hook input from stdin
INPUT=$(cat)
Expand All @@ -30,31 +31,8 @@ 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 assistant response
RESPONSE=$(jq -rs '
[.[] | select(.type == "assistant" and .message.content)] | last |
[.message.content[] | select(.type == "text") | .text] | join(" ")
' "$TRANSCRIPT_PATH" 2>/dev/null)
QUERY=$(extract_last_user_prompt "$TRANSCRIPT_PATH")
RESPONSE=$(extract_last_assistant_response "$TRANSCRIPT_PATH")

# Truncate for notification display
if [ -n "$QUERY" ] && [ ${#QUERY} -gt 200 ]; then
Expand Down
96 changes: 96 additions & 0 deletions plugins/warp/tests/test-hooks.sh
Original file line number Diff line number Diff line change
Expand Up @@ -287,6 +287,102 @@ 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-transcript.sh ==="

source "$SCRIPT_DIR/extract-transcript.sh"

TRANSCRIPT=$(mktemp -t warp-transcript)
trap 'rm -f "$TRANSCRIPT"' EXIT

# Writes each argument as one JSONL entry of the mock transcript.
write_transcript() {
printf '%s\n' "$@" > "$TRANSCRIPT"
}

HUMAN='{"type":"user","message":{"role":"user","content":"check the prod deploy"}}'
HUMAN_OLDER='{"type":"user","message":{"role":"user","content":"open the dashboard"}}'
ASSISTANT='{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"Looking."}]}}'
TOOL_RESULT='{"type":"user","message":{"role":"user","content":[{"type":"tool_result","content":"ok"}]}}'

echo ""
echo "--- Human prompts are returned ---"

write_transcript "$HUMAN_OLDER" "$ASSISTANT" "$HUMAN" "$ASSISTANT"
assert_eq "last human prompt wins" "check the prod deploy" "$(extract_last_user_prompt "$TRANSCRIPT")"

write_transcript '{"type":"user","message":{"role":"user","content":[{"type":"text","text":"deploy"},{"type":"text","text":"now"}]}}'
assert_eq "text blocks are joined" "deploy now" "$(extract_last_user_prompt "$TRANSCRIPT")"

write_transcript "$HUMAN" "$TOOL_RESULT"
assert_eq "tool results are skipped" "check the prod deploy" "$(extract_last_user_prompt "$TRANSCRIPT")"

echo ""
echo "--- Synthetic entries are skipped ---"

# Local `!` bash mode: Claude Code records the command and its output as
# plain-string user entries.
write_transcript "$HUMAN" \
'{"type":"user","message":{"role":"user","content":"<bash-input>aws ecs list-tasks</bash-input>"}}' \
'{"type":"user","message":{"role":"user","content":"<bash-stdout>arn:aws:ecs:us-east-1:123:task/prod/a4d9c84a</bash-stdout><bash-stderr></bash-stderr>"}}'
assert_eq "bash mode output is skipped" "check the prod deploy" "$(extract_last_user_prompt "$TRANSCRIPT")"

write_transcript "$HUMAN" \
'{"type":"user","message":{"role":"user","content":"<task-notification>\n<task-id>b7s43w48d</task-id>\n</task-notification>"}}'
assert_eq "background subagent notification is skipped" "check the prod deploy" "$(extract_last_user_prompt "$TRANSCRIPT")"

write_transcript "$HUMAN" \
'{"type":"user","message":{"role":"user","content":"<command-name>/clear</command-name><command-message>clear</command-message>"}}' \
'{"type":"user","message":{"role":"user","content":"<local-command-caveat>Caveat</local-command-caveat>"}}' \
'{"type":"user","message":{"role":"user","content":"<local-command-stdout>Login successful.</local-command-stdout>"}}'
assert_eq "slash-command wrappers are skipped" "check the prod deploy" "$(extract_last_user_prompt "$TRANSCRIPT")"

write_transcript "$HUMAN" \
'{"type":"user","message":{"role":"user","content":"<system-reminder>Do not mention this.</system-reminder>"}}'
assert_eq "system reminder is skipped" "check the prod deploy" "$(extract_last_user_prompt "$TRANSCRIPT")"

# Cross-session/teammate envelopes arrive behind a framing line, so the tag is
# not at the start of the entry.
write_transcript "$HUMAN" \
'{"type":"user","message":{"role":"user","content":"Another Claude session sent a message:\n<teammate-message teammate_id=\"researcher\" summary=\"done\">ok</teammate-message>"}}'
assert_eq "teammate message is skipped" "check the prod deploy" "$(extract_last_user_prompt "$TRANSCRIPT")"

write_transcript "$HUMAN" \
'{"type":"user","message":{"role":"user","content":"[Request interrupted by user for tool use]"}}'
assert_eq "interrupt marker is skipped" "check the prod deploy" "$(extract_last_user_prompt "$TRANSCRIPT")"

write_transcript "$HUMAN" \
'{"type":"user","message":{"role":"user","content":" \n<bash-stdout>indented</bash-stdout>"}}'
assert_eq "leading whitespace does not defeat the filter" "check the prod deploy" "$(extract_last_user_prompt "$TRANSCRIPT")"

echo ""
echo "--- Human prompts are never over-filtered ---"

write_transcript '{"type":"user","message":{"role":"user","content":"why does the title show <bash-stdout> instead of my prompt?"}}'
assert_eq "a prompt mentioning a tag mid-sentence is kept" \
"why does the title show <bash-stdout> instead of my prompt?" \
"$(extract_last_user_prompt "$TRANSCRIPT")"

echo ""
echo "--- Degenerate transcripts ---"

write_transcript '{"type":"user","message":{"role":"user","content":"<bash-stdout>only synthetic</bash-stdout>"}}'
assert_eq "all-synthetic transcript yields empty (Warp falls back to its own title)" \
"" "$(extract_last_user_prompt "$TRANSCRIPT")"

write_transcript "$ASSISTANT"
assert_eq "no user entries yields empty" "" "$(extract_last_user_prompt "$TRANSCRIPT")"

assert_eq "missing transcript yields empty" "" "$(extract_last_user_prompt "/nonexistent/transcript.jsonl")"
assert_eq "empty path yields empty" "" "$(extract_last_user_prompt "")"

echo ""
echo "--- Assistant response extraction ---"

write_transcript "$HUMAN" "$ASSISTANT"
assert_eq "last assistant response returned" "Looking." "$(extract_last_assistant_response "$TRANSCRIPT")"
assert_eq "missing transcript yields empty response" "" "$(extract_last_assistant_response "/nonexistent/transcript.jsonl")"

# --- Summary ---

echo ""
Expand Down