diff --git a/plugins/warp/scripts/find-controlling-tty.sh b/plugins/warp/scripts/find-controlling-tty.sh
new file mode 100755
index 0000000..3a12d6d
--- /dev/null
+++ b/plugins/warp/scripts/find-controlling-tty.sh
@@ -0,0 +1,31 @@
+#!/bin/bash
+# Emit candidate controlling-tty paths by walking up the process tree.
+#
+# Background: when the hook runs inside a sandbox that detaches the subprocess
+# from its controlling terminal (e.g. Claude Code's macOS `sandbox-exec`
+# wrapper for Bash tool calls, when `sandbox.enabled` is set), the hook has
+# no controlling terminal — opening `/dev/tty` returns ENXIO. To still deliver
+# the OSC notification, we walk up the process tree and emit each ancestor's
+# tty device path; the caller tries to write to each in turn until one
+# succeeds.
+#
+# Caveats:
+# - We do not verify the ancestor is actually Warp. In nested setups (tmux,
+# screen, ssh into non-Warp), the OSC may end up in a terminal that ignores
+# it. OSC 777 is widely ignored by terminals that don't implement it, so
+# the worst case is a silently dropped notification — not corruption.
+# - The walk is depth-limited to bound the number of `ps` invocations.
+
+find_candidate_ttys() {
+ local pid=$PPID
+ local depth=0
+ while [[ -n $pid && $pid != 0 && $pid != 1 && $depth -lt 20 ]]; do
+ local tty
+ tty=$(ps -o tty= -p "$pid" 2>/dev/null | tr -d ' ')
+ if [[ -n $tty && $tty != "??" ]]; then
+ printf '/dev/%s\n' "$tty"
+ fi
+ pid=$(ps -o ppid= -p "$pid" 2>/dev/null | tr -d ' ')
+ depth=$((depth + 1))
+ done
+}
diff --git a/plugins/warp/scripts/legacy/warp-notify.sh b/plugins/warp/scripts/legacy/warp-notify.sh
index 6ca0588..7e8b662 100755
--- a/plugins/warp/scripts/legacy/warp-notify.sh
+++ b/plugins/warp/scripts/legacy/warp-notify.sh
@@ -2,9 +2,24 @@
# Warp notification utility using OSC escape sequences
# Usage: warp-notify.sh
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+source "$SCRIPT_DIR/../find-controlling-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
+emit() {
+ printf '\033]777;notify;%s;%s\007' "$TITLE" "$BODY" > "$1" 2>/dev/null
+}
+
+# Prefer /dev/tty. If unopenable (e.g. the hook is running inside a sandbox
+# that detached the subprocess from its controlling terminal), fall back to
+# trying each ancestor process's tty in turn until one accepts the write.
+emit /dev/tty && exit 0
+
+while read -r tty; do
+ emit "$tty" && exit 0
+done < <(find_candidate_ttys)
+
+exit 0
diff --git a/plugins/warp/scripts/warp-notify.sh b/plugins/warp/scripts/warp-notify.sh
index 523f873..a3925ed 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/find-controlling-tty.sh"
# Only emit notifications when we've confirmed the Warp build can render them.
if ! should_use_structured; then
@@ -17,5 +18,17 @@ 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
+emit() {
+ printf '\033]777;notify;%s;%s\007' "$TITLE" "$BODY" > "$1" 2>/dev/null
+}
+
+# Prefer /dev/tty. If unopenable (e.g. the hook is running inside a sandbox
+# that detached the subprocess from its controlling terminal), fall back to
+# trying each ancestor process's tty in turn until one accepts the write.
+emit /dev/tty && exit 0
+
+while read -r tty; do
+ emit "$tty" && exit 0
+done < <(find_candidate_ttys)
+
+exit 0
diff --git a/plugins/warp/tests/test-find-controlling-tty.sh b/plugins/warp/tests/test-find-controlling-tty.sh
new file mode 100755
index 0000000..7415a9e
--- /dev/null
+++ b/plugins/warp/tests/test-find-controlling-tty.sh
@@ -0,0 +1,118 @@
+#!/bin/bash
+# Tests for find-controlling-tty.sh
+#
+# The function walks up the process tree using `ps`. We override `ps` as a
+# shell function so the tests don't depend on the actual process tree.
+
+set -uo pipefail
+
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../scripts" && pwd)"
+source "$SCRIPT_DIR/find-controlling-tty.sh"
+
+PASSED=0
+FAILED=0
+
+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
+}
+
+echo "=== find-controlling-tty.sh ==="
+
+echo ""
+echo "--- Emits the parent's tty when it has one ---"
+ps() {
+ case "$*" in
+ "-o tty= -p $PPID") echo "ttys003" ;;
+ "-o ppid= -p $PPID") echo "1" ;;
+ *) command ps "$@" ;;
+ esac
+}
+result=$(find_candidate_ttys | tr '\n' ' ' | sed 's/ $//')
+assert_eq "parent tty appears as /dev/" "/dev/ttys003" "$result"
+unset -f ps
+
+echo ""
+echo "--- Skips ancestors with no controlling tty, keeps walking ---"
+ps() {
+ case "$*" in
+ "-o tty= -p $PPID") echo "??" ;;
+ "-o tty= -p 77001") echo "??" ;;
+ "-o tty= -p 77002") echo "ttys004" ;;
+ "-o ppid= -p $PPID") echo "77001" ;;
+ "-o ppid= -p 77001") echo "77002" ;;
+ "-o ppid= -p 77002") echo "1" ;;
+ *) command ps "$@" ;;
+ esac
+}
+result=$(find_candidate_ttys | tr '\n' ' ' | sed 's/ $//')
+assert_eq "?? entries are skipped, real tty emitted" "/dev/ttys004" "$result"
+unset -f ps
+
+echo ""
+echo "--- Emits multiple ancestors if more than one has a tty ---"
+# A nested case (e.g. tmux): both inner and outer ancestors have ttys.
+ps() {
+ case "$*" in
+ "-o tty= -p $PPID") echo "ttys001" ;;
+ "-o tty= -p 88001") echo "ttys002" ;;
+ "-o ppid= -p $PPID") echo "88001" ;;
+ "-o ppid= -p 88001") echo "1" ;;
+ *) command ps "$@" ;;
+ esac
+}
+result=$(find_candidate_ttys | tr '\n' ' ' | sed 's/ $//')
+assert_eq "both ancestor ttys are emitted in order" "/dev/ttys001 /dev/ttys002" "$result"
+unset -f ps
+
+echo ""
+echo "--- Emits nothing when no ancestor has a tty ---"
+ps() {
+ case "$*" in
+ "-o tty="*) echo "??" ;;
+ "-o ppid="*) echo "1" ;;
+ *) command ps "$@" ;;
+ esac
+}
+result=$(find_candidate_ttys)
+assert_eq "no output when every ancestor reports ??" "" "$result"
+unset -f ps
+
+echo ""
+echo "--- Honors depth limit on unbounded process chains ---"
+ps() {
+ case "$*" in
+ "-o tty="*) echo "??" ;;
+ "-o ppid="*) echo "9999" ;;
+ *) command ps "$@" ;;
+ esac
+}
+# Should terminate (not hang) even though every ppid points to a non-init pid.
+start_secs=$SECONDS
+find_candidate_ttys > /dev/null
+elapsed=$((SECONDS - start_secs))
+if (( elapsed <= 2 )); then
+ echo " ✓ depth-limited walk terminates promptly"
+ PASSED=$((PASSED + 1))
+else
+ echo " ✗ depth-limited walk took too long (${elapsed}s)"
+ FAILED=$((FAILED + 1))
+fi
+unset -f ps
+
+echo ""
+echo "=== Results: $PASSED passed, $FAILED failed ==="
+
+if (( FAILED > 0 )); then
+ exit 1
+fi