feat(cli): add local notify command

This commit is contained in:
PCinkusz 2026-05-03 17:55:07 +02:00 committed by Brooklyn Nicholson
parent 0fa7d6f660
commit eb20289f96
7 changed files with 551 additions and 1 deletions

88
cli.py
View file

@ -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

View file

@ -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",

View 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 == []

View file

@ -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
View 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")

View file

@ -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:

View file

@ -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',