feat: add /update slash command for gateway platforms

Adds a /update command to Telegram, Discord, and other gateway platforms
that runs `hermes update` to pull the latest code, update dependencies,
sync skills, and restart the gateway.

Implementation:
- Spawns `hermes update` in a separate systemd scope (systemd-run --user
  --scope) so the process survives the gateway restart that hermes update
  triggers at the end. Falls back to nohup if systemd-run is unavailable.
- Writes a marker file (.update_pending.json) with the originating
  platform and chat_id before spawning the update.
- On gateway startup, _send_update_notification() checks for the marker,
  reads the captured update output, sends the results back to the user,
  and cleans up.

Also:
- Registers /update as a Discord slash command
- Updates README.md, docs/messaging.md, docs/slash-commands.md
- Adds 18 tests covering handler, notification, and edge cases
This commit is contained in:
teknium1 2026-03-05 01:20:58 -08:00
parent 2af2f148ab
commit d400fb8b23
6 changed files with 623 additions and 1 deletions

View file

@ -336,6 +336,8 @@ See [docs/messaging.md](docs/messaging.md) for advanced WhatsApp configuration.
| `/sethome` | Set this chat as the home channel |
| `/compress` | Manually compress conversation context |
| `/usage` | Show token usage for this session |
| `/reload-mcp` | Reload MCP servers from config |
| `/update` | Update Hermes Agent to the latest version |
| `/help` | Show available commands |
| `/<skill-name>` | Invoke any installed skill (e.g., `/axolotl`, `/gif-search`) |

View file

@ -86,6 +86,8 @@ Send `/new` or `/reset` as a message to start fresh.
|---------|-------------|
| `/compress` | Manually compress conversation context (saves memories, then summarizes) |
| `/usage` | Show token usage and context window status for the current session |
| `/update` | Update Hermes Agent to the latest version (pulls code, updates deps, restarts gateway) |
| `/reload-mcp` | Disconnect and reconnect all MCP servers from config |
### Per-Platform Overrides

View file

@ -41,6 +41,20 @@ Quick reference for all CLI slash commands in Hermes Agent.
| `/skills` | Search, install, or manage skills |
| `/platforms` | Show gateway/messaging platform status |
## Gateway Only
These commands are available in messaging platforms (Telegram, Discord, etc.) but not the interactive CLI:
| Command | Description |
|---------|-------------|
| `/stop` | Stop the running agent |
| `/sethome` | Set this chat as the home channel |
| `/compress` | Manually compress conversation context |
| `/usage` | Show token usage for the current session |
| `/reload-mcp` | Reload MCP servers from config |
| `/update` | Update Hermes Agent to the latest version |
| `/status` | Show session info |
## Examples
### Changing Models

View file

@ -533,6 +533,16 @@ class DiscordAdapter(BasePlatformAdapter):
except Exception as e:
logger.debug("Discord followup failed: %s", e)
@tree.command(name="update", description="Update Hermes Agent to the latest version")
async def slash_update(interaction: discord.Interaction):
await interaction.response.defer(ephemeral=True)
event = self._build_slash_event(interaction, "/update")
await self.handle_message(event)
try:
await interaction.followup.send("Update initiated~", ephemeral=True)
except Exception as e:
logger.debug("Discord followup failed: %s", e)
def _build_slash_event(self, interaction: discord.Interaction, text: str) -> MessageEvent:
"""Build a MessageEvent from a Discord slash command interaction."""
is_dm = isinstance(interaction.channel, discord.DMChannel)

View file

@ -455,6 +455,9 @@ class GatewayRunner:
except Exception as e:
logger.warning("Channel directory build failed: %s", e)
# Check if we're restarting after a /update command
await self._send_update_notification()
logger.info("Press Ctrl+C to stop")
return True
@ -655,7 +658,7 @@ class GatewayRunner:
# Emit command:* hook for any recognized slash command
_known_commands = {"new", "reset", "help", "status", "stop", "model",
"personality", "retry", "undo", "sethome", "set-home",
"compress", "usage", "reload-mcp"}
"compress", "usage", "reload-mcp", "update"}
if command and command in _known_commands:
await self.hooks.emit(f"command:{command}", {
"platform": source.platform.value if source.platform else "",
@ -699,6 +702,9 @@ class GatewayRunner:
if command == "reload-mcp":
return await self._handle_reload_mcp_command(event)
if command == "update":
return await self._handle_update_command(event)
# Skill slash commands: /skill-name loads the skill and sends to agent
if command:
@ -1098,6 +1104,7 @@ class GatewayRunner:
"`/compress` — Compress conversation context",
"`/usage` — Show token usage for this session",
"`/reload-mcp` — Reload MCP servers from config",
"`/update` — Update Hermes Agent to the latest version",
"`/help` — Show this message",
]
try:
@ -1460,6 +1467,111 @@ class GatewayRunner:
logger.warning("MCP reload failed: %s", e)
return f"❌ MCP reload failed: {e}"
async def _handle_update_command(self, event: MessageEvent) -> str:
"""Handle /update command — update Hermes Agent to the latest version.
Spawns ``hermes update`` in a separate systemd scope so it survives the
gateway restart that ``hermes update`` triggers at the end. A marker
file is written so the *new* gateway process can notify the user of the
result on startup.
"""
import json
import shutil
import subprocess
from datetime import datetime
project_root = Path(__file__).parent.parent.resolve()
git_dir = project_root / '.git'
if not git_dir.exists():
return "✗ Not a git repository — cannot update."
hermes_bin = shutil.which("hermes")
if not hermes_bin:
return "✗ `hermes` command not found on PATH."
# Write marker so the restarted gateway can notify this chat
pending_path = _hermes_home / ".update_pending.json"
output_path = _hermes_home / ".update_output.txt"
pending = {
"platform": event.source.platform.value,
"chat_id": event.source.chat_id,
"user_id": event.source.user_id,
"timestamp": datetime.now().isoformat(),
}
pending_path.write_text(json.dumps(pending))
# Spawn `hermes update` in a separate cgroup so it survives gateway
# restart. systemd-run --user --scope creates a transient scope unit.
update_cmd = f"{hermes_bin} update > {output_path} 2>&1"
try:
systemd_run = shutil.which("systemd-run")
if systemd_run:
subprocess.Popen(
[systemd_run, "--user", "--scope",
"--unit=hermes-update", "--",
"bash", "-c", update_cmd],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
start_new_session=True,
)
else:
# Fallback: best-effort detach with start_new_session
subprocess.Popen(
["bash", "-c", f"nohup {update_cmd} &"],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
start_new_session=True,
)
except Exception as e:
pending_path.unlink(missing_ok=True)
return f"✗ Failed to start update: {e}"
return "⚕ Starting Hermes update… I'll notify you when it's done."
async def _send_update_notification(self) -> None:
"""If the gateway is starting after a ``/update``, notify the user."""
import json
import re as _re
pending_path = _hermes_home / ".update_pending.json"
output_path = _hermes_home / ".update_output.txt"
if not pending_path.exists():
return
try:
pending = json.loads(pending_path.read_text())
platform_str = pending.get("platform")
chat_id = pending.get("chat_id")
# Read the captured update output
output = ""
if output_path.exists():
output = output_path.read_text()
# Resolve adapter
platform = Platform(platform_str)
adapter = self.adapters.get(platform)
if adapter and chat_id:
# Strip ANSI escape codes for clean display
output = _re.sub(r'\x1b\[[0-9;]*m', '', output).strip()
if output:
# Truncate if too long for a single message
if len(output) > 3500:
output = "" + output[-3500:]
msg = f"✅ Hermes update finished — gateway restarted.\n\n```\n{output}\n```"
else:
msg = "✅ Hermes update finished — gateway restarted successfully."
await adapter.send(chat_id, msg)
logger.info("Sent post-update notification to %s:%s", platform_str, chat_id)
except Exception as e:
logger.warning("Post-update notification failed: %s", e)
finally:
pending_path.unlink(missing_ok=True)
output_path.unlink(missing_ok=True)
def _set_session_env(self, context: SessionContext) -> None:
"""Set environment variables for the current session."""
os.environ["HERMES_SESSION_PLATFORM"] = context.source.platform.value

View file

@ -0,0 +1,482 @@
"""Tests for /update gateway slash command.
Tests both the _handle_update_command handler (spawns update process) and
the _send_update_notification startup hook (sends results after restart).
"""
import json
import os
from pathlib import Path
from unittest.mock import patch, MagicMock, AsyncMock
import pytest
from gateway.config import Platform
from gateway.platforms.base import MessageEvent
from gateway.session import SessionSource
def _make_event(text="/update", platform=Platform.TELEGRAM,
user_id="12345", chat_id="67890"):
"""Build a MessageEvent for testing."""
source = SessionSource(
platform=platform,
user_id=user_id,
chat_id=chat_id,
user_name="testuser",
)
return MessageEvent(text=text, source=source)
def _make_runner():
"""Create a bare GatewayRunner without calling __init__."""
from gateway.run import GatewayRunner
runner = object.__new__(GatewayRunner)
runner.adapters = {}
return runner
# ---------------------------------------------------------------------------
# _handle_update_command
# ---------------------------------------------------------------------------
class TestHandleUpdateCommand:
"""Tests for GatewayRunner._handle_update_command."""
@pytest.mark.asyncio
async def test_no_git_directory(self, tmp_path):
"""Returns an error when .git does not exist."""
runner = _make_runner()
event = _make_event()
# Point _hermes_home to tmp_path and project_root to a dir without .git
fake_root = tmp_path / "project"
fake_root.mkdir()
with patch("gateway.run._hermes_home", tmp_path), \
patch("gateway.run.Path") as MockPath:
# Path(__file__).parent.parent.resolve() -> fake_root
MockPath.return_value = MagicMock()
MockPath.__truediv__ = Path.__truediv__
# Easier: just patch the __file__ resolution in the method
pass
# Simpler approach — mock at method level using a wrapper
from gateway.run import GatewayRunner
runner = _make_runner()
with patch("gateway.run._hermes_home", tmp_path):
# The handler does Path(__file__).parent.parent.resolve()
# We need to make project_root / '.git' not exist.
# Since Path(__file__) resolves to the real gateway/run.py,
# project_root will be the real hermes-agent dir (which HAS .git).
# Patch Path to control this.
original_path = Path
class FakePath(type(Path())):
pass
# Actually, simplest: just patch the specific file attr
fake_file = str(fake_root / "gateway" / "run.py")
(fake_root / "gateway").mkdir(parents=True)
(fake_root / "gateway" / "run.py").touch()
with patch("gateway.run.__file__", fake_file):
result = await runner._handle_update_command(event)
assert "Not a git repository" in result
@pytest.mark.asyncio
async def test_no_hermes_binary(self, tmp_path):
"""Returns error when hermes is not on PATH."""
runner = _make_runner()
event = _make_event()
# Create project dir WITH .git
fake_root = tmp_path / "project"
fake_root.mkdir()
(fake_root / ".git").mkdir()
(fake_root / "gateway").mkdir()
(fake_root / "gateway" / "run.py").touch()
fake_file = str(fake_root / "gateway" / "run.py")
with patch("gateway.run._hermes_home", tmp_path), \
patch("gateway.run.__file__", fake_file), \
patch("shutil.which", return_value=None):
result = await runner._handle_update_command(event)
assert "not found on PATH" in result
@pytest.mark.asyncio
async def test_writes_pending_marker(self, tmp_path):
"""Writes .update_pending.json with correct platform and chat info."""
runner = _make_runner()
event = _make_event(platform=Platform.TELEGRAM, chat_id="99999")
fake_root = tmp_path / "project"
fake_root.mkdir()
(fake_root / ".git").mkdir()
(fake_root / "gateway").mkdir()
(fake_root / "gateway" / "run.py").touch()
fake_file = str(fake_root / "gateway" / "run.py")
hermes_home = tmp_path / "hermes"
hermes_home.mkdir()
with patch("gateway.run._hermes_home", hermes_home), \
patch("gateway.run.__file__", fake_file), \
patch("shutil.which", side_effect=lambda x: "/usr/bin/hermes" if x == "hermes" else "/usr/bin/systemd-run"), \
patch("subprocess.Popen"):
result = await runner._handle_update_command(event)
pending_path = hermes_home / ".update_pending.json"
assert pending_path.exists()
data = json.loads(pending_path.read_text())
assert data["platform"] == "telegram"
assert data["chat_id"] == "99999"
assert "timestamp" in data
@pytest.mark.asyncio
async def test_spawns_systemd_run(self, tmp_path):
"""Uses systemd-run when available."""
runner = _make_runner()
event = _make_event()
fake_root = tmp_path / "project"
fake_root.mkdir()
(fake_root / ".git").mkdir()
(fake_root / "gateway").mkdir()
(fake_root / "gateway" / "run.py").touch()
fake_file = str(fake_root / "gateway" / "run.py")
hermes_home = tmp_path / "hermes"
hermes_home.mkdir()
mock_popen = MagicMock()
with patch("gateway.run._hermes_home", hermes_home), \
patch("gateway.run.__file__", fake_file), \
patch("shutil.which", side_effect=lambda x: f"/usr/bin/{x}"), \
patch("subprocess.Popen", mock_popen):
result = await runner._handle_update_command(event)
# Verify systemd-run was used
call_args = mock_popen.call_args[0][0]
assert call_args[0] == "/usr/bin/systemd-run"
assert "--scope" in call_args
assert "Starting Hermes update" in result
@pytest.mark.asyncio
async def test_fallback_nohup_when_no_systemd_run(self, tmp_path):
"""Falls back to nohup when systemd-run is not available."""
runner = _make_runner()
event = _make_event()
fake_root = tmp_path / "project"
fake_root.mkdir()
(fake_root / ".git").mkdir()
(fake_root / "gateway").mkdir()
(fake_root / "gateway" / "run.py").touch()
fake_file = str(fake_root / "gateway" / "run.py")
hermes_home = tmp_path / "hermes"
hermes_home.mkdir()
mock_popen = MagicMock()
def which_no_systemd(x):
if x == "hermes":
return "/usr/bin/hermes"
if x == "systemd-run":
return None
return None
with patch("gateway.run._hermes_home", hermes_home), \
patch("gateway.run.__file__", fake_file), \
patch("shutil.which", side_effect=which_no_systemd), \
patch("subprocess.Popen", mock_popen):
result = await runner._handle_update_command(event)
# Verify bash -c nohup fallback was used
call_args = mock_popen.call_args[0][0]
assert call_args[0] == "bash"
assert "nohup" in call_args[2]
assert "Starting Hermes update" in result
@pytest.mark.asyncio
async def test_popen_failure_cleans_up(self, tmp_path):
"""Cleans up pending file and returns error on Popen failure."""
runner = _make_runner()
event = _make_event()
fake_root = tmp_path / "project"
fake_root.mkdir()
(fake_root / ".git").mkdir()
(fake_root / "gateway").mkdir()
(fake_root / "gateway" / "run.py").touch()
fake_file = str(fake_root / "gateway" / "run.py")
hermes_home = tmp_path / "hermes"
hermes_home.mkdir()
with patch("gateway.run._hermes_home", hermes_home), \
patch("gateway.run.__file__", fake_file), \
patch("shutil.which", side_effect=lambda x: f"/usr/bin/{x}"), \
patch("subprocess.Popen", side_effect=OSError("spawn failed")):
result = await runner._handle_update_command(event)
assert "Failed to start update" in result
# Pending file should be cleaned up
assert not (hermes_home / ".update_pending.json").exists()
@pytest.mark.asyncio
async def test_returns_user_friendly_message(self, tmp_path):
"""The success response is user-friendly."""
runner = _make_runner()
event = _make_event()
fake_root = tmp_path / "project"
fake_root.mkdir()
(fake_root / ".git").mkdir()
(fake_root / "gateway").mkdir()
(fake_root / "gateway" / "run.py").touch()
fake_file = str(fake_root / "gateway" / "run.py")
hermes_home = tmp_path / "hermes"
hermes_home.mkdir()
with patch("gateway.run._hermes_home", hermes_home), \
patch("gateway.run.__file__", fake_file), \
patch("shutil.which", side_effect=lambda x: f"/usr/bin/{x}"), \
patch("subprocess.Popen"):
result = await runner._handle_update_command(event)
assert "notify you when it's done" in result
# ---------------------------------------------------------------------------
# _send_update_notification
# ---------------------------------------------------------------------------
class TestSendUpdateNotification:
"""Tests for GatewayRunner._send_update_notification."""
@pytest.mark.asyncio
async def test_no_pending_file_is_noop(self, tmp_path):
"""Does nothing when no pending file exists."""
runner = _make_runner()
hermes_home = tmp_path / "hermes"
hermes_home.mkdir()
with patch("gateway.run._hermes_home", hermes_home):
# Should not raise
await runner._send_update_notification()
@pytest.mark.asyncio
async def test_sends_notification_with_output(self, tmp_path):
"""Sends update output to the correct platform and chat."""
runner = _make_runner()
hermes_home = tmp_path / "hermes"
hermes_home.mkdir()
# Write pending marker
pending = {
"platform": "telegram",
"chat_id": "67890",
"user_id": "12345",
"timestamp": "2026-03-04T21:00:00",
}
(hermes_home / ".update_pending.json").write_text(json.dumps(pending))
(hermes_home / ".update_output.txt").write_text(
"→ Found 3 new commit(s)\n✓ Code updated!\n✓ Update complete!"
)
# Mock the adapter
mock_adapter = AsyncMock()
mock_adapter.send = AsyncMock()
runner.adapters = {Platform.TELEGRAM: mock_adapter}
with patch("gateway.run._hermes_home", hermes_home):
await runner._send_update_notification()
mock_adapter.send.assert_called_once()
call_args = mock_adapter.send.call_args
assert call_args[0][0] == "67890" # chat_id
assert "Update complete" in call_args[0][1] or "update finished" in call_args[0][1].lower()
@pytest.mark.asyncio
async def test_strips_ansi_codes(self, tmp_path):
"""ANSI escape codes are removed from output."""
runner = _make_runner()
hermes_home = tmp_path / "hermes"
hermes_home.mkdir()
pending = {"platform": "telegram", "chat_id": "111", "user_id": "222"}
(hermes_home / ".update_pending.json").write_text(json.dumps(pending))
(hermes_home / ".update_output.txt").write_text(
"\x1b[32m✓ Code updated!\x1b[0m\n\x1b[1mDone\x1b[0m"
)
mock_adapter = AsyncMock()
runner.adapters = {Platform.TELEGRAM: mock_adapter}
with patch("gateway.run._hermes_home", hermes_home):
await runner._send_update_notification()
sent_text = mock_adapter.send.call_args[0][1]
assert "\x1b[" not in sent_text
assert "Code updated" in sent_text
@pytest.mark.asyncio
async def test_truncates_long_output(self, tmp_path):
"""Output longer than 3500 chars is truncated."""
runner = _make_runner()
hermes_home = tmp_path / "hermes"
hermes_home.mkdir()
pending = {"platform": "telegram", "chat_id": "111", "user_id": "222"}
(hermes_home / ".update_pending.json").write_text(json.dumps(pending))
(hermes_home / ".update_output.txt").write_text("x" * 5000)
mock_adapter = AsyncMock()
runner.adapters = {Platform.TELEGRAM: mock_adapter}
with patch("gateway.run._hermes_home", hermes_home):
await runner._send_update_notification()
sent_text = mock_adapter.send.call_args[0][1]
# Should start with truncation marker
assert "" in sent_text
# Total message should not be absurdly long
assert len(sent_text) < 4500
@pytest.mark.asyncio
async def test_sends_generic_message_when_no_output(self, tmp_path):
"""Sends a success message even if the output file is missing."""
runner = _make_runner()
hermes_home = tmp_path / "hermes"
hermes_home.mkdir()
pending = {"platform": "telegram", "chat_id": "111", "user_id": "222"}
(hermes_home / ".update_pending.json").write_text(json.dumps(pending))
# No .update_output.txt created
mock_adapter = AsyncMock()
runner.adapters = {Platform.TELEGRAM: mock_adapter}
with patch("gateway.run._hermes_home", hermes_home):
await runner._send_update_notification()
sent_text = mock_adapter.send.call_args[0][1]
assert "restarted successfully" in sent_text
@pytest.mark.asyncio
async def test_cleans_up_files_after_notification(self, tmp_path):
"""Both marker and output files are deleted after notification."""
runner = _make_runner()
hermes_home = tmp_path / "hermes"
hermes_home.mkdir()
pending_path = hermes_home / ".update_pending.json"
output_path = hermes_home / ".update_output.txt"
pending_path.write_text(json.dumps({
"platform": "telegram", "chat_id": "111", "user_id": "222",
}))
output_path.write_text("✓ Done")
mock_adapter = AsyncMock()
runner.adapters = {Platform.TELEGRAM: mock_adapter}
with patch("gateway.run._hermes_home", hermes_home):
await runner._send_update_notification()
assert not pending_path.exists()
assert not output_path.exists()
@pytest.mark.asyncio
async def test_cleans_up_on_error(self, tmp_path):
"""Files are cleaned up even if notification fails."""
runner = _make_runner()
hermes_home = tmp_path / "hermes"
hermes_home.mkdir()
pending_path = hermes_home / ".update_pending.json"
output_path = hermes_home / ".update_output.txt"
pending_path.write_text(json.dumps({
"platform": "telegram", "chat_id": "111", "user_id": "222",
}))
output_path.write_text("✓ Done")
# Adapter send raises
mock_adapter = AsyncMock()
mock_adapter.send.side_effect = RuntimeError("network error")
runner.adapters = {Platform.TELEGRAM: mock_adapter}
with patch("gateway.run._hermes_home", hermes_home):
await runner._send_update_notification()
# Files should still be cleaned up (finally block)
assert not pending_path.exists()
assert not output_path.exists()
@pytest.mark.asyncio
async def test_handles_corrupt_pending_file(self, tmp_path):
"""Gracefully handles a malformed pending JSON file."""
runner = _make_runner()
hermes_home = tmp_path / "hermes"
hermes_home.mkdir()
pending_path = hermes_home / ".update_pending.json"
pending_path.write_text("{corrupt json!!")
with patch("gateway.run._hermes_home", hermes_home):
# Should not raise
await runner._send_update_notification()
# File should be cleaned up
assert not pending_path.exists()
@pytest.mark.asyncio
async def test_no_adapter_for_platform(self, tmp_path):
"""Does not crash if the platform adapter is not connected."""
runner = _make_runner()
hermes_home = tmp_path / "hermes"
hermes_home.mkdir()
pending = {"platform": "discord", "chat_id": "111", "user_id": "222"}
pending_path = hermes_home / ".update_pending.json"
output_path = hermes_home / ".update_output.txt"
pending_path.write_text(json.dumps(pending))
output_path.write_text("Done")
# Only telegram adapter available, but pending says discord
mock_adapter = AsyncMock()
runner.adapters = {Platform.TELEGRAM: mock_adapter}
with patch("gateway.run._hermes_home", hermes_home):
await runner._send_update_notification()
# send should not have been called (wrong platform)
mock_adapter.send.assert_not_called()
# Files should still be cleaned up
assert not pending_path.exists()
# ---------------------------------------------------------------------------
# /update in help and known_commands
# ---------------------------------------------------------------------------
class TestUpdateInHelp:
"""Verify /update appears in help text and known commands set."""
@pytest.mark.asyncio
async def test_update_in_help_output(self):
"""The /help output includes /update."""
runner = _make_runner()
event = _make_event(text="/help")
result = await runner._handle_help_command(event)
assert "/update" in result
def test_update_is_known_command(self):
"""The /update command is in the help text (proxy for _known_commands)."""
# _known_commands is local to _handle_message, so we verify by
# checking the help output includes it.
from gateway.run import GatewayRunner
import inspect
source = inspect.getsource(GatewayRunner._handle_message)
assert '"update"' in source