hermes-agent/tools/notify_utils.py
Brooklyn Nicholson 82b9c44cbd fix(notify): restrict OSC to native-rendering terminals; hint osascript perms
Research finding: terminfo.dev "support" for OSC 9/777 only means the parser
consumes the sequence — VS Code/Cursor and Apple Terminal silently drop it
without rendering anything (microsoft/vscode#294247, anthropics/claude-code#28338).
Emitting OSC there made notifications no-op AND skipped the OS fallback.

- _detect_terminal_osc now returns a flavor only for terminals that actually
  render: iTerm2, Ghostty, kitty, WezTerm. Everything else (VS Code/Cursor,
  Apple Terminal, unknown) falls through to the osascript path. VS Code/Cursor
  users wanting click-to-focus can install the "Terminal Notification"
  extension, which parses the OSC we already emit — documented, not assumed.
- Add a one-time WARNING when the osascript fallback runs: on macOS Sequoia+,
  osascript notifications are attributed to "Script Editor" and silently
  dropped (exit 0, nothing shown) until the user grants Script Editor
  notification permission once. The hint spells out the fix so users aren't
  stuck staring at a no-op.
2026-06-18 15:33:00 -05:00

442 lines
17 KiB
Python

"""Desktop notification delivery for the /notify slash command.
All functions are fail-safe — notification errors are logged but never
propagate to the agent loop.
Cross-platform: Linux (notify-send), macOS (osascript),
Windows (PowerShell), and WSL (bridges to Windows via powershell.exe,
preferring notify-send via WSLg when available).
"""
import hashlib
import logging
import os
import platform
import shutil
import subprocess
from pathlib import Path
from typing import Optional
logger = logging.getLogger(__name__)
_SYSTEM = platform.system()
def _hermes_home() -> Path:
from hermes_constants import get_hermes_home
return get_hermes_home()
# ---------------------------------------------------------------------------
# WSL detection
# ---------------------------------------------------------------------------
_WSL_CACHE: Optional[bool] = None
def _is_wsl() -> bool:
"""Return True when running under Windows Subsystem for Linux."""
global _WSL_CACHE
if _WSL_CACHE is not None:
return _WSL_CACHE
try:
with open("/proc/version", "r") as f:
_WSL_CACHE = "microsoft" in f.read().lower()
except Exception:
_WSL_CACHE = False
return _WSL_CACHE
# ---------------------------------------------------------------------------
# Sentinel file (per-session)
# ---------------------------------------------------------------------------
#
# The pending-notify flag is scoped to a *session*, not the whole process.
# The TUI gateway and dashboard serve many concurrent sessions from one
# process sharing one HERMES_HOME; a single global sentinel would let a
# ``/notify`` set in session A fire on session B's turn completion. Keying
# the sentinel by HERMES_SESSION_KEY keeps each session's pending flag
# independent. Classic single-process CLI has no session key and falls back
# to the unsuffixed default file — same behavior as before.
def _resolve_session_key(session_key: Optional[str]) -> str:
"""Resolve the session key for the current context.
Explicit *session_key* wins (used by the TUI gateway, which serves many
sessions from one process and must name them explicitly). Otherwise read
``HERMES_SESSION_KEY`` from the session context — a contextvar bound
per-turn in the gateway, or ``os.environ`` in the classic CLI and the
slash worker. Falls back to ``""`` (the default sentinel).
"""
if session_key is not None:
return session_key
try:
from gateway.session_context import get_session_env
return get_session_env("HERMES_SESSION_KEY", "") or ""
except Exception:
return ""
def _sentinel_name(session_key: str) -> str:
key = (session_key or "").strip()
if not key:
return ".notify_pending"
digest = hashlib.sha1(key.encode("utf-8", "replace")).hexdigest()[:16]
return f".notify_pending-{digest}"
def get_notify_sentinel_path(session_key: Optional[str] = None) -> Path:
return _hermes_home() / _sentinel_name(_resolve_session_key(session_key))
def set_notify_flag(session_key: Optional[str] = None) -> bool:
"""Write the sentinel file to signal a pending notification."""
try:
p = get_notify_sentinel_path(session_key)
p.parent.mkdir(parents=True, exist_ok=True)
p.touch()
return True
except Exception as e:
logger.warning("Failed to write notify sentinel: %s", e)
return False
def clear_notify_flag(session_key: Optional[str] = None) -> bool:
"""Remove the sentinel file (cancel or consume notification)."""
try:
p = get_notify_sentinel_path(session_key)
if not p.exists():
return False
p.unlink()
return True
except Exception as e:
logger.warning("Failed to clear notify sentinel: %s", e)
return False
def is_notify_pending(session_key: Optional[str] = None) -> bool:
"""Check if a notification is pending for this session."""
return get_notify_sentinel_path(session_key).exists()
# ---------------------------------------------------------------------------
# Desktop notification
# ---------------------------------------------------------------------------
def _notify_send_available() -> bool:
"""Return True if notify-send is available and D-Bus is reachable."""
if not shutil.which("notify-send"):
return False
# Quick smoke-test: verify D-Bus notification service exists
try:
result = subprocess.run(
["notify-send", "--version"],
timeout=3, capture_output=True,
)
return result.returncode == 0
except Exception:
return False
def _show_notification_linux(title: str, message: str) -> None:
"""Desktop notification on native Linux via notify-send."""
try:
subprocess.run(
["notify-send", title, message],
timeout=5, capture_output=True,
)
logger.debug("notify: Linux notification sent via notify-send")
except FileNotFoundError:
logger.debug("notify: notify-send not found on Linux")
except subprocess.TimeoutExpired:
logger.debug("notify: notify-send timed out on Linux")
def _ps_single_quote(value: str) -> str:
"""Quote a string for a single-quoted PowerShell literal."""
return "'" + value.replace("'", "''") + "'"
def _show_notification_wsl(title: str, message: str) -> None:
"""Desktop notification in WSL via Windows balloon tip (PowerShell)."""
logger.debug("notify: attempting WSL notification via PowerShell")
try:
ps_code = (
"Add-Type -AssemblyName System.Windows.Forms; "
"$n = New-Object System.Windows.Forms.NotifyIcon; "
"$n.Icon = [System.Drawing.SystemIcons]::Information; "
f"$n.BalloonTipTitle = {_ps_single_quote(title)}; "
f"$n.BalloonTipText = {_ps_single_quote(message)}; "
"$n.Visible = $true; "
"$n.ShowBalloonTip(3000); "
"[System.Windows.Forms.Application]::DoEvents(); "
"Start-Sleep -Seconds 4; "
"$n.Dispose()"
)
result = subprocess.run(
["powershell.exe", "-c", ps_code],
timeout=8, capture_output=True,
)
if result.returncode != 0:
logger.debug("notify: PowerShell balloon failed (rc=%d, stderr=%s)",
result.returncode, result.stderr.decode(errors="replace")[:200])
else:
logger.debug("notify: WSL notification sent via PowerShell")
except subprocess.TimeoutExpired:
logger.debug("notify: PowerShell balloon timed out")
except FileNotFoundError:
logger.debug("notify: powershell.exe not found — is WSL properly configured?")
except Exception as e:
logger.warning("WSL notification failed: %s", e)
def _show_notification_macos(title: str, message: str) -> None:
"""Desktop notification on macOS.
Prefer ``terminal-notifier`` when it's on PATH: it ships a real app
bundle, so notifications attribute to it, show as banners, and are
grantable in System Settings → Notifications. Plain ``osascript display
notification`` attributes to the *launching* process — for an unsigned
CLI that often can't register an app entry, so macOS delivers it silently
to Notification Center with no banner (and no toggle the user can flip).
Install with ``brew install terminal-notifier`` for reliable banners;
otherwise fall back to osascript.
"""
tn = shutil.which("terminal-notifier")
if tn:
try:
subprocess.run(
[tn, "-title", title, "-message", message],
timeout=5, capture_output=True,
)
logger.debug("notify: macOS notification sent via terminal-notifier")
return
except Exception as e:
logger.debug(
"notify: terminal-notifier failed (%s), falling back to osascript", e
)
try:
escaped_title = title.replace('\\', '\\\\').replace('"', '\\"')
escaped_message = message.replace('\\', '\\\\').replace('"', '\\"')
subprocess.run(
["osascript", "-e",
f"display notification \"{escaped_message}\" with title \"{escaped_title}\""],
timeout=5, capture_output=True,
)
logger.debug("notify: macOS notification sent via osascript")
_osascript_permission_hint_once()
except Exception as e:
logger.debug("notify: osascript notification failed: %s", e)
_OSASCRIPT_HINT_SHOWN = False
def _osascript_permission_hint_once() -> None:
"""Warn once that osascript notifications need Script Editor permission.
On macOS Sequoia+, ``osascript display notification`` is attributed to
``com.apple.ScriptEditor2`` and is silently dropped until the user grants
Script Editor notification permission — the command still exits 0, so a
user sees "nothing happened" with no error. Surface the one-time fix so
they aren't stuck. (Native terminals using the OSC path never reach here.)
"""
global _OSASCRIPT_HINT_SHOWN
if _OSASCRIPT_HINT_SHOWN:
return
_OSASCRIPT_HINT_SHOWN = True
logger.warning(
"Desktop notifications use osascript on this terminal, which macOS "
"delivers as 'Script Editor' — banners are suppressed until you grant "
"permission once: run `open -a 'Script Editor'`, execute "
"`display notification \"test\" with title \"test\"` inside it, click "
"Allow, then check System Settings > Notifications > Script Editor. "
"For native banners, run Hermes in iTerm2/Ghostty/kitty/WezTerm, or "
"install the VS Code 'Terminal Notification' extension for Cursor."
)
# ---------------------------------------------------------------------------
# Terminal-native notifications (OSC escape sequences)
# ---------------------------------------------------------------------------
#
# The most reliable way to notify from an unsigned CLI — especially on modern
# macOS, where ``osascript`` notifications are permanently stuck under the
# "Script Editor" identity (Apple removed sender override in Monterey) and
# ``terminal-notifier`` is broken on recent releases. Here the *terminal
# emulator itself* raises the banner: it's attributed to the terminal (which
# the user already trusts), clicking it focuses the terminal, and there's no
# extra dependency.
#
# We emit the escape sequence directly to the controlling terminal
# (``/dev/tty``), NOT stdout — the TUI/slash-worker capture stdout, and the
# sequences are non-rendering so they don't disturb a live TUI screen.
#
# Two flavors cover the field (see terminfo.dev): OSC 9 (iTerm2-style, single
# string) and OSC 777 (urxvt-style, title+body). We pick ONE per terminal via
# TERM_PROGRAM/env so a terminal that supports both doesn't double-fire.
# Apple Terminal ignores both → returns False so the caller falls back.
def _detect_terminal_osc() -> Optional[str]:
"""Return the OSC notification flavor for the current terminal, or None.
Only terminals that actually *render* an OS notification from the escape
sequence are listed. terminfo.dev marks many terminals as "supporting"
OSC 9/777, but that only means their parser consumes the sequence — VS
Code/Cursor and Apple Terminal silently drop it without showing anything
(confirmed: microsoft/vscode#294247, anthropics/claude-code#28338). Those
return None so the caller falls back to an OS-level notifier (osascript).
VS Code / Cursor users who want click-to-focus can install the "Terminal
Notification" extension (it parses OSC 9/777 from the terminal stream);
that's a user-side opt-in, not something we can assume here.
"""
if os.environ.get("KITTY_WINDOW_ID"):
return "osc9" # kitty also speaks the legacy OSC 9
if os.environ.get("WEZTERM_PANE") or os.environ.get("GHOSTTY_RESOURCES_DIR"):
return "osc777"
tp = os.environ.get("TERM_PROGRAM", "")
return {
"iTerm.app": "osc9",
"WarpTerminal": "osc9",
"ghostty": "osc777",
"WezTerm": "osc777",
}.get(tp)
def _tmux_wrap(seq: str) -> str:
"""Wrap an escape sequence for tmux passthrough so it reaches the outer
terminal. Requires ``set -g allow-passthrough on`` in tmux >= 3.3."""
return "\033Ptmux;" + seq.replace("\033", "\033\033") + "\033\\"
def _emit_terminal_notification(title: str, message: str) -> bool:
"""Emit an OSC desktop-notification sequence to the controlling terminal.
Returns True when written to a terminal known to support it. Fully
fail-safe.
"""
kind = _detect_terminal_osc()
if not kind:
return False
if kind == "osc777":
seq = f"\033]777;notify;{title};{message}\007"
else: # osc9 — single string
seq = f"\033]9;{title}: {message}\007" if title else f"\033]9;{message}\007"
if os.environ.get("TMUX"):
seq = _tmux_wrap(seq)
if _write_tty(seq):
logger.debug("notify: terminal notification sent via %s", kind)
return True
return False
def _write_tty(payload: str) -> bool:
"""Write *payload* to the controlling terminal (/dev/tty). Fail-safe."""
try:
with open("/dev/tty", "w") as tty:
tty.write(payload)
tty.flush()
return True
except Exception as e:
logger.debug("notify: /dev/tty write failed: %s", e)
return False
def _show_desktop_notification(title: str, message: str) -> None:
"""Show a desktop notification bubble.
Order of preference:
1. Terminal-native OSC sequence (works in iTerm2/Ghostty/kitty/WezTerm/
Warp/VS Code/Cursor; reliable for an unsigned CLI on modern macOS).
2. OS-level fallback per platform (notify-send / terminal-notifier /
osascript / PowerShell). Used for Apple Terminal and unknown terminals.
"""
try:
if _emit_terminal_notification(title, message):
return
if _is_wsl():
# WSLg path: notify-send bridges to native Windows notifications
if _notify_send_available():
logger.debug("notify: WSLg notify-send available, using D-Bus path")
_show_notification_linux(title, message)
return
logger.debug("notify: notify-send not available in WSL, falling back to PowerShell")
_show_notification_wsl(title, message)
elif _SYSTEM == "Linux":
_show_notification_linux(title, message)
elif _SYSTEM == "Darwin":
_show_notification_macos(title, message)
elif _SYSTEM == "Windows":
ps_code = (
"Add-Type -AssemblyName System.Windows.Forms; "
"$n = New-Object System.Windows.Forms.NotifyIcon; "
"$n.Icon = [System.Drawing.SystemIcons]::Information; "
f"$n.BalloonTipTitle = {_ps_single_quote(title)}; "
f"$n.BalloonTipText = {_ps_single_quote(message)}; "
"$n.Visible = $true; "
"$n.ShowBalloonTip(3000); "
"Start-Sleep -Seconds 4"
)
subprocess.run(
["powershell", "-c", ps_code],
timeout=8, capture_output=True,
)
logger.debug("notify: Windows notification sent via PowerShell")
except Exception as e:
logger.debug("Desktop notification failed: %s", e)
# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------
def fire_notification(
*,
title: str = "Hermes Agent",
message: str = "Task complete",
) -> None:
"""Fire a desktop notification.
All errors are caught silently — notification failure must never
crash the idle loop.
Args:
title: Desktop notification title.
message: Desktop notification body.
"""
_show_desktop_notification(title, message)
def fire_approval_request_notification() -> None:
"""Notify that Hermes is blocked waiting for command approval.
This intentionally does not clear the /notify sentinel; the final
turn-complete notification should still fire after the user responds.
"""
fire_notification(message="Input needed: approval required")
def consume_pending_notification(
session_key: Optional[str] = None,
*,
title: str = "Hermes Agent",
message: str = "Task complete",
) -> bool:
"""Fire-and-clear the pending notification for *session_key*, if any.
Single entry point for the turn-complete sites (CLI idle loop, CLI
process loop, TUI gateway success/error paths) so the
check→clear→fire sequence lives in one place. Returns True when a
notification was fired. Fully fail-safe.
"""
try:
if is_notify_pending(session_key):
clear_notify_flag(session_key)
fire_notification(title=title, message=message)
return True
except Exception as e:
logger.debug("notify consume failed: %s", e)
return False