mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-31 19:16:29 +00:00
feat(cli): add local notify command
This commit is contained in:
parent
0fa7d6f660
commit
eb20289f96
7 changed files with 551 additions and 1 deletions
88
cli.py
88
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 <prompt> — 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
|
||||
|
|
|
|||
|
|
@ -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="<prompt>"),
|
||||
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="<prompt>"),
|
||||
CommandDef("goal", "Set a standing goal Hermes works on across turns until achieved", "Session",
|
||||
|
|
|
|||
140
tests/test_notify_approval.py
Normal file
140
tests/test_notify_approval.py
Normal file
|
|
@ -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 == []
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
232
tools/notify_utils.py
Normal file
232
tools/notify_utils.py
Normal file
|
|
@ -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")
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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<SlashExecResponse>('slash.exec', { command: cmd.slice(1), session_id: ctx.sid })
|
||||
.then(
|
||||
ctx.guarded<SlashExecResponse>(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',
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue