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;
;\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
+#
+# 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 = ""
+}
+
+$toastXml = @"
+
+
+
+ $logoAttr
+ $Title
+ $Body
+
+
+
+"@
+
+# --- 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