From 9284957103f67df004b7aaeba9d7a8f3a8ff3382 Mon Sep 17 00:00:00 2001 From: Chebaleomkar <122032936+Chebaleomkar@users.noreply.github.com> Date: Sun, 17 May 2026 16:25:28 +0530 Subject: [PATCH 1/2] Add Windows notification support via native toast fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On Windows, /dev/tty fails inside Claude Code's hook runner because stdio is captured. OSC 777 escape sequences never reach Warp's terminal emulator, so notifications silently fail. This adds a Windows-native fallback using PowerShell's Windows.UI.Notifications API: - win-toast.ps1: Sends native Windows toasts branded as Warp (registers dev.warp.Warp AppUserModelId with Warp's icon) - win-notify.sh: Deduplication (mkdir lock, 8s window) + event-type routing with project context in notification title - Patched on-stop.sh, on-notification.sh, on-permission-request.sh to call win-notify.sh after warp-notify.sh - Patched warp-notify.sh to try /dev/tty first, fall back gracefully Zero dependencies — uses built-in Windows 10/11 APIs. macOS/Linux behavior is unchanged. Fixes #48 --- plugins/warp/scripts/on-notification.sh | 3 + plugins/warp/scripts/on-permission-request.sh | 3 + plugins/warp/scripts/on-stop.sh | 3 + plugins/warp/scripts/warp-notify.sh | 10 +- plugins/warp/scripts/win-notify.sh | 97 +++++++++++++++++++ plugins/warp/scripts/win-toast.ps1 | 47 +++++++++ 6 files changed, 161 insertions(+), 2 deletions(-) create mode 100644 plugins/warp/scripts/win-notify.sh create mode 100644 plugins/warp/scripts/win-toast.ps1 diff --git a/plugins/warp/scripts/on-notification.sh b/plugins/warp/scripts/on-notification.sh index 8518ac1..11aace7 100755 --- a/plugins/warp/scripts/on-notification.sh +++ b/plugins/warp/scripts/on-notification.sh @@ -25,3 +25,6 @@ BODY=$(build_payload "$INPUT" "$NOTIF_TYPE" \ --arg summary "$MSG") "$SCRIPT_DIR/warp-notify.sh" "warp://cli-agent" "$BODY" + +# Windows fallback: native toast notification (OSC 777 fails on Windows) +[ -f "$SCRIPT_DIR/win-notify.sh" ] && "$SCRIPT_DIR/win-notify.sh" "$NOTIF_TYPE" "$INPUT" diff --git a/plugins/warp/scripts/on-permission-request.sh b/plugins/warp/scripts/on-permission-request.sh index 7d46ed2..0c2e519 100755 --- a/plugins/warp/scripts/on-permission-request.sh +++ b/plugins/warp/scripts/on-permission-request.sh @@ -37,3 +37,6 @@ BODY=$(build_payload "$INPUT" "permission_request" \ --argjson tool_input "$TOOL_INPUT") "$SCRIPT_DIR/warp-notify.sh" "warp://cli-agent" "$BODY" + +# Windows fallback: native toast notification (OSC 777 fails on Windows) +[ -f "$SCRIPT_DIR/win-notify.sh" ] && "$SCRIPT_DIR/win-notify.sh" "permission_request" "$INPUT" diff --git a/plugins/warp/scripts/on-stop.sh b/plugins/warp/scripts/on-stop.sh index 4163bb9..5fa476f 100755 --- a/plugins/warp/scripts/on-stop.sh +++ b/plugins/warp/scripts/on-stop.sh @@ -71,3 +71,6 @@ BODY=$(build_payload "$INPUT" "stop" \ --arg transcript_path "$TRANSCRIPT_PATH") "$SCRIPT_DIR/warp-notify.sh" "warp://cli-agent" "$BODY" + +# Windows fallback: native toast notification (OSC 777 fails on Windows) +[ -f "$SCRIPT_DIR/win-notify.sh" ] && "$SCRIPT_DIR/win-notify.sh" "stop" "$INPUT" diff --git a/plugins/warp/scripts/warp-notify.sh b/plugins/warp/scripts/warp-notify.sh index 523f873..75abbd2 100755 --- a/plugins/warp/scripts/warp-notify.sh +++ b/plugins/warp/scripts/warp-notify.sh @@ -17,5 +17,11 @@ 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 +# Try /dev/tty first (macOS/Linux). On Windows, /dev/tty fails inside +# Claude Code's hook runner because stdio is captured. Fall back to stderr. +if printf '\033]777;notify;%s;%s\007' "$TITLE" "$BODY" > /dev/tty 2>/dev/null; then + exit 0 +fi + +# Last resort: stderr (may not reach terminal in sandboxed hook contexts) +printf '\033]777;notify;%s;%s\007' "$TITLE" "$BODY" >&2 2>/dev/null || true diff --git a/plugins/warp/scripts/win-notify.sh b/plugins/warp/scripts/win-notify.sh new file mode 100644 index 0000000..f06c925 --- /dev/null +++ b/plugins/warp/scripts/win-notify.sh @@ -0,0 +1,97 @@ +#!/bin/bash +# Windows-only notification sender with deduplication +# Usage: win-notify.sh <event_type> <json_input> +# +# Only one notification per 8 seconds to prevent duplicates from +# multiple hooks firing on the same Claude Code event. + +# Only run on Windows +[ -z "$WINDIR" ] && exit 0 +command -v powershell &>/dev/null || exit 0 + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +EVENT_TYPE="${1:-unknown}" +INPUT="${2:-{}}" + +# === Deduplication === +LOCK_DIR="/tmp/warp-notify-lock" +if mkdir "$LOCK_DIR" 2>/dev/null; then + # We got the lock — we're the first hook to fire + # Clean up lock after 8 seconds (background) + (sleep 8 && rmdir "$LOCK_DIR" 2>/dev/null) & +else + # Another hook already fired recently — skip + exit 0 +fi + +# === Extract context === +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 notification title and body based on event type === +case "$EVENT_TYPE" in + stop) + NOTIF_TITLE="✅ Task Completed" + # Try to get the response summary + TRANSCRIPT_PATH=$(echo "$INPUT" | jq -r '.transcript_path // empty' 2>/dev/null) + RESPONSE="" + QUERY="" + if [ -n "$TRANSCRIPT_PATH" ] && [ -f "$TRANSCRIPT_PATH" ]; then + RESPONSE=$(jq -rs ' + [.[] | select(.type == "assistant" and .message.content)] | last | + [.message.content[] | select(.type == "text") | .text] | join(" ") + ' "$TRANSCRIPT_PATH" 2>/dev/null) + 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) + fi + if [ -n "$RESPONSE" ]; then + NOTIF_BODY="${RESPONSE:0:200}" + elif [ -n "$QUERY" ]; then + NOTIF_BODY="Done: ${QUERY:0:200}" + else + NOTIF_BODY="Claude finished the task" + fi + ;; + idle_prompt) + NOTIF_TITLE="⏳ Input Needed" + MSG=$(echo "$INPUT" | jq -r '.message // empty' 2>/dev/null) + NOTIF_BODY="${MSG:-Claude is waiting for your input}" + ;; + permission_request) + NOTIF_TITLE="🔐 Permission Required" + TOOL_NAME=$(echo "$INPUT" | jq -r '.tool_name // "a tool"' 2>/dev/null) + NOTIF_BODY="Claude wants to run: $TOOL_NAME" + ;; + session_start) + # Don't notify on session start — not useful + rmdir "$LOCK_DIR" 2>/dev/null + exit 0 + ;; + *) + NOTIF_TITLE="Claude Code" + NOTIF_BODY="Needs your attention" + ;; +esac + +# === Add project context === +if [ -n "$PROJECT" ]; then + NOTIF_TITLE="$NOTIF_TITLE — $PROJECT" +fi + +# === Fire Windows notification === +powershell -ExecutionPolicy Bypass -NoProfile -File "$SCRIPT_DIR/win-toast.ps1" \ + -Title "$NOTIF_TITLE" -Body "$NOTIF_BODY" &>/dev/null & diff --git a/plugins/warp/scripts/win-toast.ps1 b/plugins/warp/scripts/win-toast.ps1 new file mode 100644 index 0000000..d232469 --- /dev/null +++ b/plugins/warp/scripts/win-toast.ps1 @@ -0,0 +1,47 @@ +# Windows native toast notification branded as Warp +# Usage: powershell -ExecutionPolicy Bypass -File win-toast.ps1 -Title "title" -Body "body" +param( + [string]$Title = "Claude Code", + [string]$Body = "Task complete" +) + +# --- Register Warp as a notification source (one-time, no admin needed) --- +$appId = "dev.warp.Warp" +$regPath = "HKCU:\SOFTWARE\Classes\AppUserModelId\$appId" +$iconPath = "$env:LOCALAPPDATA\Programs\Warp\icon.ico" + +if (-not (Test-Path $regPath)) { + New-Item -Path $regPath -Force | Out-Null +} +New-ItemProperty -Path $regPath -Name "DisplayName" -Value "Warp" -PropertyType String -Force | Out-Null +if (Test-Path $iconPath) { + New-ItemProperty -Path $regPath -Name "IconUri" -Value $iconPath -PropertyType ExpandString -Force | Out-Null +} + +# --- Load Windows Runtime types --- +[Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime] | Out-Null +[Windows.Data.Xml.Dom.XmlDocument, Windows.Data.Xml.Dom, ContentType = WindowsRuntime] | Out-Null + +# --- Build toast XML --- +$logoAttr = "" +if (Test-Path $iconPath) { + $logoAttr = "<image placement=`"appLogoOverride`" src=`"$iconPath`" hint-crop=`"circle`"/>" +} + +$toastXml = @" +<toast> + <visual> + <binding template="ToastGeneric"> + $logoAttr + <text>$Title</text> + <text>$Body</text> + </binding> + </visual> +</toast> +"@ + +# --- Show notification --- +$xml = New-Object Windows.Data.Xml.Dom.XmlDocument +$xml.LoadXml($toastXml) +$toast = [Windows.UI.Notifications.ToastNotification]::new($xml) +[Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier($appId).Show($toast) From 660dbe616ec460510facc5ec748b5c04e93a55af Mon Sep 17 00:00:00 2001 From: Chebaleomkar <122032936+Chebaleomkar@users.noreply.github.com> Date: Sun, 17 May 2026 16:44:42 +0530 Subject: [PATCH 2/2] Add test suite for Windows notification scripts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 16 tests covering: - Non-Windows exit (no-op on macOS/Linux) - Windows detection and PowerShell invocation - Deduplication via mkdir lock (8s window) - Event type routing (stop, idle_prompt, permission_request, session_start) - Project name extraction from cwd - Hook script patching verification - warp-notify.sh fallback logic - win-toast.ps1 structure and Warp AppUserModelId Tests use a mock PowerShell to verify behavior without sending actual notifications — safe to run in CI on any platform. --- plugins/warp/tests/test-windows-notify.sh | 232 ++++++++++++++++++++++ 1 file changed, 232 insertions(+) create mode 100644 plugins/warp/tests/test-windows-notify.sh diff --git a/plugins/warp/tests/test-windows-notify.sh b/plugins/warp/tests/test-windows-notify.sh new file mode 100644 index 0000000..c0cb849 --- /dev/null +++ b/plugins/warp/tests/test-windows-notify.sh @@ -0,0 +1,232 @@ +#!/bin/bash +# Tests for the Windows notification fallback scripts. +# +# Validates win-notify.sh deduplication, event routing, and integration +# with the existing hook scripts on Windows. +# +# Usage: ./tests/test-windows-notify.sh +# +# These tests work on any platform — they mock WINDIR and powershell +# to verify logic without actually sending notifications. + +set -uo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../scripts" && pwd)" +TESTS_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +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 +} + +# Create a mock powershell that logs calls instead of sending toasts +MOCK_DIR=$(mktemp -d) +MOCK_LOG="$MOCK_DIR/powershell-calls.log" +cat > "$MOCK_DIR/powershell" << 'MOCK_EOF' +#!/bin/bash +echo "$@" >> "$(dirname "$0")/powershell-calls.log" +MOCK_EOF +chmod +x "$MOCK_DIR/powershell" + +# Clean up on exit +cleanup() { + rm -rf "$MOCK_DIR" + rmdir /tmp/warp-notify-lock 2>/dev/null || true +} +trap cleanup EXIT + +echo "=== win-notify.sh ===" + +# --- Test: exits on non-Windows --- + +echo "" +echo "--- Non-Windows exit ---" +unset WINDIR +echo '{}' | bash "$SCRIPT_DIR/win-notify.sh" "stop" '{}' 2>/dev/null +assert_eq "exits silently on non-Windows" "0" "$?" + +# --- Test: runs on Windows (mocked) --- + +echo "" +echo "--- Windows detection ---" +export WINDIR="C:\\Windows" +export PATH="$MOCK_DIR:$PATH" +# Clean any stale lock +rmdir /tmp/warp-notify-lock 2>/dev/null || true +> "$MOCK_LOG" + +bash "$SCRIPT_DIR/win-notify.sh" "stop" '{"cwd":"/tmp/my-project"}' 2>/dev/null +sleep 0.5 +assert_eq "powershell was called" "true" "$([ -s "$MOCK_LOG" ] && echo true || echo false)" + +# Check the powershell args contain the title +CALL=$(cat "$MOCK_LOG" 2>/dev/null) +if echo "$CALL" | grep -q "Task Completed"; then + echo " ✓ stop event produces 'Task Completed' title" + PASSED=$((PASSED + 1)) +else + echo " ✗ stop event produces 'Task Completed' title" + echo " actual call: $CALL" + FAILED=$((FAILED + 1)) +fi + +# --- Test: deduplication --- + +echo "" +echo "--- Deduplication ---" +# Lock should still exist from the previous call +> "$MOCK_LOG" +bash "$SCRIPT_DIR/win-notify.sh" "stop" '{"cwd":"/tmp/test"}' 2>/dev/null +sleep 0.5 +assert_eq "second call within 8s is skipped" "false" "$([ -s "$MOCK_LOG" ] && echo true || echo false)" + +# Clean lock and verify next call goes through +rmdir /tmp/warp-notify-lock 2>/dev/null || true +> "$MOCK_LOG" +bash "$SCRIPT_DIR/win-notify.sh" "stop" '{"cwd":"/tmp/test"}' 2>/dev/null +sleep 0.5 +assert_eq "call after lock cleared goes through" "true" "$([ -s "$MOCK_LOG" ] && echo true || echo false)" + +# --- Test: event types --- + +echo "" +echo "--- Event type routing ---" + +# idle_prompt +rmdir /tmp/warp-notify-lock 2>/dev/null || true +> "$MOCK_LOG" +bash "$SCRIPT_DIR/win-notify.sh" "idle_prompt" '{"cwd":"/tmp/proj"}' 2>/dev/null +sleep 0.5 +CALL=$(cat "$MOCK_LOG" 2>/dev/null) +if echo "$CALL" | grep -q "Input Needed"; then + echo " ✓ idle_prompt produces 'Input Needed' title" + PASSED=$((PASSED + 1)) +else + echo " ✗ idle_prompt produces 'Input Needed' title" + echo " actual: $CALL" + FAILED=$((FAILED + 1)) +fi + +# permission_request +rmdir /tmp/warp-notify-lock 2>/dev/null || true +> "$MOCK_LOG" +bash "$SCRIPT_DIR/win-notify.sh" "permission_request" '{"cwd":"/tmp/proj","tool_name":"Bash"}' 2>/dev/null +sleep 0.5 +CALL=$(cat "$MOCK_LOG" 2>/dev/null) +if echo "$CALL" | grep -q "Permission Required"; then + echo " ✓ permission_request produces 'Permission Required' title" + PASSED=$((PASSED + 1)) +else + echo " ✗ permission_request produces 'Permission Required' title" + echo " actual: $CALL" + FAILED=$((FAILED + 1)) +fi + +# session_start (should be skipped) +rmdir /tmp/warp-notify-lock 2>/dev/null || true +> "$MOCK_LOG" +bash "$SCRIPT_DIR/win-notify.sh" "session_start" '{"cwd":"/tmp/proj"}' 2>/dev/null +sleep 0.5 +assert_eq "session_start is silently skipped" "false" "$([ -s "$MOCK_LOG" ] && echo true || echo false)" + +# --- Test: project name extraction --- + +echo "" +echo "--- Project name in title ---" +rmdir /tmp/warp-notify-lock 2>/dev/null || true +> "$MOCK_LOG" +bash "$SCRIPT_DIR/win-notify.sh" "stop" '{"cwd":"/home/user/awesome-project"}' 2>/dev/null +sleep 0.5 +CALL=$(cat "$MOCK_LOG" 2>/dev/null) +if echo "$CALL" | grep -q "awesome-project"; then + echo " ✓ project name extracted from cwd" + PASSED=$((PASSED + 1)) +else + echo " ✗ project name extracted from cwd" + echo " actual: $CALL" + FAILED=$((FAILED + 1)) +fi + +# --- Test: hook scripts include win-notify.sh call --- + +echo "" +echo "--- Hook scripts patched ---" + +for HOOK in on-stop.sh on-notification.sh on-permission-request.sh; do + if grep -q "win-notify.sh" "$SCRIPT_DIR/$HOOK" 2>/dev/null; then + echo " ✓ $HOOK calls win-notify.sh" + PASSED=$((PASSED + 1)) + else + echo " ✗ $HOOK missing win-notify.sh call" + FAILED=$((FAILED + 1)) + fi +done + +# --- Test: warp-notify.sh fallback logic --- + +echo "" +echo "--- warp-notify.sh /dev/tty fallback ---" + +if grep -q "exit 0" "$SCRIPT_DIR/warp-notify.sh" && grep -q '>&2' "$SCRIPT_DIR/warp-notify.sh"; then + echo " ✓ warp-notify.sh has /dev/tty → stderr fallback" + PASSED=$((PASSED + 1)) +else + echo " ✗ warp-notify.sh missing fallback logic" + FAILED=$((FAILED + 1)) +fi + +# --- Test: win-toast.ps1 exists and has correct structure --- + +echo "" +echo "--- win-toast.ps1 structure ---" + +if [ -f "$SCRIPT_DIR/win-toast.ps1" ]; then + echo " ✓ win-toast.ps1 exists" + PASSED=$((PASSED + 1)) +else + echo " ✗ win-toast.ps1 missing" + FAILED=$((FAILED + 1)) +fi + +if grep -q "dev.warp.Warp" "$SCRIPT_DIR/win-toast.ps1" 2>/dev/null; then + echo " ✓ win-toast.ps1 uses Warp AppUserModelId" + PASSED=$((PASSED + 1)) +else + echo " ✗ win-toast.ps1 missing Warp AppUserModelId" + FAILED=$((FAILED + 1)) +fi + +if grep -q "ToastNotificationManager" "$SCRIPT_DIR/win-toast.ps1" 2>/dev/null; then + echo " ✓ win-toast.ps1 uses Windows.UI.Notifications API" + PASSED=$((PASSED + 1)) +else + echo " ✗ win-toast.ps1 missing ToastNotificationManager" + FAILED=$((FAILED + 1)) +fi + +# Clean up env +unset WINDIR + +# --- Summary --- + +echo "" +echo "=== Results: $PASSED passed, $FAILED failed ===" + +if [ "$FAILED" -gt 0 ]; then + exit 1 +fi