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: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ Payloads include a protocol version negotiated between the plugin and Warp (`min

The plugin registers lifecycle hooks including:
- **SessionStart** — emits the plugin version and a welcome system message
- **Stop** — reads the transcript to extract your prompt and Claude's response, then sends a task-complete notification that also reports how many background tasks and scheduled wakeups are still pending, so Warp can tell a finished session from one waiting on background work
- **Stop** — reads the end of the transcript to extract your prompt and Claude's response, then sends a task-complete notification that also reports how many background tasks and scheduled wakeups are still pending, so Warp can tell a finished session from one waiting on background work
- **Notification** (`idle_prompt`, `agent_needs_input`) — fires when Claude is waiting for user input
- **PermissionRequest** — fires when Claude wants to run a tool, includes the tool name and a preview of its input
- **UserPromptSubmit** — fires when you submit a prompt, signaling the session is active again
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
15 changes: 9 additions & 6 deletions plugins/warp/scripts/legacy/on-stop.sh
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
# Sends a Warp notification when Claude completes a task

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$SCRIPT_DIR/../transcript-tail.sh"

# Read hook input from stdin
INPUT=$(cat)
Expand All @@ -15,16 +16,18 @@ MSG="Task completed"

# Try to extract prompt and response from the transcript (JSONL format)
if [ -n "$TRANSCRIPT_PATH" ] && [ -f "$TRANSCRIPT_PATH" ]; then
# Get the first user prompt
PROMPT=$(jq -rs '
[.[] | select(.type == "user")] | first | .message.content // empty
# Get the first user prompt. Stream records and stop at the first match,
# so memory stays at one record however long the transcript is.
PROMPT=$(jq -rn '
first(inputs | select(.type == "user")) | .message.content // empty
' "$TRANSCRIPT_PATH" 2>/dev/null)

# Get the last assistant response
RESPONSE=$(jq -rs '
# Get the last assistant response from a bounded tail of the transcript
RESPONSE_FILTER='
[.[] | select(.type == "assistant" and .message.content)] | last |
[.message.content[] | select(.type == "text") | .text] | join(" ")
' "$TRANSCRIPT_PATH" 2>/dev/null)
'
RESPONSE=$(transcript_tail "$TRANSCRIPT_PATH" "$TRANSCRIPT_TAIL_BYTES" | jq -rs "$RESPONSE_FILTER" 2>/dev/null)

if [ -n "$PROMPT" ] && [ -n "$RESPONSE" ]; then
# Truncate prompt to 50 chars
Expand Down
20 changes: 15 additions & 5 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/transcript-tail.sh"

# Read hook input from stdin
INPUT=$(cat)
Expand All @@ -36,7 +37,10 @@ if [ -n "$TRANSCRIPT_PATH" ] && [ -f "$TRANSCRIPT_PATH" ]; then
# 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 '
#
# Both values sit near the end, so read a bounded tail instead of the whole
# transcript, and retry with a larger one only when no prompt is in the first.
QUERY_FILTER='
[
.[] | select(.type == "user") |
if .message.content | type == "string" then .
Expand All @@ -48,13 +52,19 @@ if [ -n "$TRANSCRIPT_PATH" ] && [ -f "$TRANSCRIPT_PATH" ]; then
then [.message.content[] | select(.type == "text") | .text] | join(" ")
else .message.content // empty
end
' "$TRANSCRIPT_PATH" 2>/dev/null)
'
QUERY=$(transcript_tail "$TRANSCRIPT_PATH" "$TRANSCRIPT_TAIL_BYTES" | jq -rs "$QUERY_FILTER" 2>/dev/null)
if [ -z "$QUERY" ] && transcript_larger_than "$TRANSCRIPT_PATH" "$TRANSCRIPT_TAIL_BYTES"; then
QUERY=$(transcript_tail "$TRANSCRIPT_PATH" "$TRANSCRIPT_TAIL_MAX_BYTES" | jq -rs "$QUERY_FILTER" 2>/dev/null)
fi

# Get the last assistant response
RESPONSE=$(jq -rs '
# Get the last assistant response. The last assistant record is always at
# the very end, so the first window is enough.
RESPONSE_FILTER='
[.[] | select(.type == "assistant" and .message.content)] | last |
[.message.content[] | select(.type == "text") | .text] | join(" ")
' "$TRANSCRIPT_PATH" 2>/dev/null)
'
RESPONSE=$(transcript_tail "$TRANSCRIPT_PATH" "$TRANSCRIPT_TAIL_BYTES" | jq -rs "$RESPONSE_FILTER" 2>/dev/null)

# Truncate for notification display
if [ -n "$QUERY" ] && [ ${#QUERY} -gt 200 ]; then
Expand Down
33 changes: 33 additions & 0 deletions plugins/warp/scripts/transcript-tail.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
#!/bin/bash
# Reads only the end of a Claude Code transcript.
#
# Transcripts grow for the whole session and can reach gigabytes. Slurping the
# whole file with `jq -s` costs about three times its size in memory at every
# turn end, while the last prompt and response sit within the final few
# hundred KB. Reading a bounded tail keeps the Stop hook's memory flat.
#
# Usage:
# source "$SCRIPT_DIR/transcript-tail.sh"
# transcript_tail "$TRANSCRIPT_PATH" "$TRANSCRIPT_TAIL_BYTES" | jq -rs '...'

# Window to read, and the larger one to retry with when the last prompt
# is further back (a long autonomous turn with big tool results).
TRANSCRIPT_TAIL_BYTES=4194304
TRANSCRIPT_TAIL_MAX_BYTES=33554432

# Prints the last $2 bytes of transcript $1, dropping the partial line the cut
# lands in. Transcripts are JSONL, so every remaining line is a whole record.
transcript_tail() {
local path="$1" bytes="$2"
if [ "$(wc -c < "$path")" -gt "$bytes" ]; then
tail -c "$bytes" "$path" | tail -n +2
else
cat "$path"
fi
}

# Returns 0 if transcript $1 is larger than $2 bytes, so a bigger window could
# find something the smaller one missed.
transcript_larger_than() {
[ "$(wc -c < "$1")" -gt "$2" ]
}
53 changes: 52 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,57 @@ 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 "--- Transcript tail ---"

source "$SCRIPT_DIR/transcript-tail.sh"
TAIL_TMP=$(mktemp -d)
trap 'rm -rf "$TAIL_TMP"' EXIT

prompt_line() { jq -nc --arg t "$1" '{type:"user",message:{role:"user",content:$t}}'; }
reply_line() { jq -nc --arg t "$1" '{type:"assistant",message:{role:"assistant",content:[{type:"text",text:$t}]}}'; }
# About 1 MB of tool results, the bulk of a long session's transcript.
FILLER=$(jq -nc --arg c "$(head -c 1000 /dev/zero | tr '\0' x)" \
'{type:"user",message:{role:"user",content:[{type:"tool_result",tool_use_id:"t1",content:$c}]}}')
filler_mb() { yes "$FILLER" | head -n $(($1 * 1000)); }

# Runs on-stop.sh on a transcript and prints the structured payload it emits.
run_stop_hook() {
local seq
seq=$(jq -nc --arg p "$1" '{session_id:"s1",cwd:"/tmp/proj",transcript_path:$p}' \
| WARP_CLI_AGENT_PROTOCOL_VERSION=1 WARP_CLIENT_VERSION=v0.2026.09.30.08.29.stable_01 \
CLAUDE_CODE_VERSION=2.1.283 bash "$SCRIPT_DIR/on-stop.sh" 2>/dev/null \
| jq -r '.terminalSequence // empty' 2>/dev/null)
seq=${seq#*warp://cli-agent;}
printf '%s' "${seq%$'\a'}"
}

{ prompt_line "first prompt"; reply_line "first reply"; } > "$TAIL_TMP/small.jsonl"
assert_eq "small transcript is read whole" "2" "$(transcript_tail "$TAIL_TMP/small.jsonl" "$TRANSCRIPT_TAIL_BYTES" | wc -l | tr -d ' ')"

{ prompt_line "old prompt"; filler_mb 6; prompt_line "recent prompt"; reply_line "recent reply"; } > "$TAIL_TMP/large.jsonl"
FIRST_LINE=$(transcript_tail "$TAIL_TMP/large.jsonl" "$TRANSCRIPT_TAIL_BYTES" | head -n 1)
echo "$FIRST_LINE" | jq -e . >/dev/null 2>&1
assert_eq "tail starts on a whole record" "0" "$?"
TAIL_SIZE=$(transcript_tail "$TAIL_TMP/large.jsonl" "$TRANSCRIPT_TAIL_BYTES" | wc -c | tr -d ' ')
assert_eq "tail stays within the window" "true" "$([ "$TAIL_SIZE" -le "$TRANSCRIPT_TAIL_BYTES" ] && echo true || echo false)"
PAYLOAD=$(run_stop_hook "$TAIL_TMP/large.jsonl")
assert_json_field "stop query from the tail" "$PAYLOAD" ".query" "recent prompt"
assert_json_field "stop response from the tail" "$PAYLOAD" ".response" "recent reply"

{ prompt_line "far prompt"; filler_mb 10; reply_line "far reply"; } > "$TAIL_TMP/far.jsonl"
assert_json_field "prompt beyond the first window is found" "$(run_stop_hook "$TAIL_TMP/far.jsonl")" ".query" "far prompt"

# The Stop hook must not hold the whole transcript in memory. Linux enforces
# ulimit -v; skip where it does not (macOS).
if (ulimit -v 131072) 2>/dev/null; then
{ filler_mb 100; prompt_line "bounded prompt"; reply_line "bounded reply"; } > "$TAIL_TMP/huge.jsonl"
PAYLOAD=$(ulimit -v 131072; run_stop_hook "$TAIL_TMP/huge.jsonl")
assert_json_field "100 MB transcript fits in 128 MB" "$PAYLOAD" ".query" "bounded prompt"
else
echo " - memory bound check skipped (ulimit -v not enforced here)"
fi

# --- Summary ---

echo ""
Expand Down