diff --git a/plugins/warp/scripts/build-payload.sh b/plugins/warp/scripts/build-payload.sh
index 9ad610e..b0c8523 100644
--- a/plugins/warp/scripts/build-payload.sh
+++ b/plugins/warp/scripts/build-payload.sh
@@ -35,6 +35,11 @@ build_payload() {
local protocol_version
protocol_version=$(negotiate_protocol_version)
+ # Sourced by the hook scripts, so `return` (not `exit`) when jq is missing;
+ # callers treat an empty body as "no notification to send" rather than
+ # surfacing "jq: command not found" as a hook error.
+ command -v jq &>/dev/null || return 0
+
# Extract common fields from the hook input
local session_id cwd project
session_id=$(echo "$input" | jq -r '.session_id // empty' 2>/dev/null)
@@ -46,7 +51,12 @@ build_payload() {
# Build the payload: common fields + any extra args passed by the caller.
# Extra args should be jq flag pairs like: --arg key "value" or --argjson key '{"a":1}'
- jq -nc \
+ #
+ # MSYS2_ARG_CONV_EXCL stops Git Bash rewriting path-like arguments into
+ # Windows paths, which corrupted cwd and transcript_path (e.g.
+ # "/Users/alice/p" -> "D:/.../Users/alice/p"). jq only sees opaque strings.
+ # Ignored on Linux/macOS.
+ MSYS2_ARG_CONV_EXCL='*' jq -nc \
--argjson v "$protocol_version" \
--arg agent "claude" \
--arg event "$event" \
diff --git a/plugins/warp/scripts/emit-terminal-sequence.sh b/plugins/warp/scripts/emit-terminal-sequence.sh
index b6a83b9..7c45f7d 100644
--- a/plugins/warp/scripts/emit-terminal-sequence.sh
+++ b/plugins/warp/scripts/emit-terminal-sequence.sh
@@ -20,10 +20,22 @@
# When the sequence is delivered via /dev/tty (side effect), nothing is printed
# to stdout. When it must go through terminalSequence, a JSON object is printed
# to stdout — the caller should ensure this reaches the hook's stdout.
+#
+# On Windows Git Bash / MSYS2 a /dev/tty node exists but opening it fails with
+# ENXIO when the process has no controlling terminal, so the node's presence
+# says nothing about whether it is usable.
# The first Claude Code version that supports the terminalSequence output field.
TERMINAL_SEQUENCE_MIN_VERSION="2.1.141"
+# Write $1 to the controlling terminal; returns 1 if there isn't a usable one.
+# stderr is redirected *before* the open: in `> /dev/tty 2>/dev/null` the open
+# runs while stderr is still inherited, so a failure would escape. Probing and
+# writing in one step also avoids a check-then-use gap if the tty goes away.
+_tty_write() {
+ printf '%s' "$1" 2>/dev/null > /dev/tty
+}
+
# Compare two dotted version strings (e.g. "2.1.141" >= "2.1.141").
# Returns 0 (true) if $1 >= $2, 1 (false) otherwise.
_version_at_least() {
@@ -59,6 +71,10 @@ emit_terminal_sequence() {
local seq="$1"
[ -z "$seq" ] && return 0
+ # Without jq the JSON fallback is impossible, so stay silent rather than
+ # leaking "jq: command not found" as a hook error.
+ command -v jq &>/dev/null || return 0
+
# Classify the running Claude Code version, if we can.
local raw="${CLAUDE_CODE_VERSION:-}"
local ver=""
@@ -67,20 +83,20 @@ emit_terminal_sequence() {
if [ -n "$ver" ]; then
if _version_at_least "$ver" "$TERMINAL_SEQUENCE_MIN_VERSION"; then
# Known new Claude Code — use the structured output field.
- jq -nc --arg seq "$seq" '{terminalSequence: $seq}'
+ MSYS2_ARG_CONV_EXCL='*' jq -nc --arg seq "$seq" '{terminalSequence: $seq}'
else
# Known-old Claude Code — /dev/tty is the only safe path.
# Emitting terminalSequence here would be rejected by the Stop
# hook validator as an unknown field.
- printf '%s' "$seq" > /dev/tty 2>/dev/null || true
+ _tty_write "$seq" || true
fi
return 0
fi
# Unknown Claude Code version — try /dev/tty, fall back to JSON
# as a best-effort attempt for new CC without version detection.
- if printf '%s' "$seq" > /dev/tty 2>/dev/null; then
+ if _tty_write "$seq"; then
return 0
fi
- jq -nc --arg seq "$seq" '{terminalSequence: $seq}'
+ MSYS2_ARG_CONV_EXCL='*' jq -nc --arg seq "$seq" '{terminalSequence: $seq}'
}
diff --git a/plugins/warp/scripts/legacy/warp-notify.sh b/plugins/warp/scripts/legacy/warp-notify.sh
index 6ca0588..cc9f97e 100755
--- a/plugins/warp/scripts/legacy/warp-notify.sh
+++ b/plugins/warp/scripts/legacy/warp-notify.sh
@@ -6,5 +6,9 @@ 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
+# Write to /dev/tty when one is usable. MSYS2 has a /dev/tty node that cannot be
+# opened on a detached process, so probe first; stderr is redirected before the
+# open (left-to-right) so the ENXIO error cannot leak either way.
+if [ -e /dev/tty ] && { : > /dev/tty; } 2>/dev/null; then
+ printf '\033]777;notify;%s;%s\007' "$TITLE" "$BODY" 2>/dev/null > /dev/tty || true
+fi
diff --git a/plugins/warp/tests/test-hooks.sh b/plugins/warp/tests/test-hooks.sh
index 754bdd0..65d652e 100755
--- a/plugins/warp/tests/test-hooks.sh
+++ b/plugins/warp/tests/test-hooks.sh
@@ -287,6 +287,83 @@ for HOOK in on-permission-request.sh on-prompt-submit.sh on-post-tool-use.sh; do
assert_eq "$HOOK exits 0 without protocol version" "0" "$?"
done
+echo ""
+echo "--- Windows: MSYS2 must not rewrite payload paths ---"
+
+# Git Bash rewrites path-like arguments before the child process sees them, so
+# jq received mangled values. Both the extracted cwd and caller --arg paths
+# must survive.
+PAYLOAD=$(build_payload '{"session_id":"s1","cwd":"/Users/alice/my-project"}' "stop")
+assert_json_field "cwd not rewritten" "$PAYLOAD" ".cwd" "/Users/alice/my-project"
+
+PAYLOAD=$(build_payload '{"session_id":"s1","cwd":"/Users/alice/p"}' "stop" \
+ --arg transcript_path "/tmp/transcript.jsonl")
+assert_json_field "transcript_path not rewritten" \
+ "$PAYLOAD" ".transcript_path" "/tmp/transcript.jsonl"
+
+echo ""
+echo "--- Windows: detached hooks emit nothing on stderr ---"
+
+# No controlling terminal on this process, so a /dev/tty write fails with ENXIO.
+# Reproduced by piping stdin and capturing stderr; the hooks must stay silent.
+NO_TTY_INPUT='{"session_id":"s1","cwd":"/Users/alice/p","stop_hook_active":false,"error":"rate_limit","last_assistant_message":"e","notification_type":"idle_prompt","message":"m","prompt":"p","tool_name":"Bash","tool_input":{"command":"ls"}}'
+ERR_FILE="$(mktemp)"
+
+for HOOK in on-stop.sh on-stop-failure.sh on-notification.sh on-session-start.sh \
+ on-permission-request.sh on-prompt-submit.sh on-post-tool-use.sh; do
+ : > "$ERR_FILE"
+ echo "$NO_TTY_INPUT" | \
+ WARP_CLI_AGENT_PROTOCOL_VERSION=1 \
+ WARP_CLIENT_VERSION=v0.2026.04.01.stable_01 \
+ bash "$HOOK_DIR/$HOOK" >/dev/null 2>"$ERR_FILE"
+ assert_eq "$HOOK: stderr empty" "0" "$(wc -c < "$ERR_FILE" | tr -d ' ')"
+done
+
+# All three version-dispatch branches of emit_terminal_sequence, no tty.
+for CASE in "unknown:" "old:2.1.100" "new:2.1.141"; do
+ : > "$ERR_FILE"
+ CLAUDE_CODE_VERSION="${CASE#*:}" bash -c \
+ "source '$SCRIPT_DIR/emit-terminal-sequence.sh'; emit_terminal_sequence 'SEQ'" \
+ >/dev/null 2>"$ERR_FILE"
+ assert_eq "${CASE%%:**} version: stderr empty" "0" "$(wc -c < "$ERR_FILE" | tr -d ' ')"
+done
+
+# The write must stay silent when it fails, and must report failure so the
+# caller can fall back to the JSON field.
+: > "$ERR_FILE"
+{ _tty_write "seq"; } >/dev/null 2>"$ERR_FILE" || true
+assert_eq "_tty_write: stderr empty on failed write" "0" "$(wc -c < "$ERR_FILE" | tr -d ' ')"
+
+# Control: the old unguarded pattern (stderr inherits the terminal) must still
+# leak where no usable tty exists, otherwise the assertion above is vacuous.
+if [ -e /dev/tty ] && ! _tty_write ""; then
+ assert_eq "control: unguarded write still leaks (test is meaningful)" "nonzero" \
+ "$({ printf 'x' > /dev/tty; } 2>"$ERR_FILE"; \
+ if [ "$(wc -c < "$ERR_FILE" | tr -d ' ')" -gt 0 ]; then echo nonzero; else echo zero; fi)"
+fi
+
+rm -f "$ERR_FILE"
+
+echo ""
+echo "--- Missing jq degrades silently ---"
+
+# jq is required by build_payload and emit_terminal_sequence; without it both
+# must stay quiet rather than emitting "jq: command not found" as a hook error.
+NO_JQ_PATH="/usr/bin:/bin"
+ERR_FILE="$(mktemp)"
+
+: > "$ERR_FILE"
+BODY=$(PATH="$NO_JQ_PATH" bash -c "source '$SCRIPT_DIR/build-payload.sh'; build_payload '{\"cwd\":\"/tmp/x\"}' stop" 2>"$ERR_FILE")
+assert_eq "build_payload without jq: stderr empty" "0" "$(wc -c < "$ERR_FILE" | tr -d ' ')"
+assert_eq "build_payload without jq: empty body" "" "$BODY"
+
+: > "$ERR_FILE"
+PATH="$NO_JQ_PATH" bash -c \
+ "source '$SCRIPT_DIR/emit-terminal-sequence.sh'; CLAUDE_CODE_VERSION=2.1.141 emit_terminal_sequence 'SEQ'" \
+ >/dev/null 2>"$ERR_FILE"
+assert_eq "emit_terminal_sequence without jq: stderr empty" "0" "$(wc -c < "$ERR_FILE" | tr -d ' ')"
+rm -f "$ERR_FILE"
+
# --- Summary ---
echo ""