Skip to content

fix: deliver OSC notifications when hooks have no controlling tty#44

Open
ThomasCrul wants to merge 1 commit into
warpdotdev:mainfrom
ThomasCrul:fix/notify-no-controlling-tty
Open

fix: deliver OSC notifications when hooks have no controlling tty#44
ThomasCrul wants to merge 1 commit into
warpdotdev:mainfrom
ThomasCrul:fix/notify-no-controlling-tty

Conversation

@ThomasCrul
Copy link
Copy Markdown

Fix notifications when hooks have no controlling tty

Summary

When the plugin's hooks run inside a sandbox that detaches the subprocess from its controlling terminal, /dev/tty becomes unopenable — open("/dev/tty") returns ENXIO because the process is in a new session with no controlling tty. Every printf > /dev/tty in warp-notify.sh then silently fails, so no OSC reaches Warp and the agent indicators (blue dot, in-progress / blocked badges, completion toasts) never show.

The concrete trigger is Claude Code's macOS sandbox-exec wrapper for Bash tool calls, opted into via "sandbox": {"enabled": true} in .claude/settings.json (a sandbox feature it added recently). Other sandboxers (firejail, etc.) that detach the controlling tty would hit the same failure.

This PR makes the notify path resilient: when /dev/tty is unavailable, walk the process tree and try each ancestor's tty in turn. The first one that accepts the write (typically claude itself, which lives in the Warp pane and isn't sandboxed) is used.

Behavior is unchanged for users not running in a tty-detached sandbox.

Repro

Requires macOS + Claude Code + Warp.

  1. Enable Claude Code's bash sandbox in your project's .claude/settings.json:
    { "sandbox": { "enabled": true } }
  2. Run claude inside Warp from that project directory.
  3. Send any prompt. The plugin's hooks (SessionStart, Stop, Notification, UserPromptSubmit, PostToolUse) all fire and call warp-notify.sh, but Warp shows no agent indicator and no completion toast.

Instrumenting warp-notify.sh to capture the redirection error shows:

controlling_tty=??
/dev/tty test: char-device
/dev/tty readable: yes, writable: yes      # the `test` builtin lies — the device node exists
/dev/tty write: rc=1 err='warp-notify.sh: /dev/tty: Device not configured'

Writing to the parent process's actual pty (e.g. /dev/ttys003) succeeds and Warp displays the indicator.

Changes

  • New plugins/warp/scripts/find-controlling-tty.sh — exposes find_candidate_ttys, which walks $PPID upward via ps -o tty= / ps -o ppid= and prints each ancestor's tty device path (one per line), skipping ancestors with no controlling tty. Depth-capped at 20 hops to bound the walk.
  • plugins/warp/scripts/warp-notify.sh (structured) — try /dev/tty first; on failure, iterate find_candidate_ttys and write to each until one accepts the bytes.
  • plugins/warp/scripts/legacy/warp-notify.sh — same fallback.
  • New plugins/warp/tests/test-find-controlling-tty.sh — 5 cases: direct-parent hit, walk-past-??-ancestors, multiple-ancestor emission (nested case), no-tty-anywhere, depth-limit termination. Mocks ps as a shell function.

Existing tests/test-hooks.sh continues to pass (40/40); new tests add 5 more.

Caveats

  • Workaround, not root-cause fix. The proper place to solve this is in Claude Code — either don't sandbox hooks, or expose a non-tty notification channel — but neither is in this plugin's control. Happy to link a Claude Code issue if one's tracked.
  • ps cost. Worst case is 2 calls per hop × 20 hops = 40 ps invocations; in practice 2-4 calls. Sub-millisecond on modern hardware.
  • No "is this Warp?" check on ancestors. 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 rather than corruption.
  • Breaks if Claude Code ever sandboxes its own root process. The fallback works today because only the bash hook subprocess is sandboxed; claude itself runs in the Warp pane and is reachable via the tree walk.
  • ps -o tty= output format. macOS reports ttys003, Linux reports pts/3; both prefix correctly to /dev/.... Not tested on other Unixes (the plugin targets Warp, which is macOS / Linux only).

Relation to other PRs

  • fix: TTY detection for hook subprocesses #19 (open since Apr 15) takes the same general approach — walking the parent chain. This PR factors the walk into a reusable helper so both the structured and legacy warp-notify.sh paths share it, emits all writable candidates (so a failed write to one ancestor falls through to the next, useful in nested setups), updates both scripts, and adds dedicated unit tests for the helper. Happy to defer to fix: TTY detection for hook subprocesses #19 if preferred — flagging this purely so the duplication is visible.
  • Add IPC fallback for Warp notifications #40 (May 12) is a complementary forward-looking change: prefer WARP_CLI_AGENT_IPC when Warp exposes it. That env var isn't set on current stable Warp (v0.2026.05.06.15.42.stable_05) on my machine, so it doesn't help today, but it's the cleaner long-term channel and this PR would naturally fall back to it once Warp sets it (the /dev/tty path still gets tried first; if both fail, the ancestor walk runs).
  • fix: bound /dev/tty write so hooks can't hang when Warp UI is unresponsive #37 (bounds /dev/tty writes against UI hangs) is unrelated but in the same general area.

Test plan

  • bash plugins/warp/tests/test-hooks.sh — 40/40 pass
  • bash plugins/warp/tests/test-find-controlling-tty.sh — 5/5 pass
  • Manual: with sandbox.enabled: true in .claude/settings.json, fresh claude in Warp shows agent indicators (blue dot, in-progress badge, completion toast). With sandbox off, behavior is unchanged.

When the plugin's hooks run inside a sandbox that detaches the subprocess
from its controlling terminal (e.g. Claude Code's macOS sandbox-exec
wrapper for Bash tool calls, opted into via `sandbox.enabled` in
`.claude/settings.json`), `open("/dev/tty")` returns ENXIO and every
notification silently fails. Fall back to walking the process tree and
writing the OSC sequence directly to the first ancestor tty that accepts
the bytes — typically the un-sandboxed `claude` process in the Warp pane.

- Add `find-controlling-tty.sh` exposing `find_candidate_ttys`, which
  prints each ancestor's tty device path (one per line), skipping
  ancestors with no controlling tty. Depth-capped at 20 hops.
- Update both `warp-notify.sh` (structured) and `legacy/warp-notify.sh`
  to try `/dev/tty` first and iterate candidates on failure.
- Add `test-find-controlling-tty.sh` with 5 cases (direct-parent hit,
  walk-past-?? ancestors, multiple-ancestor emission, no-tty-anywhere,
  depth-limit termination), mocking `ps` as a shell function.

Behavior is unchanged for users not running in a tty-detached sandbox.
skspade added a commit to skspade/claude-code-warp that referenced this pull request May 13, 2026
skspade added a commit to skspade/claude-code-warp that referenced this pull request May 13, 2026
Resolution: took pr-27's version of on-post-tool-use.sh and test-hooks.sh.
This sacrifices warpdotdev#35's PostToolUse-specific perf inline-jq optimization
(which used a direct /dev/tty write, bypassing warpdotdev#44's tty walker) in
favor of using the patched warp-notify.sh helper, plus gains
tool_preview and permission_mode fields.
skspade added a commit to skspade/claude-code-warp that referenced this pull request May 13, 2026
… Warp UI is unresponsive

Resolution: hybrid of warpdotdev#44's tty walker and warpdotdev#37's timeout watchdog.
Each candidate tty is now tried with a TIMEOUT_SEC watchdog (default 2s)
so a hung Warp UI on one tty falls through to the next ancestor instead
of blocking forever.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant