Skip to content
Merged
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.0.0",
"version": "2.1.0",
"category": "productivity",
"tags": ["notifications", "terminal", "warp"]
}
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.0.0",
"version": "2.1.0",
"author": {
"name": "Warp",
"url": "https://warp.dev"
Expand Down
86 changes: 86 additions & 0 deletions plugins/warp/scripts/emit-terminal-sequence.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
#!/bin/bash
# Emits an OSC terminal escape sequence using the best available method.
#
# Claude Code 2.1.141 added a `terminalSequence` JSON output field for hooks,
# so they can deliver OSC sequences without a controlling terminal. Older
# Claude Code doesn't know that field, and Stop hooks reject unknown fields
# ("Stop hook error: JSON validation failed"), so on older versions we must
# write to /dev/tty instead.
#
# Decision tree:
# 1. CLAUDE_CODE_VERSION known, >= 2.1.141 → emit terminalSequence JSON
# 2. CLAUDE_CODE_VERSION known, < 2.1.141 → write /dev/tty; give up if missing
# 3. CLAUDE_CODE_VERSION unknown → try /dev/tty, fall back to JSON
#
# Usage:
# source "$SCRIPT_DIR/emit-terminal-sequence.sh"
# SEQ=$(printf '\033]777;notify;%s;%s\007' "$TITLE" "$BODY")
# emit_terminal_sequence "$SEQ"
#
# When the sequence is delivered via /dev/tty (side effect), nothing is printed
# to stdout. When it must go through terminalSequence, a JSON object is printed
# to stdout — the caller should ensure this reaches the hook's stdout.

# The first Claude Code version that supports the terminalSequence output field.
TERMINAL_SEQUENCE_MIN_VERSION="2.1.141"

# Compare two dotted version strings (e.g. "2.1.141" >= "2.1.141").
# Returns 0 (true) if $1 >= $2, 1 (false) otherwise.
_version_at_least() {
local a b av bv i
IFS=. read -ra a <<< "$1"
IFS=. read -ra b <<< "$2"
for ((i = 0; i < ${#b[@]}; i++)); do
av="${a[i]:-0}"
bv="${b[i]:-0}"
if ((av > bv)); then return 0; fi
if ((av < bv)); then return 1; fi
done
return 0
}

# Extract a bare version number (e.g. "2.1.141") from `claude --version` output,
# which may look like "claude 2.1.141" or "2.1.141" or "Claude Code v2.1.141".
_parse_cc_version() {
echo "$1" | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -1
}

# Returns 0 if the running Claude Code version supports terminalSequence.
_supports_terminal_sequence() {
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Some hooks (but not others for some reason) error out when you include unexpected fields , so we need to check the claude code version to see whether this field is supported before emitting it

local raw="${CLAUDE_CODE_VERSION:-}"
[ -z "$raw" ] && return 1
local ver
ver=$(_parse_cc_version "$raw")
[ -z "$ver" ] && return 1
_version_at_least "$ver" "$TERMINAL_SEQUENCE_MIN_VERSION"
}

emit_terminal_sequence() {
local seq="$1"
[ -z "$seq" ] && return 0

# Classify the running Claude Code version, if we can.
local raw="${CLAUDE_CODE_VERSION:-}"
local ver=""
[ -n "$raw" ] && ver=$(_parse_cc_version "$raw")

if [ -n "$ver" ]; then
if _version_at_least "$ver" "$TERMINAL_SEQUENCE_MIN_VERSION"; then
# Known new Claude Code — use the structured output field.
jq -nc --arg seq "$seq" '{terminalSequence: $seq}'
else
# Known-old Claude Code — /dev/tty is the only safe path.
# Emitting terminalSequence here would be rejected by the Stop
# hook validator as an unknown field.
printf '%s' "$seq" > /dev/tty 2>/dev/null || true
fi
return 0
fi

# Unknown Claude Code version — try /dev/tty, fall back to JSON
# as a best-effort attempt for new CC without version detection.
if printf '%s' "$seq" > /dev/tty 2>/dev/null; then
return 0
fi
jq -nc --arg seq "$seq" '{terminalSequence: $seq}'
}
11 changes: 11 additions & 0 deletions plugins/warp/scripts/on-session-start.sh
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,17 @@ source "$SCRIPT_DIR/build-payload.sh"
# Read hook input from stdin
INPUT=$(cat)

# Best-effort Claude Code version detection.
# Cache in $CLAUDE_ENV_FILE so subsequent hooks can skip the lookup, and
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was very surprised to find that claude code doesn't include an easy way for plugins to check the installed claude code version, so we need to do this check/cache the env variable manually

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oof, that's annoying and indeed surprising

# export it now so the rest of this hook (warp-notify.sh below) can use it.
if [ -n "${CLAUDE_ENV_FILE:-}" ] && [ -z "${CLAUDE_CODE_VERSION:-}" ]; then
CC_VERSION=$(claude --version 2>/dev/null | head -1 || true)
if [ -n "$CC_VERSION" ]; then
echo "export CLAUDE_CODE_VERSION=\"$CC_VERSION\"" >> "$CLAUDE_ENV_FILE"
export CLAUDE_CODE_VERSION="$CC_VERSION"
fi
fi

# Read plugin version from plugin.json
PLUGIN_VERSION=$(jq -r '.version // "unknown"' "$SCRIPT_DIR/../.claude-plugin/plugin.json" 2>/dev/null)

Expand Down
10 changes: 8 additions & 2 deletions plugins/warp/scripts/warp-notify.sh
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,15 @@
#
# For structured Warp notifications, title should be "warp://cli-agent"
# and body should be a JSON string matching the cli-agent notification schema.
#
# Output behavior:
# - On old Claude Code: writes OSC 777 directly to /dev/tty (no stdout)
# - On new Claude Code (>= 2.1.141): prints {terminalSequence: ...} JSON to
# stdout so the caller can pass it through as hook output

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$SCRIPT_DIR/should-use-structured.sh"
source "$SCRIPT_DIR/emit-terminal-sequence.sh"

# Only emit notifications when we've confirmed the Warp build can render them.
if ! should_use_structured; then
Expand All @@ -17,5 +23,5 @@ TITLE="${1:-Notification}"
BODY="${2:-}"

# OSC 777 format: \033]777;notify;<title>;<body>\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
SEQ=$(printf '\033]777;notify;%s;%s\007' "$TITLE" "$BODY")
emit_terminal_sequence "$SEQ"
64 changes: 64 additions & 0 deletions plugins/warp/tests/test-hooks.sh
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,70 @@ assert_eq "newer preview version returns true" "0" "$?"
unset WARP_CLI_AGENT_PROTOCOL_VERSION
unset WARP_CLIENT_VERSION

echo ""
echo "=== emit-terminal-sequence.sh ==="

source "$SCRIPT_DIR/../scripts/emit-terminal-sequence.sh"

echo ""
echo "--- Version comparison ---"
_version_at_least "2.1.141" "2.1.141"
assert_eq "equal versions" "0" "$?"
_version_at_least "2.1.142" "2.1.141"
assert_eq "newer patch" "0" "$?"
_version_at_least "2.2.0" "2.1.141"
assert_eq "newer minor" "0" "$?"
_version_at_least "3.0.0" "2.1.141"
assert_eq "newer major" "0" "$?"
_version_at_least "2.1.140" "2.1.141"
assert_eq "older patch" "1" "$?"
_version_at_least "2.0.999" "2.1.141"
assert_eq "older minor" "1" "$?"
_version_at_least "1.9.999" "2.1.141"
assert_eq "older major" "1" "$?"

echo ""
echo "--- Version parsing ---"
assert_eq "bare version" "2.1.141" "$(_parse_cc_version '2.1.141')"
assert_eq "prefixed with name" "2.1.141" "$(_parse_cc_version 'claude 2.1.141')"
assert_eq "prefixed with v" "2.1.141" "$(_parse_cc_version 'Claude Code v2.1.141')"
assert_eq "empty string" "" "$(_parse_cc_version '')"
assert_eq "no version" "" "$(_parse_cc_version 'no version here')"

echo ""
echo "--- _supports_terminal_sequence ---"

unset CLAUDE_CODE_VERSION
_supports_terminal_sequence
assert_eq "unset version → false" "1" "$?"

export CLAUDE_CODE_VERSION="2.1.141"
_supports_terminal_sequence
assert_eq "exact min version → true" "0" "$?"

export CLAUDE_CODE_VERSION="claude 2.1.150"
_supports_terminal_sequence
assert_eq "newer with prefix → true" "0" "$?"

export CLAUDE_CODE_VERSION="2.1.100"
_supports_terminal_sequence
assert_eq "older version → false" "1" "$?"

export CLAUDE_CODE_VERSION="garbage"
_supports_terminal_sequence
assert_eq "unparseable version → false" "1" "$?"

unset CLAUDE_CODE_VERSION

echo ""
echo "--- emit_terminal_sequence output ---"

# With known new version → outputs terminalSequence JSON
export CLAUDE_CODE_VERSION="2.1.141"
OUTPUT=$(emit_terminal_sequence "test-seq")
assert_json_field "new CC outputs terminalSequence" "$OUTPUT" ".terminalSequence" "test-seq"
unset CLAUDE_CODE_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.
Expand Down
Loading