From eb20289f968df4c23f8a8aee5a5051e67134ded3 Mon Sep 17 00:00:00 2001 From: PCinkusz Date: Sun, 3 May 2026 17:55:07 +0200 Subject: [PATCH] feat(cli): add local notify command --- cli.py | 88 ++++++++++ hermes_cli/commands.py | 2 + tests/test_notify_approval.py | 140 ++++++++++++++++ tools/approval.py | 38 ++++- tools/notify_utils.py | 232 +++++++++++++++++++++++++++ tui_gateway/server.py | 30 ++++ ui-tui/src/app/slash/commands/ops.ts | 22 +++ 7 files changed, 551 insertions(+), 1 deletion(-) create mode 100644 tests/test_notify_approval.py create mode 100644 tools/notify_utils.py diff --git a/cli.py b/cli.py index 4ca07fa0bf5..09bc062565e 100644 --- a/cli.py +++ b/cli.py @@ -7137,6 +7137,53 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin): except Exception: return False + def _should_handle_notify_command_inline(self, text: str, has_images: bool = False) -> bool: + """Return True when /notify should be dispatched mid-task. + + Same pattern as /steer: write the sentinel file without queuing + through _pending_input (which would miss the mid-run window). + """ + if not text or has_images or not _looks_like_slash_command(text): + return False + if not getattr(self, "_agent_running", False): + return False + try: + from hermes_cli.commands import resolve_command + base = text.split(None, 1)[0].lower().lstrip('/') + cmd = resolve_command(base) + return bool(cmd and cmd.name == "notify") + except Exception: + return False + + def _handle_notify_command(self, cmd_original: str): + """Handle /notify [prompt | cancel]. + + - /notify — set flag + submit prompt + - /notify — set flag only (mid-task or pre-turn) + - /notify cancel — clear pending notification + """ + from tools.notify_utils import set_notify_flag, clear_notify_flag + + parts = cmd_original.split(None, 1) + sub = parts[1].strip() if len(parts) > 1 else "" + + if sub.lower() == "cancel": + if clear_notify_flag(): + _cprint(" 🔕 Notification cancelled") + else: + _cprint(" No pending notification") + return + + set_notify_flag() + + if sub: + # Has a prompt — set flag AND submit + self._pending_input.put(sub) + _cprint(f" 🔔 Will notify when done: " + f"{sub[:80]}{'...' if len(sub) > 80 else ''}") + else: + _cprint(" 🔔 Will notify when this turn finishes") + def _output_console(self): """Use prompt_toolkit-safe Rich rendering once the TUI is live.""" if getattr(self, "_app", None): @@ -7625,6 +7672,8 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin): _cprint(f" Queued for the next turn: {payload[:80]}{'...' if len(payload) > 80 else ''}") else: _cprint(f" Queued: {payload[:80]}{'...' if len(payload) > 80 else ''}") + elif canonical == "notify": + self._handle_notify_command(cmd_original) elif canonical == "steer": # Inject a message after the next tool call without interrupting. # If the agent is actively running, push the text into the agent's @@ -10389,6 +10438,21 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin): # Flush any remaining streamed text and close the box self._flush_stream() + # Notify check — fire if /notify was set for this turn. + # Must run BEFORE goal continuation so the notification + # reflects actual turn completion (mirrors the TUI path). + try: + from tools.notify_utils import ( + is_notify_pending, + clear_notify_flag, + fire_notification, + ) + if is_notify_pending(): + clear_notify_flag() + fire_notification() + except Exception as e: + logging.debug("notify idle-check failed: %s", e) + # Signal end-of-text to TTS consumer and wait for it to finish if use_streaming_tts and text_queue is not None: text_queue.put(None) # sentinel @@ -11279,6 +11343,14 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin): event.app.invalidate() return + # Handle /notify while the agent is running — same pattern + # as /steer: write the sentinel file on the UI thread so it + # survives mid-run without queueing through _pending_input. + if self._should_handle_notify_command_inline(text, has_images=has_images): + self.process_command(text) + event.app.current_buffer.reset(append_to_history=True) + return + # Snapshot and clear attached images images = list(self._attached_images) self._attached_images.clear() @@ -13054,6 +13126,22 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin): self._pending_tool_info.clear() self._last_scrollback_tool = "" + # Notify check — fire if /notify was set during this turn. + # Must run BEFORE goal continuation so the notification + # reflects the actual turn completion, not a + # potentially-auto-continued turn. + try: + from tools.notify_utils import ( + is_notify_pending, + clear_notify_flag, + fire_notification, + ) + if is_notify_pending(): + clear_notify_flag() + fire_notification() + except Exception as e: + logging.debug("notify idle-check failed: %s", e) + app.invalidate() # Refresh status line # Goal continuation: if a standing goal is active, ask diff --git a/hermes_cli/commands.py b/hermes_cli/commands.py index f81d50eace9..7df8eb78306 100644 --- a/hermes_cli/commands.py +++ b/hermes_cli/commands.py @@ -103,6 +103,8 @@ COMMAND_REGISTRY: list[CommandDef] = [ aliases=("tasks",)), CommandDef("queue", "Queue a prompt for the next turn (doesn't interrupt)", "Session", aliases=("q",), args_hint=""), + CommandDef("notify", "Set a desktop notification when Hermes finishes this turn", "Session", + args_hint="[prompt | cancel]", cli_only=True), CommandDef("steer", "Inject a message after the next tool call without interrupting", "Session", args_hint=""), CommandDef("goal", "Set a standing goal Hermes works on across turns until achieved", "Session", diff --git a/tests/test_notify_approval.py b/tests/test_notify_approval.py new file mode 100644 index 00000000000..cd0b411225b --- /dev/null +++ b/tests/test_notify_approval.py @@ -0,0 +1,140 @@ +"""Tests for /notify approval-request notifications.""" + + +def test_fire_approval_request_notification_uses_input_needed_message(monkeypatch): + from tools import notify_utils + + calls = [] + monkeypatch.setattr( + notify_utils, + "fire_notification", + lambda *, title="Hermes Agent", message="Task complete", config=None: calls.append( + {"title": title, "message": message, "config": config} + ), + ) + + notify_utils.fire_approval_request_notification() + + assert calls == [ + {"title": "Hermes Agent", "message": "Input needed: approval required", "config": None} + ] + + +def test_fire_approval_request_notification_does_not_clear_pending_notify(monkeypatch, tmp_path): + from tools import notify_utils + + monkeypatch.setattr(notify_utils, "_hermes_home", lambda: tmp_path) + monkeypatch.setattr(notify_utils, "fire_notification", lambda **kwargs: None) + notify_utils.set_notify_flag() + + notify_utils.fire_approval_request_notification() + + assert notify_utils.is_notify_pending() is True + + +def test_approval_request_notification_skips_messaging_gateway_platform(monkeypatch): + from gateway import session_context + from tools import approval + from tools import notify_utils + + calls = [] + monkeypatch.setattr( + session_context, + "get_session_env", + lambda name, default="": "telegram" if name == "HERMES_SESSION_PLATFORM" else default, + ) + monkeypatch.setattr(notify_utils, "is_notify_pending", lambda: True) + monkeypatch.setattr(notify_utils, "fire_approval_request_notification", lambda: calls.append("approval")) + + approval._notify_approval_request_if_pending() + + assert calls == [] + + +def test_check_all_command_guards_notifies_when_cli_approval_requested(monkeypatch): + from tools import approval + from tools import notify_utils + + calls = [] + monkeypatch.setenv("HERMES_INTERACTIVE", "1") + monkeypatch.delenv("HERMES_GATEWAY_SESSION", raising=False) + monkeypatch.delenv("HERMES_EXEC_ASK", raising=False) + monkeypatch.delenv("HERMES_YOLO_MODE", raising=False) + monkeypatch.setattr(approval, "_get_approval_mode", lambda: "manual") + monkeypatch.setattr(approval, "detect_hardline_command", lambda command: (False, None)) + monkeypatch.setattr( + approval, + "detect_dangerous_command", + lambda command: (True, "dangerous:test", "test approval"), + ) + monkeypatch.setattr(approval, "is_approved", lambda session_key, pattern_key: False) + monkeypatch.setattr(approval, "prompt_dangerous_approval", lambda *args, **kwargs: "deny") + monkeypatch.setattr(notify_utils, "is_notify_pending", lambda: True) + monkeypatch.setattr(notify_utils, "fire_approval_request_notification", lambda: calls.append("approval")) + + result = approval.check_all_command_guards("rm -rf /tmp/demo", "local") + + assert result["approved"] is False + assert calls == ["approval"] + + +def test_gateway_approval_prompt_is_emitted_before_desktop_notification(monkeypatch): + from tools import approval + from tools import notify_utils + + calls = [] + session_key = "notify-order-session" + + def notify_cb(_approval_data): + calls.append("approval-prompt") + approval.resolve_gateway_approval(session_key, "deny") + + monkeypatch.setenv("HERMES_GATEWAY_SESSION", "1") + monkeypatch.delenv("HERMES_INTERACTIVE", raising=False) + monkeypatch.delenv("HERMES_EXEC_ASK", raising=False) + monkeypatch.delenv("HERMES_YOLO_MODE", raising=False) + monkeypatch.setattr(approval, "get_current_session_key", lambda default="default": session_key) + monkeypatch.setattr(approval, "_get_approval_mode", lambda: "manual") + monkeypatch.setattr(approval, "_get_approval_config", lambda: {"gateway_timeout": 1}) + monkeypatch.setattr(approval, "detect_hardline_command", lambda command: (False, None)) + monkeypatch.setattr( + approval, + "detect_dangerous_command", + lambda command: (True, "dangerous:test", "test approval"), + ) + monkeypatch.setattr(approval, "is_approved", lambda session_key, pattern_key: False) + monkeypatch.setattr(notify_utils, "is_notify_pending", lambda: True) + monkeypatch.setattr(notify_utils, "fire_approval_request_notification", lambda: calls.append("desktop-notify")) + approval.register_gateway_notify(session_key, notify_cb) + + result = approval.check_all_command_guards("rm -rf /tmp/demo", "local") + + assert result["approved"] is False + assert calls == ["approval-prompt", "desktop-notify"] + + +def test_check_all_command_guards_skips_approval_notification_without_notify_pending(monkeypatch): + from tools import approval + from tools import notify_utils + + calls = [] + monkeypatch.setenv("HERMES_INTERACTIVE", "1") + monkeypatch.delenv("HERMES_GATEWAY_SESSION", raising=False) + monkeypatch.delenv("HERMES_EXEC_ASK", raising=False) + monkeypatch.delenv("HERMES_YOLO_MODE", raising=False) + monkeypatch.setattr(approval, "_get_approval_mode", lambda: "manual") + monkeypatch.setattr(approval, "detect_hardline_command", lambda command: (False, None)) + monkeypatch.setattr( + approval, + "detect_dangerous_command", + lambda command: (True, "dangerous:test", "test approval"), + ) + monkeypatch.setattr(approval, "is_approved", lambda session_key, pattern_key: False) + monkeypatch.setattr(approval, "prompt_dangerous_approval", lambda *args, **kwargs: "deny") + monkeypatch.setattr(notify_utils, "is_notify_pending", lambda: False) + monkeypatch.setattr(notify_utils, "fire_approval_request_notification", lambda: calls.append("approval")) + + result = approval.check_all_command_guards("rm -rf /tmp/demo", "local") + + assert result["approved"] is False + assert calls == [] diff --git a/tools/approval.py b/tools/approval.py index 6e4cca276b8..1de3e269d0e 100644 --- a/tools/approval.py +++ b/tools/approval.py @@ -151,6 +151,33 @@ def _is_gateway_approval_context() -> bool: return True return bool(_get_session_platform()) + +def _notify_approval_request_if_pending() -> None: + """Fire a /notify input-needed notification for approval prompts. + + Best-effort only: approval safety flow must not depend on desktop + notification delivery. Do not clear the sentinel here; the final + turn-complete notification should still fire after the user responds. + """ + try: + from tools.notify_utils import ( + fire_approval_request_notification, + is_notify_pending, + ) + + # /notify is local CLI/TUI-only. TUI gateway sessions set only a + # session key; messaging gateway sessions also set a platform. Do not + # let a local sentinel trigger desktop notifications from Telegram, + # Discord, Slack, etc. approval flows. + if _get_session_platform(): + return + + if is_notify_pending(): + fire_approval_request_notification() + except Exception as exc: + logger.debug("Approval-request notification failed: %s", exc) + + # Sensitive write targets that should trigger approval even when referenced # via shell expansions like $HOME or $HERMES_HOME, or by the resolved absolute # active profile home path such as /home/hermes/.hermes/config.yaml. The @@ -1207,6 +1234,7 @@ def check_dangerous_command(command: str, env_type: str, "pattern_key": pattern_key, "description": description, }) + _notify_approval_request_if_pending() return { "approved": False, "pattern_key": pattern_key, @@ -1219,6 +1247,7 @@ def check_dangerous_command(command: str, env_type: str, ), } + _notify_approval_request_if_pending() choice = prompt_dangerous_approval(command, description, approval_callback=approval_callback) @@ -1547,6 +1576,10 @@ def check_all_command_guards(command: str, env_type: str, "pattern_key": primary_key, "description": combined_desc, } + # The approval prompt reached the user — surface a local /notify + # input-needed desktop notification if one is pending (no-op on + # messaging-gateway sessions, which carry a platform). + _notify_approval_request_if_pending() resolved = decision["resolved"] choice = decision["choice"] @@ -1596,7 +1629,9 @@ def check_all_command_guards(command: str, env_type: str, "user_approved": True, "description": combined_desc} # Fallback: no gateway callback registered (e.g. cron, batch). - # Return approval_required for backward compat. + # Return approval_required for backward compat. Do not fire the local + # desktop /notify hook here because there is no local UI prompt to pair + # it with. submit_pending(session_key, { "command": command, "pattern_key": primary_key, @@ -1626,6 +1661,7 @@ def check_all_command_guards(command: str, env_type: str, session_key=session_key, surface="cli", ) + _notify_approval_request_if_pending() choice = prompt_dangerous_approval(command, combined_desc, allow_permanent=not has_tirith, approval_callback=approval_callback) diff --git a/tools/notify_utils.py b/tools/notify_utils.py new file mode 100644 index 00000000000..37cfaf8497a --- /dev/null +++ b/tools/notify_utils.py @@ -0,0 +1,232 @@ +"""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 logging +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 +# --------------------------------------------------------------------------- + +def get_notify_sentinel_path() -> Path: + return _hermes_home() / ".notify_pending" + + +def set_notify_flag() -> bool: + """Write the sentinel file to signal a pending notification.""" + try: + p = get_notify_sentinel_path() + 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() -> bool: + """Remove the sentinel file (cancel or consume notification).""" + try: + p = get_notify_sentinel_path() + 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() -> bool: + """Check if a notification is pending.""" + return get_notify_sentinel_path().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_desktop_notification(title: str, message: str) -> None: + """Show a desktop notification bubble. + + WSL path: prefer notify-send via WSLg (native Windows toasts), + fall back to PowerShell balloon tip. + """ + try: + 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": + 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") + 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( + config: Optional[dict] = None, + *, + 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: + config: Optional config dict. Reads from config.yaml when None. + 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") \ No newline at end of file diff --git a/tui_gateway/server.py b/tui_gateway/server.py index 12ee450c6f5..cd1e0d76bdf 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -6330,6 +6330,22 @@ def _run_prompt_submit(rid, sid: str, session: dict, text: Any) -> None: file=sys.stderr, ) + # Notify check — fire if /notify was set for this turn. + # (The sentinel was set by the SlashWorker that handled the + # /notify command; the notification fires here after the + # agent's turn completes.) + try: + from tools.notify_utils import ( + is_notify_pending, + clear_notify_flag, + fire_notification, + ) + if is_notify_pending(): + clear_notify_flag() + fire_notification() + except Exception as e: + logging.debug("tui notify idle-check failed: %s", e) + # Apply pending_title now that the DB row exists. _pending = session.get("pending_title") if _pending and status == "complete": @@ -6410,6 +6426,20 @@ def _run_prompt_submit(rid, sid: str, session: dict, text: Any) -> None: f"[gateway-turn] {type(e).__name__}: {e}", file=sys.stderr, flush=True ) _emit("error", sid, {"message": str(e)}) + # If /notify was set for this failed turn, consume it here too. + # Otherwise the global sentinel can leak into a later unrelated + # turn after the TUI error path exits. + try: + from tools.notify_utils import ( + is_notify_pending, + clear_notify_flag, + fire_notification, + ) + if is_notify_pending(): + clear_notify_flag() + fire_notification() + except Exception as notify_exc: + logging.debug("tui notify error-path check failed: %s", notify_exc) finally: try: if approval_token is not None: diff --git a/ui-tui/src/app/slash/commands/ops.ts b/ui-tui/src/app/slash/commands/ops.ts index ad41a04977f..0040fd4c960 100644 --- a/ui-tui/src/app/slash/commands/ops.ts +++ b/ui-tui/src/app/slash/commands/ops.ts @@ -62,6 +62,28 @@ interface SkillsReloadResponse { } export const opsCommands: SlashCommand[] = [ + { + help: 'notify when this turn finishes; optionally submit a prompt', + name: 'notify', + run: (arg, ctx, cmd) => { + const trimmed = arg.trim() + + ctx.gateway + .rpc('slash.exec', { command: cmd.slice(1), session_id: ctx.sid }) + .then( + ctx.guarded(r => { + const body = r?.output || '/notify: no output' + ctx.transcript.sys(body) + + if (trimmed && trimmed.toLowerCase() !== 'cancel') { + ctx.transcript.send(trimmed) + } + }) + ) + .catch(ctx.guardedErr) + } + }, + { help: 'stop background processes', name: 'stop',