Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 38 additions & 17 deletions hermes_cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -12398,6 +12398,14 @@ def cmd_dashboard(args):
exc_info=True,
)

# The desktop app and remote backends run agent turns through serve's
# in-process /api/ws gateway (and the dashboard's Chat tab), so lifecycle
# hooks must be wired on this path too. _prepare_agent_startup's
# _AGENT_COMMANDS gate deliberately excludes serve/dashboard (they are not
# interactive agent-entrypoint commands), which means hooks would never
# register here otherwise.
_register_shell_hooks(accept_hooks=False)

from hermes_cli.web_server import start_server

# Interactive auth setup: if this bind will engage the auth gate but no
Expand Down Expand Up @@ -12651,6 +12659,35 @@ def _should_background_mcp_startup(args) -> bool:
return args.command in {None, "chat", "rl"}


def _register_shell_hooks(accept_hooks: bool = False) -> None:
"""Register configured shell + outbound webhooks (idempotent).

Shared by ``_prepare_agent_startup`` (interactive agent entrypoints) and
``cmd_dashboard`` (the headless ``serve`` backend and the dashboard), so
lifecycle hooks fire regardless of how the agent turn is hosted. ``serve``
is deliberately absent from ``_AGENT_COMMANDS`` (it is not an interactive
agent-entrypoint command), but it still hosts agent turns via its
in-process ``/api/ws`` gateway and must therefore wire hooks itself.
"""
try:
from hermes_cli.config import load_config
from agent.shell_hooks import register_from_config

_hooks_cfg = load_config()
register_from_config(_hooks_cfg, accept_hooks=accept_hooks)

from agent.outbound_webhooks import (
register_from_config as register_outbound_webhooks,
)

register_outbound_webhooks(_hooks_cfg)
except Exception:
logger.debug(
"shell-hook registration failed",
exc_info=True,
)


def _prepare_agent_startup(args) -> None:
"""Discover plugins/MCP/hooks for commands that can run an agent turn."""
# --yolo: chokepoint guarantee that HERMES_YOLO_MODE is set before ANY
Expand Down Expand Up @@ -12729,23 +12766,7 @@ def _prepare_agent_startup(args) -> None:
"MCP tool discovery failed at CLI startup",
exc_info=True,
)
try:
from hermes_cli.config import load_config
from agent.shell_hooks import register_from_config

_hooks_cfg = load_config()
register_from_config(_hooks_cfg, accept_hooks=_accept_hooks)

from agent.outbound_webhooks import (
register_from_config as register_outbound_webhooks,
)

register_outbound_webhooks(_hooks_cfg)
except Exception:
logger.debug(
"shell-hook registration failed at CLI startup",
exc_info=True,
)
_register_shell_hooks(accept_hooks=_accept_hooks)


def _apply_safe_mode(args) -> None:
Expand Down
102 changes: 102 additions & 0 deletions tests/hermes_cli/test_dashboard_shell_hooks.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
"""serve/dashboard must register shell hooks so lifecycle hooks (mnemon
prime/remind/nudge, …) fire on the desktop/headless agent host.

Regression: the shell-hook registration in ``_prepare_agent_startup`` is gated
on ``_AGENT_COMMANDS = {None, chat, acp, rl}`` (+ cron/gateway/mcp subcommands),
which excludes ``serve``/``dashboard``. The desktop app runs agent turns through
serve's in-process ``/api/ws`` gateway, so hooks were never registered there and
lifecycle hooks silently stopped firing after an upgrade dropped the earlier
local patch.
"""

from __future__ import annotations

import types

import pytest


def _args(**kw):
defaults = dict(
status=False,
stop=False,
host="127.0.0.1",
port=9119,
no_open=True,
insecure=False,
skip_build=False,
isolated=False,
open_profile="",
headless_backend=True,
)
defaults.update(kw)
return types.SimpleNamespace(**defaults)


@pytest.fixture
def main_mod():
import hermes_cli.main as main_mod

return main_mod


def _neutralize_startup(main_mod, monkeypatch):
"""Mock the expensive/blocking cmd_dashboard steps so the test reaches the
hook-registration call without a real web server, web build, or config."""
monkeypatch.delenv("HERMES_HOME", raising=False)
monkeypatch.setattr(
"hermes_cli.profiles.get_active_profile_name", lambda: "default"
)
monkeypatch.setattr(
"hermes_cli.config.require_parseable_user_config", lambda **kw: None
)
monkeypatch.setattr("hermes_cli.plugins.discover_plugins", lambda: None)
monkeypatch.setattr(
"hermes_cli.mcp_startup.start_background_mcp_discovery", lambda **kw: None
)
import hermes_cli.web_server as web_server

monkeypatch.setattr(web_server, "start_server", lambda **kw: None)
monkeypatch.setattr(
main_mod, "_maybe_setup_dashboard_auth_interactively", lambda args: None
)


def test_serve_registers_shell_hooks(main_mod, monkeypatch):
"""Headless serve must register hooks before starting the server."""
_neutralize_startup(main_mod, monkeypatch)

calls = []
monkeypatch.setattr(
main_mod,
"_register_shell_hooks",
lambda accept_hooks=False: calls.append(accept_hooks),
)

main_mod.cmd_dashboard(_args())

assert calls == [False] # headless: never auto-accept hooks


def test_register_shell_hooks_wires_shell_and_outbound(monkeypatch):
"""The shared helper forwards to both shell-hook and outbound-webhook
registrars with the caller's accept_hooks flag."""
import hermes_cli.main as main_mod

shell_calls = []
outbound_calls = []

monkeypatch.setattr("hermes_cli.config.load_config", lambda: {"hooks": {}})
monkeypatch.setattr(
"agent.shell_hooks.register_from_config",
lambda cfg, accept_hooks=False: shell_calls.append((cfg, accept_hooks)),
)
monkeypatch.setattr(
"agent.outbound_webhooks.register_from_config",
lambda cfg: outbound_calls.append(cfg),
)

main_mod._register_shell_hooks(accept_hooks=True)

assert shell_calls == [({"hooks": {}}, True)]
assert outbound_calls == [{"hooks": {}}]