diff --git a/plugins/warp/scripts/legacy/warp-notify.sh b/plugins/warp/scripts/legacy/warp-notify.sh
index 6ca0588..fc67ef7 100755
--- a/plugins/warp/scripts/legacy/warp-notify.sh
+++ b/plugins/warp/scripts/legacy/warp-notify.sh
@@ -2,9 +2,13 @@
# Warp notification utility using OSC escape sequences
# Usage: warp-notify.sh
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+source "$SCRIPT_DIR/../resolve-tty.sh"
+
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
+# Hook processes have no controlling terminal, so resolve the real tty of an
+# ancestor process rather than relying on /dev/tty (which fails in that case).
+printf '\033]777;notify;%s;%s\007' "$TITLE" "$BODY" > "$(resolve_tty_device)" 2>/dev/null || true
diff --git a/plugins/warp/scripts/resolve-tty.sh b/plugins/warp/scripts/resolve-tty.sh
new file mode 100644
index 0000000..1613f8b
--- /dev/null
+++ b/plugins/warp/scripts/resolve-tty.sh
@@ -0,0 +1,36 @@
+#!/bin/bash
+# Resolve the terminal device to write OSC escape sequences to.
+#
+# Claude Code spawns hook processes WITHOUT a controlling terminal, so the
+# usual `/dev/tty` is unavailable ("Device not configured" / ENXIO) and any
+# notification written there is silently dropped. Walk up the process tree
+# to find an ancestor (the `claude` process or its parent shell) that still
+# has a controlling tty, and return that device node instead.
+#
+# Falls back to `/dev/tty` when no ancestor with a tty is found, so callers
+# behave exactly as before in environments where `/dev/tty` already works.
+#
+# Usage:
+# source "$SCRIPT_DIR/resolve-tty.sh"
+# printf '...' > "$(resolve_tty_device)"
+
+resolve_tty_device() {
+ local pid=$PPID depth=0 tty_name
+ while [ -n "$pid" ] && [ "$pid" -gt 1 ] && [ "$depth" -lt 25 ]; do
+ # `ps -o tty=` prints e.g. `ttys003` (macOS) or `pts/3` (Linux),
+ # and `?` / `??` for a process with no controlling terminal.
+ tty_name=$(ps -o tty= -p "$pid" 2>/dev/null | tr -d '[:space:]')
+ case "$tty_name" in
+ '' | '?' | '??')
+ : # this ancestor has no controlling tty — keep walking up
+ ;;
+ *)
+ printf '/dev/%s\n' "$tty_name"
+ return 0
+ ;;
+ esac
+ pid=$(ps -o ppid= -p "$pid" 2>/dev/null | tr -d '[:space:]')
+ depth=$((depth + 1))
+ done
+ printf '/dev/tty\n'
+}
diff --git a/plugins/warp/scripts/warp-notify.sh b/plugins/warp/scripts/warp-notify.sh
index 523f873..b9a7af1 100755
--- a/plugins/warp/scripts/warp-notify.sh
+++ b/plugins/warp/scripts/warp-notify.sh
@@ -7,6 +7,7 @@
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$SCRIPT_DIR/should-use-structured.sh"
+source "$SCRIPT_DIR/resolve-tty.sh"
# Only emit notifications when we've confirmed the Warp build can render them.
if ! should_use_structured; then
@@ -17,5 +18,6 @@ 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
+# Hook processes have no controlling terminal, so resolve the real tty of an
+# ancestor process rather than relying on /dev/tty (which fails in that case).
+printf '\033]777;notify;%s;%s\007' "$TITLE" "$BODY" > "$(resolve_tty_device)" 2>/dev/null || true