diff --git a/cli.py b/cli.py index a6e72e6519..bc8c66c7fe 100644 --- a/cli.py +++ b/cli.py @@ -16167,13 +16167,18 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin): outcome = outcome[:119] + "…" _cprint(f"\n{_DIM}{icon} {label}: {detail} → {outcome}{_RST}") - def _ring_bell(self, prompt: bool = False) -> None: + def _ring_bell(self, prompt: bool = False, context: str = "", detail: str = "") -> None: """Write a terminal bell (\\a) if the matching display.bell_* flag is on. ``prompt=True`` is the blocking-modal variant (clarify / approval / sudo / secret capture) gated by ``display.bell_on_prompt``; the default is the end-of-turn bell gated by ``display.bell_on_complete``. Works over SSH — the BEL propagates to the user's terminal. + + The same flag also emits an OSC 9 desktop notification (Ghostty, + iTerm2, Kitty, WezTerm) and, inside a supporting Warp build, a + ``warp://cli-agent`` OSC 777 event — see ``hermes_cli.terminal_notify``. + ``context`` is the short notification body (e.g. "approval"). """ flag = "bell_on_prompt" if prompt else "bell_on_complete" if not getattr(self, flag, False): @@ -16183,6 +16188,17 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin): sys.stdout.flush() except Exception: pass + try: + from hermes_cli.terminal_notify import notify as _terminal_notify + + _terminal_notify( + context or ("input needed" if prompt else "turn complete"), + prompt=prompt, + session_id=getattr(self, "session_id", "") or "", + detail=detail, + ) + except Exception: + pass def _clarify_callback(self, question, choices, multi_select=False, questions=None): """ @@ -16231,7 +16247,7 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin): self._clarify_freetext = is_open_ended self._clarify_multi_base = None - self._ring_bell(prompt=True) + self._ring_bell(prompt=True, context="clarify") # Trigger an immediate prompt_toolkit repaint from this (non-main) # thread. Modal prompts must paint at once and must not be gated by the # _invalidate throttle / resize guard — see _paint_now / _invalidate (#41098). @@ -16423,7 +16439,7 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin): self._clarify_state = state self._clarify_batch_set_active(state, 0) self._clarify_deadline = None if timeout <= 0 else _time.monotonic() + timeout - self._ring_bell(prompt=True) + self._ring_bell(prompt=True, context="clarify") self._paint_now() _last_countdown_refresh = _time.monotonic() @@ -16474,7 +16490,7 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin): "response_queue": response_queue, } self._sudo_deadline = _time.monotonic() + timeout - self._ring_bell(prompt=True) + self._ring_bell(prompt=True, context="sudo password") # Modal prompt — paint immediately, bypassing the throttle/resize guard # so the prompt can't be dropped and time out unseen (#41098). @@ -16544,7 +16560,7 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin): } self._approval_deadline = _time.monotonic() + timeout - self._ring_bell(prompt=True) + self._ring_bell(prompt=True, context="approval", detail=command) # Modal prompt — paint immediately, bypassing the throttle/resize # guard. A throttled paint here can be silently dropped (250ms # window collision or in-flight resize), leaving the panel unseen so @@ -17688,7 +17704,7 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin): # Play terminal bell when agent finishes (if enabled). # Works over SSH — the bell propagates to the user's terminal. - self._ring_bell() + self._ring_bell(context="turn complete") # Notify when iteration budget was hit if result and not result.get("completed") and not result.get("interrupted"): diff --git a/hermes_cli/callbacks.py b/hermes_cli/callbacks.py index 58d1a8390e..903bc6709b 100644 --- a/hermes_cli/callbacks.py +++ b/hermes_cli/callbacks.py @@ -121,7 +121,7 @@ def prompt_for_secret(cli, var_name: str, prompt: str, metadata=None) -> dict: } cli._secret_deadline = _time.monotonic() + timeout if hasattr(cli, "_ring_bell"): - cli._ring_bell(prompt=True) + cli._ring_bell(prompt=True, context=f"secret needed ({var_name})") # Avoid storing stale draft input as the secret when Enter is pressed. if hasattr(cli, "_clear_secret_input_buffer"): try: diff --git a/hermes_cli/terminal_notify.py b/hermes_cli/terminal_notify.py new file mode 100644 index 0000000000..6c1877cd8c --- /dev/null +++ b/hermes_cli/terminal_notify.py @@ -0,0 +1,99 @@ +"""Terminal-native desktop notifications: OSC 9 and Warp's OSC 777 CLI-agent protocol. + +Both emitters ride on the existing ``display.bell_on_prompt`` / +``display.bell_on_complete`` flags (see ``cli._ring_bell``) — no extra config. + +- **OSC 9** (``ESC ] 9 ; BEL``): Ghostty, iTerm2, Kitty and WezTerm + raise an OS notification; terminals that don't know the sequence drop it. +- **OSC 777** (``ESC ] 777 ; notify ; warp://cli-agent ; BEL``): Warp's + structured CLI-agent protocol (tab status + notification mailbox). Only sent + when Warp advertises support and the build is newer than the last release + that set the protocol var without being able to render the payload. + +Sequences are written to ``/dev/tty`` because prompt_toolkit's stdout wrapper +can buffer or strip raw escapes; when ``/dev/tty`` can't be opened (Windows, +no controlling terminal) they fall back to ``sys.stdout``. Never raises. +""" + +from __future__ import annotations + +import json +import os +import re +import sys + +_C0_AND_DEL = re.compile(r"[\x00-\x1f\x7f]") +_WARP_PROTOCOL_VERSION = 1 +# Last Warp release per channel that set WARP_CLI_AGENT_PROTOCOL_VERSION but +# could not render structured payloads (Warp's reference agent plugin, +# should-use-structured.sh). Bash compares these lexicographically; so do we. +_WARP_LAST_BROKEN = { + "stable": "v0.2026.03.25.08.24.stable_05", + "preview": "v0.2026.03.25.08.24.preview_05", +} + + +def _write_tty(seq: str) -> None: + """Write raw escapes to /dev/tty, falling back to sys.stdout. Never raises.""" + try: + with open("/dev/tty", "w", encoding="utf-8") as tty: + tty.write(seq) + return + except OSError: + pass + try: + sys.stdout.write(seq) + sys.stdout.flush() + except Exception: + pass + + +def osc9(body: str) -> str: + """OSC 9 sequence with C0 controls and DEL stripped from the body.""" + return f"\x1b]9;{_C0_AND_DEL.sub('', body)}\x07" + + +def warp_supported(env=None) -> bool: + """True when running in a Warp build that can render OSC 777 agent payloads.""" + env = os.environ if env is None else env + if env.get("TERM_PROGRAM") != "WarpTerminal" or not env.get("WARP_CLI_AGENT_PROTOCOL_VERSION"): + return False + client = env.get("WARP_CLIENT_VERSION", "") + if not client: + return False + for channel, last_broken in _WARP_LAST_BROKEN.items(): + if channel in client and client <= last_broken: + return False + return True + + +def warp_osc777(event: str, detail: str, session_id: str = "") -> str: + """OSC 777 ``warp://cli-agent`` notification; ``event`` is ``stop`` or ``permission_request``. + + Payload mirrors the reference plugin's build-payload.sh: common fields plus + ``summary`` (permission_request) or ``response`` (stop), truncated to 200. + """ + try: + advertised = int(os.environ.get("WARP_CLI_AGENT_PROTOCOL_VERSION", "1")) + except ValueError: + advertised = 1 + cwd = os.getcwd() + payload = { + "v": min(advertised, _WARP_PROTOCOL_VERSION), + "agent": "hermes", + "event": event, + "session_id": session_id, + "cwd": cwd, + "project": os.path.basename(cwd), + } + payload["summary" if event == "permission_request" else "response"] = detail[:200] + return f"\x1b]777;notify;warp://cli-agent;{json.dumps(payload, separators=(',', ':'))}\x07" + + +def notify(context: str, *, prompt: bool, session_id: str = "", detail: str = "") -> None: + """Emit OSC 9 (plus Warp OSC 777 when supported) for a blocking prompt or turn end.""" + seq = osc9(f"Hermes: {context}") + if warp_supported(): + event = "permission_request" if prompt else "stop" + seq += warp_osc777(event, detail or context, session_id) + _write_tty(seq)