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)
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