diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json
index 8df5d11..a265fe6 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": "1.1.0",
+ "version": "2.0.0",
"category": "productivity",
"tags": ["notifications", "terminal", "warp"]
}
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
new file mode 100644
index 0000000..dd34d55
--- /dev/null
+++ b/.github/workflows/test.yml
@@ -0,0 +1,22 @@
+name: Plugin Tests
+on:
+ pull_request:
+ push:
+ branches: [main]
+
+jobs:
+ plugin-tests:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - name: Run tests
+ # Auto-discovers and runs all test-*.sh scripts under any tests/ directory.
+ # To add a new test, just drop a test-*.sh file in a tests/ folder.
+ run: |
+ shopt -s globstar nullglob
+ failed=0
+ for f in **/tests/test-*.sh; do
+ echo "--- $f ---"
+ bash "$f" || failed=1
+ done
+ exit $failed
diff --git a/README.md b/README.md
index d96bf27..6bc7f70 100644
--- a/README.md
+++ b/README.md
@@ -8,14 +8,18 @@ Official [Warp](https://warp.dev) terminal integration for [Claude Code](https:/
Get native Warp notifications when Claude Code:
- **Completes a task** — with a summary showing your prompt and Claude's response
-- **Needs your input** — when Claude requires approval or has a question
+- **Needs your input** — when Claude has been idle and is waiting for you
+- **Requests permission** — when Claude wants to run a tool and needs your approval
Notifications appear in Warp's notification center and as system notifications, so you can context-switch while Claude works and get alerted when attention is needed.
-**Example notification:**
-```
-"what's 1+1" → 2
-```
+### 📡 Session Status
+
+The plugin keeps Warp informed of Claude's current state by emitting structured events on every session transition:
+- **Prompt submitted** — you sent a prompt, Claude is working
+- **Tool completed** — a tool call finished, Claude is back to running
+
+This powers Warp's inline status indicators for Claude Code sessions.
## Installation
@@ -27,9 +31,9 @@ Notifications appear in Warp's notification center and as system notifications,
/plugin install warp@claude-code-warp
```
-> ⚠️ **Important**: After installing, **restart Claude Code** for notifications to activate.
+> ⚠️ **Important**: After installing, **restart Claude Code or run /reload-plugins** for the plugin to activate.
-Once restarted, you'll see a confirmation message and notifications will appear automatically when Claude completes tasks.
+Once restarted, you'll see a confirmation message and notifications will appear automatically.
## Requirements
@@ -39,27 +43,26 @@ Once restarted, you'll see a confirmation message and notifications will appear
## How It Works
-This plugin uses Warp's [pluggable notifications](https://docs.warp.dev/features/notifications) feature via OSC escape sequences. When Claude Code triggers a hook event, the plugin:
+The plugin communicates with Warp via OSC 777 escape sequences. Each hook script builds a structured JSON payload (via `build-payload.sh`) and sends it to `warp://cli-agent`, where Warp parses it to drive notifications and session UI.
-1. Reads the session transcript to extract your original prompt and Claude's response
-2. Formats a concise notification message
-3. Sends an OSC 777 escape sequence to Warp, which displays a native notification
+Payloads include a protocol version negotiated between the plugin and Warp (`min(plugin_version, warp_version)`), the session ID, working directory, and event-specific fields.
-The plugin registers three hooks:
-- **SessionStart** — shows a welcome message confirming the plugin is active
-- **Stop** — fires when Claude finishes responding
-- **Notification** — fires when Claude needs user input
+The plugin registers six hooks:
+- **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
+- **Notification** (`idle_prompt`) — fires when Claude has been idle and needs your 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
+- **PostToolUse** — fires when a tool call completes, signaling the session is no longer blocked
-## Configuration
+### 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.
-Notifications work out of the box. To customize Warp's notification behavior (sounds, system notifications, etc.), see [Warp's notification settings](https://docs.warp.dev/features/notifications).
-## Roadmap
+## Configuration
-Future Warp integrations planned:
-- Warp AI context sharing
-- Warp Drive integration for sharing Claude Code configurations
-- Custom slash commands
+Notifications work out of the box. To customize Warp's notification behavior (sounds, system notifications, etc.), see [Warp's notification settings](https://docs.warp.dev/features/notifications).
## Uninstall
@@ -68,9 +71,10 @@ Future Warp integrations planned:
/plugin marketplace remove claude-code-warp
```
-## Contributing
+## Versioning
-Contributions welcome! Please open an issue or PR on [GitHub](https://github.com/warpdotdev/claude-code-warp).
+The plugin version in `plugins/warp/.claude-plugin/plugin.json` is checked by the Warp client to detect outdated installations.
+When bumping the version here, also update `MINIMUM_PLUGIN_VERSION` in the Warp client.
## License
diff --git a/plugins/warp/.claude-plugin/plugin.json b/plugins/warp/.claude-plugin/plugin.json
index 97b8831..d7799cd 100644
--- a/plugins/warp/.claude-plugin/plugin.json
+++ b/plugins/warp/.claude-plugin/plugin.json
@@ -1,10 +1,11 @@
{
"name": "warp",
"description": "Warp terminal integration for Claude Code - native notifications, and more to come",
- "version": "1.1.0",
+ "version": "2.0.0",
"author": {
"name": "Warp",
"url": "https://warp.dev"
},
- "homepage": "https://github.com/warpdotdev/claude-code-warp"
+ "homepage": "https://github.com/warpdotdev/claude-code-warp",
+ "strict": false
}
diff --git a/plugins/warp/hooks/hooks.json b/plugins/warp/hooks/hooks.json
index 6e44d07..f2597ee 100644
--- a/plugins/warp/hooks/hooks.json
+++ b/plugins/warp/hooks/hooks.json
@@ -24,7 +24,7 @@
],
"Notification": [
{
- "matcher": "*",
+ "matcher": "idle_prompt",
"hooks": [
{
"type": "command",
@@ -32,6 +32,36 @@
}
]
}
+ ],
+ "PermissionRequest": [
+ {
+ "hooks": [
+ {
+ "type": "command",
+ "command": "${CLAUDE_PLUGIN_ROOT}/scripts/on-permission-request.sh"
+ }
+ ]
+ }
+ ],
+ "UserPromptSubmit": [
+ {
+ "hooks": [
+ {
+ "type": "command",
+ "command": "${CLAUDE_PLUGIN_ROOT}/scripts/on-prompt-submit.sh"
+ }
+ ]
+ }
+ ],
+ "PostToolUse": [
+ {
+ "hooks": [
+ {
+ "type": "command",
+ "command": "${CLAUDE_PLUGIN_ROOT}/scripts/on-post-tool-use.sh"
+ }
+ ]
+ }
]
}
}
diff --git a/plugins/warp/scripts/build-payload.sh b/plugins/warp/scripts/build-payload.sh
new file mode 100644
index 0000000..9ad610e
--- /dev/null
+++ b/plugins/warp/scripts/build-payload.sh
@@ -0,0 +1,58 @@
+#!/bin/bash
+# Builds a structured JSON notification payload for warp://cli-agent.
+#
+# Usage: source this file, then call build_payload with event-specific fields.
+#
+# Example:
+# source "$(dirname "${BASH_SOURCE[0]}")/build-payload.sh"
+# BODY=$(build_payload "$INPUT" "stop" \
+# --arg query "$QUERY" \
+# --arg response "$RESPONSE" \
+# --arg transcript_path "$TRANSCRIPT_PATH")
+#
+# The function extracts common fields (session_id, cwd, project) from the
+# hook's stdin JSON (passed as $1), then merges any extra jq args you pass.
+
+# The current protocol version this plugin knows how to produce.
+PLUGIN_CURRENT_PROTOCOL_VERSION=1
+
+# Negotiate the protocol version with Warp.
+# Uses min(plugin_current, warp_declared), falling back to 1 if Warp doesn't advertise a version.
+negotiate_protocol_version() {
+ local warp_version="${WARP_CLI_AGENT_PROTOCOL_VERSION:-1}"
+ if [ "$warp_version" -lt "$PLUGIN_CURRENT_PROTOCOL_VERSION" ] 2>/dev/null; then
+ echo "$warp_version"
+ else
+ echo "$PLUGIN_CURRENT_PROTOCOL_VERSION"
+ fi
+}
+
+build_payload() {
+ local input="$1"
+ local event="$2"
+ shift 2
+
+ local protocol_version
+ protocol_version=$(negotiate_protocol_version)
+
+ # Extract common fields from the hook input
+ local session_id cwd project
+ session_id=$(echo "$input" | jq -r '.session_id // empty' 2>/dev/null)
+ cwd=$(echo "$input" | jq -r '.cwd // empty' 2>/dev/null)
+ project=""
+ if [ -n "$cwd" ]; then
+ project=$(basename "$cwd")
+ fi
+
+ # Build the payload: common fields + any extra args passed by the caller.
+ # Extra args should be jq flag pairs like: --arg key "value" or --argjson key '{"a":1}'
+ jq -nc \
+ --argjson v "$protocol_version" \
+ --arg agent "claude" \
+ --arg event "$event" \
+ --arg session_id "$session_id" \
+ --arg cwd "$cwd" \
+ --arg project "$project" \
+ "$@" \
+ '{v:$v, agent:$agent, event:$event, session_id:$session_id, cwd:$cwd, project:$project} + $ARGS.named'
+}
diff --git a/plugins/warp/scripts/legacy/on-notification.sh b/plugins/warp/scripts/legacy/on-notification.sh
new file mode 100755
index 0000000..378923e
--- /dev/null
+++ b/plugins/warp/scripts/legacy/on-notification.sh
@@ -0,0 +1,14 @@
+#!/bin/bash
+# Hook script for Claude Code Notification event
+# Sends a Warp notification when Claude needs user input
+
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+
+# Read hook input from stdin
+INPUT=$(cat)
+
+# Extract the notification message
+MSG=$(echo "$INPUT" | jq -r '.message // "Input needed"' 2>/dev/null)
+[ -z "$MSG" ] && MSG="Input needed"
+
+"$SCRIPT_DIR/warp-notify.sh" "Claude Code" "$MSG"
diff --git a/plugins/warp/scripts/legacy/on-session-start.sh b/plugins/warp/scripts/legacy/on-session-start.sh
new file mode 100755
index 0000000..4d5e7fb
--- /dev/null
+++ b/plugins/warp/scripts/legacy/on-session-start.sh
@@ -0,0 +1,20 @@
+#!/bin/bash
+# Hook script for Claude Code SessionStart event
+# Shows welcome message and Warp detection status
+
+# Check if running in Warp terminal
+if [ "$TERM_PROGRAM" = "WarpTerminal" ]; then
+ # Running in Warp - notifications will work
+ cat << 'EOF'
+{
+ "systemMessage": "🔔 Warp plugin active. You'll receive native Warp notifications when tasks complete or input is needed."
+}
+EOF
+else
+ # Not running in Warp - suggest installing
+ cat << 'EOF'
+{
+ "systemMessage": "ℹ️ Warp plugin installed but you're not running in Warp terminal. Install Warp (https://warp.dev) to get native notifications when Claude completes tasks or needs input."
+}
+EOF
+fi
diff --git a/plugins/warp/scripts/legacy/on-stop.sh b/plugins/warp/scripts/legacy/on-stop.sh
new file mode 100755
index 0000000..2a45dd9
--- /dev/null
+++ b/plugins/warp/scripts/legacy/on-stop.sh
@@ -0,0 +1,48 @@
+#!/bin/bash
+# Hook script for Claude Code Stop event
+# Sends a Warp notification when Claude completes a task
+
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+
+# Read hook input from stdin
+INPUT=$(cat)
+
+# Extract transcript path from the hook input
+TRANSCRIPT_PATH=$(echo "$INPUT" | jq -r '.transcript_path // empty' 2>/dev/null)
+
+# Default message
+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
+ ' "$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)
+
+ if [ -n "$PROMPT" ] && [ -n "$RESPONSE" ]; then
+ # Truncate prompt to 50 chars
+ if [ ${#PROMPT} -gt 50 ]; then
+ PROMPT="${PROMPT:0:47}..."
+ fi
+ # Truncate response to 120 chars
+ if [ ${#RESPONSE} -gt 120 ]; then
+ RESPONSE="${RESPONSE:0:117}..."
+ fi
+ MSG="\"${PROMPT}\" → ${RESPONSE}"
+ elif [ -n "$RESPONSE" ]; then
+ # Fallback to just response if no prompt found
+ if [ ${#RESPONSE} -gt 175 ]; then
+ RESPONSE="${RESPONSE:0:172}..."
+ fi
+ MSG="$RESPONSE"
+ fi
+fi
+
+"$SCRIPT_DIR/warp-notify.sh" "Claude Code" "$MSG"
diff --git a/plugins/warp/scripts/legacy/warp-notify.sh b/plugins/warp/scripts/legacy/warp-notify.sh
new file mode 100755
index 0000000..6ca0588
--- /dev/null
+++ b/plugins/warp/scripts/legacy/warp-notify.sh
@@ -0,0 +1,10 @@
+#!/bin/bash
+# Warp notification utility using OSC escape sequences
+# Usage: warp-notify.sh
+
+TITLE="${1:-Notification}"
+BODY="${2:-}"
+
+# OSC 777 format: \033]777;notify;;\007
+# Write directly to /dev/tty to ensure it reaches the terminal
+printf '\033]777;notify;%s;%s\007' "$TITLE" "$BODY" > /dev/tty 2>/dev/null || true
diff --git a/plugins/warp/scripts/on-notification.sh b/plugins/warp/scripts/on-notification.sh
index 378923e..8518ac1 100755
--- a/plugins/warp/scripts/on-notification.sh
+++ b/plugins/warp/scripts/on-notification.sh
@@ -1,14 +1,27 @@
#!/bin/bash
-# Hook script for Claude Code Notification event
-# Sends a Warp notification when Claude needs user input
+# Hook script for Claude Code Notification event (idle_prompt only)
+# Sends a structured Warp notification when Claude has been idle
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+source "$SCRIPT_DIR/should-use-structured.sh"
+
+# Legacy fallback for old Warp versions
+if ! should_use_structured; then
+ [ "$TERM_PROGRAM" = "WarpTerminal" ] && exec "$SCRIPT_DIR/legacy/on-notification.sh"
+ exit 0
+fi
+
+source "$SCRIPT_DIR/build-payload.sh"
# Read hook input from stdin
INPUT=$(cat)
-# Extract the notification message
+# Extract notification-specific fields
+NOTIF_TYPE=$(echo "$INPUT" | jq -r '.notification_type // "unknown"' 2>/dev/null)
MSG=$(echo "$INPUT" | jq -r '.message // "Input needed"' 2>/dev/null)
[ -z "$MSG" ] && MSG="Input needed"
-"$SCRIPT_DIR/warp-notify.sh" "Claude Code" "$MSG"
+BODY=$(build_payload "$INPUT" "$NOTIF_TYPE" \
+ --arg summary "$MSG")
+
+"$SCRIPT_DIR/warp-notify.sh" "warp://cli-agent" "$BODY"
diff --git a/plugins/warp/scripts/on-permission-request.sh b/plugins/warp/scripts/on-permission-request.sh
new file mode 100755
index 0000000..7d46ed2
--- /dev/null
+++ b/plugins/warp/scripts/on-permission-request.sh
@@ -0,0 +1,39 @@
+#!/bin/bash
+# Hook script for Claude Code PermissionRequest event
+# Sends a structured Warp notification when Claude needs permission to run a tool
+
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+source "$SCRIPT_DIR/should-use-structured.sh"
+
+# No legacy equivalent for this hook
+if ! should_use_structured; then
+ exit 0
+fi
+
+source "$SCRIPT_DIR/build-payload.sh"
+
+# Read hook input from stdin
+INPUT=$(cat)
+
+# 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)
+# Fallback to empty object if jq failed or returned empty
+[ -z "$TOOL_INPUT" ] && TOOL_INPUT='{}'
+
+# Build a human-readable summary
+TOOL_PREVIEW=$(echo "$INPUT" | jq -r '(.tool_input | if .command then .command elif .file_path then .file_path else (tostring | .[0:80]) end) // ""' 2>/dev/null)
+SUMMARY="Wants to run $TOOL_NAME"
+if [ -n "$TOOL_PREVIEW" ]; then
+ if [ ${#TOOL_PREVIEW} -gt 120 ]; then
+ TOOL_PREVIEW="${TOOL_PREVIEW:0:117}..."
+ fi
+ SUMMARY="$SUMMARY: $TOOL_PREVIEW"
+fi
+
+BODY=$(build_payload "$INPUT" "permission_request" \
+ --arg summary "$SUMMARY" \
+ --arg tool_name "$TOOL_NAME" \
+ --argjson tool_input "$TOOL_INPUT")
+
+"$SCRIPT_DIR/warp-notify.sh" "warp://cli-agent" "$BODY"
diff --git a/plugins/warp/scripts/on-post-tool-use.sh b/plugins/warp/scripts/on-post-tool-use.sh
new file mode 100755
index 0000000..568e5b3
--- /dev/null
+++ b/plugins/warp/scripts/on-post-tool-use.sh
@@ -0,0 +1,24 @@
+#!/bin/bash
+# Hook script for Claude Code PostToolUse event
+# Sends a structured Warp notification after a tool call completes,
+# transitioning the session status from Blocked back to Running.
+
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+source "$SCRIPT_DIR/should-use-structured.sh"
+
+# No legacy equivalent for this hook
+if ! should_use_structured; then
+ exit 0
+fi
+
+source "$SCRIPT_DIR/build-payload.sh"
+
+# Read hook input from stdin
+INPUT=$(cat)
+
+TOOL_NAME=$(echo "$INPUT" | jq -r '.tool_name // empty' 2>/dev/null)
+
+BODY=$(build_payload "$INPUT" "tool_complete" \
+ --arg tool_name "$TOOL_NAME")
+
+"$SCRIPT_DIR/warp-notify.sh" "warp://cli-agent" "$BODY"
diff --git a/plugins/warp/scripts/on-prompt-submit.sh b/plugins/warp/scripts/on-prompt-submit.sh
new file mode 100755
index 0000000..0a8a55e
--- /dev/null
+++ b/plugins/warp/scripts/on-prompt-submit.sh
@@ -0,0 +1,28 @@
+#!/bin/bash
+# Hook script for Claude Code UserPromptSubmit event
+# Sends a structured Warp notification when the user submits a prompt,
+# transitioning the session status from idle/blocked back to running.
+
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+source "$SCRIPT_DIR/should-use-structured.sh"
+
+# No legacy equivalent for this hook
+if ! should_use_structured; then
+ exit 0
+fi
+
+source "$SCRIPT_DIR/build-payload.sh"
+
+# Read hook input from stdin
+INPUT=$(cat)
+
+# Extract the user's prompt
+QUERY=$(echo "$INPUT" | jq -r '.prompt // empty' 2>/dev/null)
+if [ -n "$QUERY" ] && [ ${#QUERY} -gt 200 ]; then
+ QUERY="${QUERY:0:197}..."
+fi
+
+BODY=$(build_payload "$INPUT" "prompt_submit" \
+ --arg query "$QUERY")
+
+"$SCRIPT_DIR/warp-notify.sh" "warp://cli-agent" "$BODY"
diff --git a/plugins/warp/scripts/on-session-start.sh b/plugins/warp/scripts/on-session-start.sh
index 4d5e7fb..b907979 100755
--- a/plugins/warp/scripts/on-session-start.sh
+++ b/plugins/warp/scripts/on-session-start.sh
@@ -1,20 +1,32 @@
#!/bin/bash
# Hook script for Claude Code SessionStart event
-# Shows welcome message and Warp detection status
+# Shows welcome message, Warp detection status, and emits plugin version
-# Check if running in Warp terminal
-if [ "$TERM_PROGRAM" = "WarpTerminal" ]; then
- # Running in Warp - notifications will work
- cat << 'EOF'
-{
- "systemMessage": "🔔 Warp plugin active. You'll receive native Warp notifications when tasks complete or input is needed."
-}
-EOF
-else
- # Not running in Warp - suggest installing
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+source "$SCRIPT_DIR/should-use-structured.sh"
+
+# Legacy fallback for old Warp versions
+if ! should_use_structured; then
+ exec "$SCRIPT_DIR/legacy/on-session-start.sh"
+fi
+
+if ! command -v jq &>/dev/null; then
cat << 'EOF'
{
- "systemMessage": "ℹ️ Warp plugin installed but you're not running in Warp terminal. Install Warp (https://warp.dev) to get native notifications when Claude completes tasks or needs input."
+ "systemMessage": "⚠️ Warp notifications require jq — install it with your system package manager (e.g. brew install jq, apt install jq)"
}
EOF
+ exit 0
fi
+source "$SCRIPT_DIR/build-payload.sh"
+
+# Read hook input from stdin
+INPUT=$(cat)
+
+# Read plugin version from plugin.json
+PLUGIN_VERSION=$(jq -r '.version // "unknown"' "$SCRIPT_DIR/../.claude-plugin/plugin.json" 2>/dev/null)
+
+# Emit structured notification with plugin version so Warp can track it
+BODY=$(build_payload "$INPUT" "session_start" \
+ --arg plugin_version "$PLUGIN_VERSION")
+"$SCRIPT_DIR/warp-notify.sh" "warp://cli-agent" "$BODY"
diff --git a/plugins/warp/scripts/on-stop.sh b/plugins/warp/scripts/on-stop.sh
index 2a45dd9..4163bb9 100755
--- a/plugins/warp/scripts/on-stop.sh
+++ b/plugins/warp/scripts/on-stop.sh
@@ -1,48 +1,73 @@
#!/bin/bash
# Hook script for Claude Code Stop event
-# Sends a Warp notification when Claude completes a task
+# Sends a structured Warp notification when Claude completes a task
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+source "$SCRIPT_DIR/should-use-structured.sh"
+
+# Legacy fallback for old Warp versions
+if ! should_use_structured; then
+ [ "$TERM_PROGRAM" = "WarpTerminal" ] && exec "$SCRIPT_DIR/legacy/on-stop.sh"
+ exit 0
+fi
+
+source "$SCRIPT_DIR/build-payload.sh"
# Read hook input from stdin
INPUT=$(cat)
-# Extract transcript path from the hook input
-TRANSCRIPT_PATH=$(echo "$INPUT" | jq -r '.transcript_path // empty' 2>/dev/null)
-
-# Default message
-MSG="Task completed"
+# Skip if a stop hook is already active (prevents double-notification)
+STOP_HOOK_ACTIVE=$(echo "$INPUT" | jq -r '.stop_hook_active // false' 2>/dev/null)
+if [ "$STOP_HOOK_ACTIVE" = "true" ]; then
+ exit 0
+fi
-# Try to extract prompt and response from the transcript (JSONL format)
+# 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.
+TRANSCRIPT_PATH=$(echo "$INPUT" | jq -r '.transcript_path // empty' 2>/dev/null)
+sleep 0.3
+QUERY=""
+RESPONSE=""
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 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)
-
- if [ -n "$PROMPT" ] && [ -n "$RESPONSE" ]; then
- # Truncate prompt to 50 chars
- if [ ${#PROMPT} -gt 50 ]; then
- PROMPT="${PROMPT:0:47}..."
- fi
- # Truncate response to 120 chars
- if [ ${#RESPONSE} -gt 120 ]; then
- RESPONSE="${RESPONSE:0:117}..."
- fi
- MSG="\"${PROMPT}\" → ${RESPONSE}"
- elif [ -n "$RESPONSE" ]; then
- # Fallback to just response if no prompt found
- if [ ${#RESPONSE} -gt 175 ]; then
- RESPONSE="${RESPONSE:0:172}..."
- fi
- MSG="$RESPONSE"
+
+ # Truncate for notification display
+ if [ -n "$QUERY" ] && [ ${#QUERY} -gt 200 ]; then
+ QUERY="${QUERY:0:197}..."
+ fi
+ if [ -n "$RESPONSE" ] && [ ${#RESPONSE} -gt 200 ]; then
+ RESPONSE="${RESPONSE:0:197}..."
fi
fi
-"$SCRIPT_DIR/warp-notify.sh" "Claude Code" "$MSG"
+BODY=$(build_payload "$INPUT" "stop" \
+ --arg query "$QUERY" \
+ --arg response "$RESPONSE" \
+ --arg transcript_path "$TRANSCRIPT_PATH")
+
+"$SCRIPT_DIR/warp-notify.sh" "warp://cli-agent" "$BODY"
diff --git a/plugins/warp/scripts/should-use-structured.sh b/plugins/warp/scripts/should-use-structured.sh
new file mode 100644
index 0000000..13360e0
--- /dev/null
+++ b/plugins/warp/scripts/should-use-structured.sh
@@ -0,0 +1,44 @@
+#!/bin/bash
+# Determines whether the current Warp build supports structured CLI agent notifications.
+#
+# Usage:
+# source "$SCRIPT_DIR/should-use-structured.sh"
+# if should_use_structured; then
+# # ... send structured notification
+# else
+# # ... legacy fallback or exit
+# fi
+#
+# Returns 0 (true) when structured notifications are safe to use, 1 (false) otherwise.
+
+# Last known Warp release per channel that unconditionally set
+# WARP_CLI_AGENT_PROTOCOL_VERSION without gating it behind the
+# HOANotifications feature flag. These builds advertise protocol
+# support but can't actually render structured notifications.
+LAST_BROKEN_DEV=""
+LAST_BROKEN_STABLE="v0.2026.03.25.08.24.stable_05"
+LAST_BROKEN_PREVIEW="v0.2026.03.25.08.24.preview_05"
+
+should_use_structured() {
+ # No protocol version advertised → Warp doesn't know about structured notifications.
+ [ -z "${WARP_CLI_AGENT_PROTOCOL_VERSION:-}" ] && return 1
+
+ # No client version available → can't verify this build has the fix.
+ # (This catches the broken prod release before this was set, but after WARP_CLI_AGENT_PROTOCOL_VERSION was set without a flag check.)
+ [ -z "${WARP_CLIENT_VERSION:-}" ] && return 1
+
+ # Check whether this version is at or before the last broken release for its channel.
+ local threshold=""
+ case "$WARP_CLIENT_VERSION" in
+ *dev*) threshold="$LAST_BROKEN_DEV" ;;
+ *stable*) threshold="$LAST_BROKEN_STABLE" ;;
+ *preview*) threshold="$LAST_BROKEN_PREVIEW" ;;
+ esac
+
+ # If we matched a channel and the version is <= the broken threshold, fall back.
+ if [ -n "$threshold" ] && [[ ! "$WARP_CLIENT_VERSION" > "$threshold" ]]; then
+ return 1
+ fi
+
+ return 0
+}
diff --git a/plugins/warp/scripts/warp-notify.sh b/plugins/warp/scripts/warp-notify.sh
index 6ca0588..523f873 100755
--- a/plugins/warp/scripts/warp-notify.sh
+++ b/plugins/warp/scripts/warp-notify.sh
@@ -1,6 +1,17 @@
#!/bin/bash
# Warp notification utility using OSC escape sequences
# Usage: warp-notify.sh
+#
+# For structured Warp notifications, title should be "warp://cli-agent"
+# and body should be a JSON string matching the cli-agent notification schema.
+
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+source "$SCRIPT_DIR/should-use-structured.sh"
+
+# Only emit notifications when we've confirmed the Warp build can render them.
+if ! should_use_structured; then
+ exit 0
+fi
TITLE="${1:-Notification}"
BODY="${2:-}"
diff --git a/plugins/warp/tests/test-hooks.sh b/plugins/warp/tests/test-hooks.sh
new file mode 100755
index 0000000..220d3ae
--- /dev/null
+++ b/plugins/warp/tests/test-hooks.sh
@@ -0,0 +1,240 @@
+#!/bin/bash
+# Tests for the Warp Claude Code plugin hook scripts.
+#
+# Validates that each hook script produces correctly structured JSON payloads
+# by piping mock Claude Code hook input into the scripts and checking the output.
+#
+# Usage: ./tests/test-hooks.sh
+#
+# Since the hook scripts write OSC sequences to /dev/tty (not stdout),
+# we test build-payload.sh directly — it's the shared JSON construction logic
+# that all hook scripts use.
+
+set -uo pipefail
+
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../scripts" && pwd)"
+source "$SCRIPT_DIR/build-payload.sh"
+
+PASSED=0
+FAILED=0
+
+# --- Test helpers ---
+
+assert_eq() {
+ local test_name="$1"
+ local expected="$2"
+ local actual="$3"
+ if [ "$expected" = "$actual" ]; then
+ echo " ✓ $test_name"
+ PASSED=$((PASSED + 1))
+ else
+ echo " ✗ $test_name"
+ echo " expected: $expected"
+ echo " actual: $actual"
+ FAILED=$((FAILED + 1))
+ fi
+}
+
+assert_json_field() {
+ local test_name="$1"
+ local json="$2"
+ local field="$3"
+ local expected="$4"
+ local actual
+ actual=$(echo "$json" | jq -r "$field" 2>/dev/null)
+ assert_eq "$test_name" "$expected" "$actual"
+}
+
+# --- Tests ---
+
+echo "=== build-payload.sh ==="
+
+echo ""
+echo "--- Common fields ---"
+PAYLOAD=$(build_payload '{"session_id":"sess-123","cwd":"/Users/alice/my-project"}' "stop")
+assert_json_field "v is 1" "$PAYLOAD" ".v" "1"
+assert_json_field "agent is claude" "$PAYLOAD" ".agent" "claude"
+assert_json_field "event is stop" "$PAYLOAD" ".event" "stop"
+assert_json_field "session_id extracted" "$PAYLOAD" ".session_id" "sess-123"
+assert_json_field "cwd extracted" "$PAYLOAD" ".cwd" "/Users/alice/my-project"
+assert_json_field "project is basename of cwd" "$PAYLOAD" ".project" "my-project"
+
+echo ""
+echo "--- Common fields with missing data ---"
+PAYLOAD=$(build_payload '{}' "stop")
+assert_json_field "empty session_id" "$PAYLOAD" ".session_id" ""
+assert_json_field "empty cwd" "$PAYLOAD" ".cwd" ""
+assert_json_field "empty project" "$PAYLOAD" ".project" ""
+
+echo ""
+echo "--- Extra args are merged ---"
+PAYLOAD=$(build_payload '{"session_id":"s1","cwd":"/tmp/proj"}' "stop" \
+ --arg query "hello" \
+ --arg response "world")
+assert_json_field "query merged" "$PAYLOAD" ".query" "hello"
+assert_json_field "response merged" "$PAYLOAD" ".response" "world"
+assert_json_field "common fields still present" "$PAYLOAD" ".session_id" "s1"
+
+echo ""
+echo "--- Stop event ---"
+PAYLOAD=$(build_payload '{"session_id":"s1","cwd":"/tmp/proj"}' "stop" \
+ --arg query "write a haiku" \
+ --arg response "Memory is safe, the borrow checker stands guard" \
+ --arg transcript_path "/tmp/transcript.jsonl")
+assert_json_field "event is stop" "$PAYLOAD" ".event" "stop"
+assert_json_field "query present" "$PAYLOAD" ".query" "write a haiku"
+assert_json_field "response present" "$PAYLOAD" ".response" "Memory is safe, the borrow checker stands guard"
+assert_json_field "transcript_path present" "$PAYLOAD" ".transcript_path" "/tmp/transcript.jsonl"
+
+echo ""
+echo "--- Permission request event ---"
+PAYLOAD=$(build_payload '{"session_id":"s1","cwd":"/tmp/proj"}' "permission_request" \
+ --arg summary "Wants to run Bash: rm -rf /tmp" \
+ --arg tool_name "Bash" \
+ --argjson tool_input '{"command":"rm -rf /tmp"}')
+assert_json_field "event is permission_request" "$PAYLOAD" ".event" "permission_request"
+assert_json_field "summary present" "$PAYLOAD" ".summary" "Wants to run Bash: rm -rf /tmp"
+assert_json_field "tool_name present" "$PAYLOAD" ".tool_name" "Bash"
+assert_json_field "tool_input.command present" "$PAYLOAD" ".tool_input.command" "rm -rf /tmp"
+
+echo ""
+echo "--- Idle prompt event ---"
+PAYLOAD=$(build_payload '{"session_id":"s1","cwd":"/tmp/proj","notification_type":"idle_prompt"}' "idle_prompt" \
+ --arg summary "Claude is waiting for your input")
+assert_json_field "event is idle_prompt" "$PAYLOAD" ".event" "idle_prompt"
+assert_json_field "summary present" "$PAYLOAD" ".summary" "Claude is waiting for your input"
+
+echo ""
+echo "--- JSON special characters in values ---"
+PAYLOAD=$(build_payload '{"session_id":"s1","cwd":"/tmp/proj"}' "stop" \
+ --arg query 'what does "hello world" mean?' \
+ --arg response 'It means greeting. Use: printf("hello")')
+assert_json_field "quotes in query preserved" "$PAYLOAD" ".query" 'what does "hello world" mean?'
+assert_json_field "parens in response preserved" "$PAYLOAD" ".response" 'It means greeting. Use: printf("hello")'
+
+echo ""
+echo "--- Protocol version negotiation ---"
+
+# Default: no env var set → falls back to plugin max (1)
+unset WARP_CLI_AGENT_PROTOCOL_VERSION
+PAYLOAD=$(build_payload '{"session_id":"s1","cwd":"/tmp"}' "stop")
+assert_json_field "defaults to v1 when env var absent" "$PAYLOAD" ".v" "1"
+
+# Warp declares v1 → use 1
+export WARP_CLI_AGENT_PROTOCOL_VERSION=1
+PAYLOAD=$(build_payload '{"session_id":"s1","cwd":"/tmp"}' "stop")
+assert_json_field "v1 when warp declares 1" "$PAYLOAD" ".v" "1"
+
+# Warp declares a higher version than the plugin knows → capped to plugin current
+export WARP_CLI_AGENT_PROTOCOL_VERSION=99
+PAYLOAD=$(build_payload '{"session_id":"s1","cwd":"/tmp"}' "stop")
+assert_json_field "capped to plugin current when warp is ahead" "$PAYLOAD" ".v" "1"
+
+# Warp declares a lower version than the plugin knows → use warp's version
+# (not testable with PLUGIN_MAX=1 since there's no v0, but we verify the min logic
+# by temporarily overriding the variable)
+PLUGIN_CURRENT_PROTOCOL_VERSION=5
+export WARP_CLI_AGENT_PROTOCOL_VERSION=3
+PAYLOAD=$(build_payload '{"session_id":"s1","cwd":"/tmp"}' "stop")
+assert_json_field "uses warp version when plugin is ahead" "$PAYLOAD" ".v" "3"
+PLUGIN_CURRENT_PROTOCOL_VERSION=1
+
+# Clean up
+unset WARP_CLI_AGENT_PROTOCOL_VERSION
+
+echo ""
+echo "=== should-use-structured.sh ==="
+
+source "$SCRIPT_DIR/../scripts/should-use-structured.sh"
+
+echo ""
+echo "--- No protocol version → legacy ---"
+unset WARP_CLI_AGENT_PROTOCOL_VERSION
+unset WARP_CLIENT_VERSION
+should_use_structured
+assert_eq "no protocol version returns false" "1" "$?"
+
+echo ""
+echo "--- Protocol set, no client version → legacy ---"
+export WARP_CLI_AGENT_PROTOCOL_VERSION=1
+unset WARP_CLIENT_VERSION
+should_use_structured
+assert_eq "missing WARP_CLIENT_VERSION returns false" "1" "$?"
+
+echo ""
+echo "--- Protocol set, dev version → always structured (dev was never broken) ---"
+export WARP_CLI_AGENT_PROTOCOL_VERSION=1
+export WARP_CLIENT_VERSION="v0.2026.03.30.08.43.dev_00"
+should_use_structured
+assert_eq "dev version returns true" "0" "$?"
+
+echo ""
+echo "--- Protocol set, broken stable version → legacy ---"
+export WARP_CLIENT_VERSION="v0.2026.03.25.08.24.stable_05"
+should_use_structured
+assert_eq "exact broken stable version returns false" "1" "$?"
+
+echo ""
+echo "--- Protocol set, newer stable version → structured ---"
+export WARP_CLIENT_VERSION="v0.2026.04.01.08.00.stable_00"
+should_use_structured
+assert_eq "newer stable version returns true" "0" "$?"
+
+echo ""
+echo "--- Protocol set, broken preview version → legacy ---"
+export WARP_CLIENT_VERSION="v0.2026.03.25.08.24.preview_05"
+should_use_structured
+assert_eq "exact broken preview version returns false" "1" "$?"
+
+echo ""
+echo "--- Protocol set, newer preview version → structured ---"
+export WARP_CLIENT_VERSION="v0.2026.04.01.08.00.preview_00"
+should_use_structured
+assert_eq "newer preview version returns true" "0" "$?"
+
+# Clean up
+unset WARP_CLI_AGENT_PROTOCOL_VERSION
+unset WARP_CLIENT_VERSION
+
+# --- Routing tests ---
+# These test the hook scripts as subprocesses to verify routing behavior.
+# We override /dev/tty writes since they'd fail in CI.
+
+HOOK_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../scripts" && pwd)"
+
+echo ""
+echo "=== Routing ==="
+
+echo ""
+echo "--- SessionStart routing ---"
+
+# Legacy Warp (TERM_PROGRAM=WarpTerminal, no protocol version)
+OUTPUT=$(TERM_PROGRAM=WarpTerminal bash "$HOOK_DIR/on-session-start.sh" < /dev/null 2>/dev/null)
+SYS_MSG=$(echo "$OUTPUT" | jq -r '.systemMessage // empty' 2>/dev/null)
+assert_eq "legacy Warp shows active message" \
+ "🔔 Warp plugin active. You'll receive native Warp notifications when tasks complete or input is needed." \
+ "$SYS_MSG"
+
+# Not Warp (neither env var set)
+OUTPUT=$(TERM_PROGRAM=other bash "$HOOK_DIR/on-session-start.sh" < /dev/null 2>/dev/null)
+SYS_MSG=$(echo "$OUTPUT" | jq -r '.systemMessage // empty' 2>/dev/null)
+assert_eq "non-Warp shows install message" \
+ "ℹ️ Warp plugin installed but you're not running in Warp terminal. Install Warp (https://warp.dev) to get native notifications when Claude completes tasks or needs input." \
+ "$SYS_MSG"
+
+echo ""
+echo "--- Modern-only hooks exit silently without protocol version ---"
+
+for HOOK in on-permission-request.sh on-prompt-submit.sh on-post-tool-use.sh; do
+ echo '{}' | bash "$HOOK_DIR/$HOOK" 2>/dev/null
+ assert_eq "$HOOK exits 0 without protocol version" "0" "$?"
+done
+
+# --- Summary ---
+
+echo ""
+echo "=== Results: $PASSED passed, $FAILED failed ==="
+
+if [ "$FAILED" -gt 0 ]; then
+ exit 1
+fi