diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index a265fe6..f891a3a 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": "2.0.0", + "version": "2.1.0", "category": "productivity", "tags": ["notifications", "terminal", "warp"] } diff --git a/plugins/warp/.claude-plugin/plugin.json b/plugins/warp/.claude-plugin/plugin.json index da219bb..8dd9247 100644 --- a/plugins/warp/.claude-plugin/plugin.json +++ b/plugins/warp/.claude-plugin/plugin.json @@ -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" diff --git a/plugins/warp/scripts/emit-terminal-sequence.sh b/plugins/warp/scripts/emit-terminal-sequence.sh new file mode 100644 index 0000000..b6a83b9 --- /dev/null +++ b/plugins/warp/scripts/emit-terminal-sequence.sh @@ -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() { + 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}' +} diff --git a/plugins/warp/scripts/on-session-start.sh b/plugins/warp/scripts/on-session-start.sh index ce7364b..50f6963 100755 --- a/plugins/warp/scripts/on-session-start.sh +++ b/plugins/warp/scripts/on-session-start.sh @@ -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 +# 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) diff --git a/plugins/warp/scripts/warp-notify.sh b/plugins/warp/scripts/warp-notify.sh index 523f873..f65559a 100755 --- a/plugins/warp/scripts/warp-notify.sh +++ b/plugins/warp/scripts/warp-notify.sh @@ -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 @@ -17,5 +23,5 @@ TITLE="${1:-Notification}" BODY="${2:-}" # OSC 777 format: \033]777;notify;;<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" diff --git a/plugins/warp/tests/test-hooks.sh b/plugins/warp/tests/test-hooks.sh index c8fcbb6..754bdd0 100755 --- a/plugins/warp/tests/test-hooks.sh +++ b/plugins/warp/tests/test-hooks.sh @@ -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.