diff --git a/AGENTS.md b/AGENTS.md index a25393ad9..19c6f2797 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -173,6 +173,7 @@ if canonical == "mycommand": - `args_hint` — argument placeholder shown in help (e.g. `""`, `"[name]"`) - `cli_only` — only available in the interactive CLI - `gateway_only` — only available in messaging platforms +- `gateway_config_gate` — config dotpath (e.g. `"display.tool_progress_command"`); when set on a `cli_only` command, the command becomes available in the gateway if the config value is truthy. `GATEWAY_KNOWN_COMMANDS` always includes config-gated commands so the gateway can dispatch them; help/menus only show them when the gate is open. **Adding an alias** requires only adding it to the `aliases` tuple on the existing `CommandDef`. No other file changes needed — dispatch, help text, Telegram menu, Slack mapping, and autocomplete all update automatically. diff --git a/gateway/run.py b/gateway/run.py index 86f30d6ab..fd0d60042 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -1716,6 +1716,9 @@ class GatewayRunner: if canonical == "reasoning": return await self._handle_reasoning_command(event) + if canonical == "verbose": + return await self._handle_verbose_command(event) + if canonical == "provider": return await self._handle_provider_command(event) @@ -3784,6 +3787,68 @@ class GatewayRunner: else: return f"🧠 ✓ Reasoning effort set to `{effort}` (this session only)" + async def _handle_verbose_command(self, event: MessageEvent) -> str: + """Handle /verbose command — cycle tool progress display mode. + + Gated by ``display.tool_progress_command`` in config.yaml (default off). + When enabled, cycles the tool progress mode through off → new → all → + verbose → off, same as the CLI. + """ + import yaml + + config_path = _hermes_home / "config.yaml" + + # --- check config gate ------------------------------------------------ + try: + user_config = {} + if config_path.exists(): + with open(config_path, encoding="utf-8") as f: + user_config = yaml.safe_load(f) or {} + gate_enabled = user_config.get("display", {}).get("tool_progress_command", False) + except Exception: + gate_enabled = False + + if not gate_enabled: + return ( + "The `/verbose` command is not enabled for messaging platforms.\n\n" + "Enable it in `config.yaml`:\n```yaml\n" + "display:\n tool_progress_command: true\n```" + ) + + # --- cycle mode ------------------------------------------------------- + cycle = ["off", "new", "all", "verbose"] + descriptions = { + "off": "⚙️ Tool progress: **OFF** — no tool activity shown.", + "new": "⚙️ Tool progress: **NEW** — shown when tool changes.", + "all": "⚙️ Tool progress: **ALL** — every tool call shown.", + "verbose": "⚙️ Tool progress: **VERBOSE** — full args and results.", + } + + raw_progress = user_config.get("display", {}).get("tool_progress", "all") + # YAML 1.1 parses bare "off" as boolean False — normalise back + if raw_progress is False: + current = "off" + elif raw_progress is True: + current = "all" + else: + current = str(raw_progress).lower() + if current not in cycle: + current = "all" + idx = (cycle.index(current) + 1) % len(cycle) + new_mode = cycle[idx] + + # Save to config.yaml + try: + if "display" not in user_config or not isinstance(user_config.get("display"), dict): + user_config["display"] = {} + user_config["display"]["tool_progress"] = new_mode + with open(config_path, "w", encoding="utf-8") as f: + yaml.dump(user_config, f, default_flow_style=False, sort_keys=False) + return f"{descriptions[new_mode]}\n_(saved to config — takes effect on next message)_" + except Exception as e: + logger.warning("Failed to save tool_progress mode: %s", e) + return f"{descriptions[new_mode]}\n_(could not save to config: {e})_" + async def _handle_compress_command(self, event: MessageEvent) -> str: """Handle /compress command -- manually compress conversation context.""" source = event.source diff --git a/hermes_cli/commands.py b/hermes_cli/commands.py index 4e7a2352e..d442f7f94 100644 --- a/hermes_cli/commands.py +++ b/hermes_cli/commands.py @@ -36,6 +36,7 @@ class CommandDef: subcommands: tuple[str, ...] = () # tab-completable subcommands cli_only: bool = False # only available in CLI gateway_only: bool = False # only available in gateway/messaging + gateway_config_gate: str | None = None # config dotpath; when truthy, overrides cli_only for gateway # --------------------------------------------------------------------------- @@ -87,7 +88,8 @@ COMMAND_REGISTRY: list[CommandDef] = [ CommandDef("statusbar", "Toggle the context/model status bar", "Configuration", cli_only=True, aliases=("sb",)), CommandDef("verbose", "Cycle tool progress display: off -> new -> all -> verbose", - "Configuration", cli_only=True), + "Configuration", cli_only=True, + gateway_config_gate="display.tool_progress_command"), CommandDef("reasoning", "Manage reasoning effort and display", "Configuration", args_hint="[level|show|hide]", subcommands=("none", "low", "minimal", "medium", "high", "xhigh", "show", "hide", "on", "off")), @@ -205,7 +207,7 @@ def rebuild_lookups() -> None: GATEWAY_KNOWN_COMMANDS = frozenset( name for cmd in COMMAND_REGISTRY - if not cmd.cli_only + if not cmd.cli_only or cmd.gateway_config_gate for name in (cmd.name, *cmd.aliases) ) @@ -259,20 +261,76 @@ for _cmd in COMMAND_REGISTRY: # Gateway helpers # --------------------------------------------------------------------------- -# Set of all command names + aliases recognized by the gateway +# Set of all command names + aliases recognized by the gateway. +# Includes config-gated commands so the gateway can dispatch them +# (the handler checks the config gate at runtime). GATEWAY_KNOWN_COMMANDS: frozenset[str] = frozenset( name for cmd in COMMAND_REGISTRY - if not cmd.cli_only + if not cmd.cli_only or cmd.gateway_config_gate for name in (cmd.name, *cmd.aliases) ) +def _resolve_config_gates() -> set[str]: + """Return canonical names of commands whose ``gateway_config_gate`` is truthy. + + Reads ``config.yaml`` and walks the dot-separated key path for each + config-gated command. Returns an empty set on any error so callers + degrade gracefully. + """ + gated = [c for c in COMMAND_REGISTRY if c.gateway_config_gate] + if not gated: + return set() + try: + import yaml + config_path = os.path.join( + os.getenv("HERMES_HOME", os.path.expanduser("~/.hermes")), + "config.yaml", + ) + if os.path.exists(config_path): + with open(config_path, encoding="utf-8") as f: + cfg = yaml.safe_load(f) or {} + else: + cfg = {} + except Exception: + return set() + result: set[str] = set() + for cmd in gated: + val: Any = cfg + for key in cmd.gateway_config_gate.split("."): + if isinstance(val, dict): + val = val.get(key) + else: + val = None + break + if val: + result.add(cmd.name) + return result + + +def _is_gateway_available(cmd: CommandDef, config_overrides: set[str] | None = None) -> bool: + """Check if *cmd* should appear in gateway surfaces (help, menus, mappings). + + Unconditionally available when ``cli_only`` is False. When ``cli_only`` + is True but ``gateway_config_gate`` is set, the command is available only + when the config value is truthy. Pass *config_overrides* (from + ``_resolve_config_gates()``) to avoid re-reading config for every command. + """ + if not cmd.cli_only: + return True + if cmd.gateway_config_gate: + overrides = config_overrides if config_overrides is not None else _resolve_config_gates() + return cmd.name in overrides + return False + + def gateway_help_lines() -> list[str]: """Generate gateway help text lines from the registry.""" + overrides = _resolve_config_gates() lines: list[str] = [] for cmd in COMMAND_REGISTRY: - if cmd.cli_only: + if not _is_gateway_available(cmd, overrides): continue args = f" {cmd.args_hint}" if cmd.args_hint else "" alias_parts: list[str] = [] @@ -293,9 +351,10 @@ def telegram_bot_commands() -> list[tuple[str, str]]: underscores. Aliases are skipped -- Telegram shows one menu entry per canonical command. """ + overrides = _resolve_config_gates() result: list[tuple[str, str]] = [] for cmd in COMMAND_REGISTRY: - if cmd.cli_only: + if not _is_gateway_available(cmd, overrides): continue tg_name = cmd.name.replace("-", "_") result.append((tg_name, cmd.description)) @@ -308,9 +367,10 @@ def slack_subcommand_map() -> dict[str, str]: Maps both canonical names and aliases so /hermes bg do stuff works the same as /hermes background do stuff. """ + overrides = _resolve_config_gates() mapping: dict[str, str] = {} for cmd in COMMAND_REGISTRY: - if cmd.cli_only: + if not _is_gateway_available(cmd, overrides): continue mapping[cmd.name] = f"/{cmd.name}" for alias in cmd.aliases: diff --git a/hermes_cli/config.py b/hermes_cli/config.py index 826e3a8bc..745bf9123 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -269,6 +269,7 @@ DEFAULT_CONFIG = { "streaming": False, "show_cost": False, # Show $ cost in the status bar (off by default) "skin": "default", + "tool_progress_command": False, # Enable /verbose command in messaging gateway }, # Privacy settings diff --git a/tests/gateway/test_verbose_command.py b/tests/gateway/test_verbose_command.py new file mode 100644 index 000000000..857d0744e --- /dev/null +++ b/tests/gateway/test_verbose_command.py @@ -0,0 +1,146 @@ +"""Tests for gateway /verbose command (config-gated tool progress cycling).""" + +import asyncio +from unittest.mock import AsyncMock, MagicMock + +import pytest +import yaml + +import gateway.run as gateway_run +from gateway.config import Platform +from gateway.platforms.base import MessageEvent +from gateway.session import SessionSource + + +def _make_event(text="/verbose", 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__.""" + runner = object.__new__(gateway_run.GatewayRunner) + runner.adapters = {} + runner._ephemeral_system_prompt = "" + runner._prefill_messages = [] + runner._reasoning_config = None + runner._show_reasoning = False + runner._provider_routing = {} + runner._fallback_model = None + runner._running_agents = {} + runner.hooks = MagicMock() + runner.hooks.emit = AsyncMock() + runner.hooks.loaded_hooks = [] + runner._session_db = None + runner._get_or_create_gateway_honcho = lambda session_key: (None, None) + return runner + + +class TestVerboseCommand: + """Tests for _handle_verbose_command in the gateway.""" + + @pytest.mark.asyncio + async def test_disabled_by_default(self, tmp_path, monkeypatch): + """When tool_progress_command is false, /verbose returns an info message.""" + hermes_home = tmp_path / "hermes" + hermes_home.mkdir() + config_path = hermes_home / "config.yaml" + config_path.write_text("display:\n tool_progress: all\n", encoding="utf-8") + + monkeypatch.setattr(gateway_run, "_hermes_home", hermes_home) + + runner = _make_runner() + result = await runner._handle_verbose_command(_make_event()) + + assert "not enabled" in result.lower() + assert "tool_progress_command" in result + + @pytest.mark.asyncio + async def test_enabled_cycles_mode(self, tmp_path, monkeypatch): + """When enabled, /verbose cycles tool_progress mode.""" + hermes_home = tmp_path / "hermes" + hermes_home.mkdir() + config_path = hermes_home / "config.yaml" + config_path.write_text( + "display:\n tool_progress_command: true\n tool_progress: all\n", + encoding="utf-8", + ) + + monkeypatch.setattr(gateway_run, "_hermes_home", hermes_home) + + runner = _make_runner() + result = await runner._handle_verbose_command(_make_event()) + + # all -> verbose + assert "VERBOSE" in result + + # Verify config was saved + saved = yaml.safe_load(config_path.read_text(encoding="utf-8")) + assert saved["display"]["tool_progress"] == "verbose" + + @pytest.mark.asyncio + async def test_cycles_through_all_modes(self, tmp_path, monkeypatch): + """Calling /verbose repeatedly cycles through all four modes.""" + hermes_home = tmp_path / "hermes" + hermes_home.mkdir() + config_path = hermes_home / "config.yaml" + config_path.write_text( + "display:\n tool_progress_command: true\n tool_progress: 'off'\n", + encoding="utf-8", + ) + + monkeypatch.setattr(gateway_run, "_hermes_home", hermes_home) + runner = _make_runner() + + # off -> new -> all -> verbose -> off + expected = ["new", "all", "verbose", "off"] + for mode in expected: + result = await runner._handle_verbose_command(_make_event()) + saved = yaml.safe_load(config_path.read_text(encoding="utf-8")) + assert saved["display"]["tool_progress"] == mode, \ + f"Expected {mode}, got {saved['display']['tool_progress']}" + + @pytest.mark.asyncio + async def test_defaults_to_all_when_no_tool_progress_set(self, tmp_path, monkeypatch): + """When tool_progress is not in config, defaults to 'all' then cycles to verbose.""" + hermes_home = tmp_path / "hermes" + hermes_home.mkdir() + config_path = hermes_home / "config.yaml" + config_path.write_text( + "display:\n tool_progress_command: true\n", + encoding="utf-8", + ) + + monkeypatch.setattr(gateway_run, "_hermes_home", hermes_home) + + runner = _make_runner() + result = await runner._handle_verbose_command(_make_event()) + + # default "all" -> verbose + assert "VERBOSE" in result + saved = yaml.safe_load(config_path.read_text(encoding="utf-8")) + assert saved["display"]["tool_progress"] == "verbose" + + @pytest.mark.asyncio + async def test_no_config_file_returns_disabled(self, tmp_path, monkeypatch): + """When config.yaml doesn't exist, command reports disabled.""" + hermes_home = tmp_path / "hermes" + hermes_home.mkdir() + # No config.yaml + + monkeypatch.setattr(gateway_run, "_hermes_home", hermes_home) + + runner = _make_runner() + result = await runner._handle_verbose_command(_make_event()) + assert "not enabled" in result.lower() + + def test_verbose_is_in_gateway_known_commands(self): + """The /verbose command is recognized by the gateway dispatch.""" + from hermes_cli.commands import GATEWAY_KNOWN_COMMANDS + assert "verbose" in GATEWAY_KNOWN_COMMANDS diff --git a/tests/hermes_cli/test_commands.py b/tests/hermes_cli/test_commands.py index 7e53d048f..2c7ef280a 100644 --- a/tests/hermes_cli/test_commands.py +++ b/tests/hermes_cli/test_commands.py @@ -134,12 +134,19 @@ class TestDerivedDicts: # --------------------------------------------------------------------------- class TestGatewayKnownCommands: - def test_excludes_cli_only(self): + def test_excludes_cli_only_without_config_gate(self): for cmd in COMMAND_REGISTRY: - if cmd.cli_only: + if cmd.cli_only and not cmd.gateway_config_gate: assert cmd.name not in GATEWAY_KNOWN_COMMANDS, \ f"cli_only command '{cmd.name}' should not be in GATEWAY_KNOWN_COMMANDS" + def test_includes_config_gated_cli_only(self): + """Commands with gateway_config_gate are always in GATEWAY_KNOWN_COMMANDS.""" + for cmd in COMMAND_REGISTRY: + if cmd.gateway_config_gate: + assert cmd.name in GATEWAY_KNOWN_COMMANDS, \ + f"config-gated command '{cmd.name}' should be in GATEWAY_KNOWN_COMMANDS" + def test_includes_gateway_commands(self): for cmd in COMMAND_REGISTRY: if not cmd.cli_only: @@ -160,11 +167,11 @@ class TestGatewayHelpLines: lines = gateway_help_lines() assert len(lines) > 10 - def test_excludes_cli_only_commands(self): + def test_excludes_cli_only_commands_without_config_gate(self): lines = gateway_help_lines() joined = "\n".join(lines) for cmd in COMMAND_REGISTRY: - if cmd.cli_only: + if cmd.cli_only and not cmd.gateway_config_gate: assert f"`/{cmd.name}" not in joined, \ f"cli_only command /{cmd.name} should not be in gateway help" @@ -188,10 +195,10 @@ class TestTelegramBotCommands: for name, _ in telegram_bot_commands(): assert "-" not in name, f"Telegram command '{name}' contains a hyphen" - def test_excludes_cli_only(self): + def test_excludes_cli_only_without_config_gate(self): names = {name for name, _ in telegram_bot_commands()} for cmd in COMMAND_REGISTRY: - if cmd.cli_only: + if cmd.cli_only and not cmd.gateway_config_gate: tg_name = cmd.name.replace("-", "_") assert tg_name not in names @@ -211,13 +218,84 @@ class TestSlackSubcommandMap: assert "bg" in mapping assert "reset" in mapping - def test_excludes_cli_only(self): + def test_excludes_cli_only_without_config_gate(self): mapping = slack_subcommand_map() for cmd in COMMAND_REGISTRY: - if cmd.cli_only: + if cmd.cli_only and not cmd.gateway_config_gate: assert cmd.name not in mapping +# --------------------------------------------------------------------------- +# Config-gated gateway commands +# --------------------------------------------------------------------------- + +class TestGatewayConfigGate: + """Tests for the gateway_config_gate mechanism on CommandDef.""" + + def test_verbose_has_config_gate(self): + cmd = resolve_command("verbose") + assert cmd is not None + assert cmd.cli_only is True + assert cmd.gateway_config_gate == "display.tool_progress_command" + + def test_verbose_in_gateway_known_commands(self): + """Config-gated commands are always recognized by the gateway.""" + assert "verbose" in GATEWAY_KNOWN_COMMANDS + + def test_config_gate_excluded_from_help_when_off(self, tmp_path, monkeypatch): + """When the config gate is falsy, the command should not appear in help.""" + # Write a config with the gate off (default) + config_file = tmp_path / "config.yaml" + config_file.write_text("display:\n tool_progress_command: false\n") + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + + lines = gateway_help_lines() + joined = "\n".join(lines) + assert "`/verbose" not in joined + + def test_config_gate_included_in_help_when_on(self, tmp_path, monkeypatch): + """When the config gate is truthy, the command should appear in help.""" + config_file = tmp_path / "config.yaml" + config_file.write_text("display:\n tool_progress_command: true\n") + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + + lines = gateway_help_lines() + joined = "\n".join(lines) + assert "`/verbose" in joined + + def test_config_gate_excluded_from_telegram_when_off(self, tmp_path, monkeypatch): + config_file = tmp_path / "config.yaml" + config_file.write_text("display:\n tool_progress_command: false\n") + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + + names = {name for name, _ in telegram_bot_commands()} + assert "verbose" not in names + + def test_config_gate_included_in_telegram_when_on(self, tmp_path, monkeypatch): + config_file = tmp_path / "config.yaml" + config_file.write_text("display:\n tool_progress_command: true\n") + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + + names = {name for name, _ in telegram_bot_commands()} + assert "verbose" in names + + def test_config_gate_excluded_from_slack_when_off(self, tmp_path, monkeypatch): + config_file = tmp_path / "config.yaml" + config_file.write_text("display:\n tool_progress_command: false\n") + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + + mapping = slack_subcommand_map() + assert "verbose" not in mapping + + def test_config_gate_included_in_slack_when_on(self, tmp_path, monkeypatch): + config_file = tmp_path / "config.yaml" + config_file.write_text("display:\n tool_progress_command: true\n") + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + + mapping = slack_subcommand_map() + assert "verbose" in mapping + + # --------------------------------------------------------------------------- # Autocomplete (SlashCommandCompleter) # --------------------------------------------------------------------------- diff --git a/website/docs/reference/slash-commands.md b/website/docs/reference/slash-commands.md index 057418c7f..70b15efa9 100644 --- a/website/docs/reference/slash-commands.md +++ b/website/docs/reference/slash-commands.md @@ -46,7 +46,7 @@ Type `/` in the CLI to open the autocomplete menu. Built-in commands are case-in | `/provider` | Show available providers and current provider | | `/prompt` | View/set custom system prompt | | `/personality` | Set a predefined personality | -| `/verbose` | Cycle tool progress display: off → new → all → verbose | +| `/verbose` | Cycle tool progress display: off → new → all → verbose. Can be [enabled for messaging](#notes) via config. | | `/reasoning` | Manage reasoning effort and display (usage: /reasoning [level\|show\|hide]) | | `/skin` | Show or change the display skin/theme | | `/voice [on\|off\|tts\|status]` | Toggle CLI voice mode and spoken playback. Recording uses `voice.record_key` (default: `Ctrl+B`). | @@ -125,7 +125,8 @@ The messaging gateway supports the following built-in commands inside Telegram, ## Notes -- `/skin`, `/tools`, `/toolsets`, `/browser`, `/config`, `/prompt`, `/cron`, `/skills`, `/platforms`, `/paste`, `/verbose`, `/statusbar`, and `/plugins` are **CLI-only** commands. +- `/skin`, `/tools`, `/toolsets`, `/browser`, `/config`, `/prompt`, `/cron`, `/skills`, `/platforms`, `/paste`, `/statusbar`, and `/plugins` are **CLI-only** commands. +- `/verbose` is **CLI-only by default**, but can be enabled for messaging platforms by setting `display.tool_progress_command: true` in `config.yaml`. When enabled, it cycles the `display.tool_progress` mode and saves to config. - `/status`, `/sethome`, `/update`, `/approve`, and `/deny` are **messaging-only** commands. - `/background`, `/voice`, `/reload-mcp`, and `/rollback` work in **both** the CLI and the messaging gateway. - `/voice join`, `/voice channel`, and `/voice leave` are only meaningful on Discord. diff --git a/website/docs/user-guide/cli.md b/website/docs/user-guide/cli.md index 334ef6691..1c4857d71 100644 --- a/website/docs/user-guide/cli.md +++ b/website/docs/user-guide/cli.md @@ -230,7 +230,7 @@ The CLI shows animated feedback as the agent works: ┊ 📄 web_extract (2.1s) ``` -Cycle through display modes with `/verbose`: `off → new → all → verbose`. +Cycle through display modes with `/verbose`: `off → new → all → verbose`. This command can also be enabled for messaging platforms — see [configuration](/docs/user-guide/configuration#display-settings). ## Session Management diff --git a/website/docs/user-guide/configuration.md b/website/docs/user-guide/configuration.md index 7e5dc5373..6e4cfa98e 100644 --- a/website/docs/user-guide/configuration.md +++ b/website/docs/user-guide/configuration.md @@ -1163,6 +1163,7 @@ This controls both the `text_to_speech` tool and spoken replies in voice mode (` ```yaml display: tool_progress: all # off | new | all | verbose + tool_progress_command: false # Enable /verbose slash command in messaging gateway skin: default # Built-in or custom CLI skin (see user-guide/features/skins) theme_mode: auto # auto | light | dark — color scheme for skin-aware rendering personality: "kawaii" # Legacy cosmetic field still surfaced in some summaries @@ -1194,6 +1195,8 @@ This works with any skin — built-in or custom. Skin authors can provide `color | `all` | Every tool call with a short preview (default) | | `verbose` | Full args, results, and debug logs | +In the CLI, cycle through these modes with `/verbose`. To use `/verbose` in messaging platforms (Telegram, Discord, Slack, etc.), set `tool_progress_command: true` in the `display` section above. The command will then cycle the mode and save to config. + ## Privacy ```yaml diff --git a/website/docs/user-guide/features/plugins.md b/website/docs/user-guide/features/plugins.md index 5b9a87016..30ab6c35a 100644 --- a/website/docs/user-guide/features/plugins.md +++ b/website/docs/user-guide/features/plugins.md @@ -83,6 +83,7 @@ The handler receives the argument string (everything after `/greet`) and returns | `aliases` | Tuple of alternative names | | `cli_only` | Only available in CLI | | `gateway_only` | Only available in messaging platforms | +| `gateway_config_gate` | Config dotpath (e.g. `"display.my_option"`). When set on a `cli_only` command, the command becomes available in the gateway if the config value is truthy. | ## Managing plugins diff --git a/website/docs/user-guide/messaging/index.md b/website/docs/user-guide/messaging/index.md index 597e19514..6069df4f4 100644 --- a/website/docs/user-guide/messaging/index.md +++ b/website/docs/user-guide/messaging/index.md @@ -188,6 +188,7 @@ Control how much tool activity is displayed in `~/.hermes/config.yaml`: ```yaml display: tool_progress: all # off | new | all | verbose + tool_progress_command: false # set to true to enable /verbose in messaging ``` When enabled, the bot sends status messages as it works: