# NousResearch/hermes-agent · Shell command passthrough and quick command bypass features

## Open now

- 2 issues, 1 PR open on GitHub.
- #25783: bug: exec quick commands routed to agent as text when agent is mid-turn
- #28663: Exec-type quick commands are blocked when gateway is draining
- PR #28734: fix(gateway): allow exec-type quick commands to bypass draining guard

These issues and PRs concern adding a \`!\` prefix for direct shell command execution from chat input, and ensuring exec-type quick commands bypass the agent loop or gateway draining guard rather than being routed as text.

## TL;DR

- 10 member(s): 2 open issue(s), 4 closed issue(s), 1 open PR(s), 1 merged PR(s), 2 PR(s) closed without merging.
- A fix was merged to main in PR #746. Verify it addresses your case.
- **Recommended:** read PR #746 (and any competing PRs below) before filing. Confirm the merged change covers your symptom.

## Maintainer resolution verdict

- Internal-tracker verdict over this cluster's issue members: `partially_resolved` · deciding PR(s): `pr-746`. This is distinct from the fix status above. Weigh it yourself.

## How this cluster was grouped

- Link evidence (`STRUCTURAL_PARTIAL`): some members are connected by structural evidence (shared changed files, or a closing/superseded reference); the rest are joined by an automated relevance judgement over their text.
- Members outside that structural core may belong to a different underlying problem.
- Pairwise link density `0.14`: the share of member pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36. `1.00` means every pair is linked.

## Open related issues

- #25783: bug: exec quick commands routed to agent as text when agent is mid-turn
- #28663: Exec-type quick commands are blocked when gateway is draining

## Closed / duplicate issues

- #744: Feature Request: Native User-Defined Quick Commands That Bypass the Agent Loop
- #7545: \[Feature\]: Bang (!) prefix to run shell commands inline from chat input
- #33766: feat: \`!command\` prefix for direct shell execution (like Claude Code)
- #53341: feat(cli): add \`!\` prefix for direct shell command passthrough

## Prior & competing PRs

- **#746: feat(cli,gateway): add user-defined quick commands that bypass agent loop**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`; implication `superseded`
  - files: `cli.py`, `gateway/run.py`, `hermes_cli/config.py`, `tests/test_quick_commands.py`
  - fix hunks:
  - `cli.py`

    ```diff
    @@ -2400,9 +2400,32 @@ def process_command(self, command: str) -> bool:
             elif cmd_lower == "/reload-mcp":
                 self._reload_mcp()
             else:
    -            # Check for skill slash commands (/gif-search, /axolotl, etc.)
    +            # Check for user-defined quick commands (bypass agent loop, no LLM call)
                 base_cmd = cmd_lower.split()[0]
    -            if base_cmd in _skill_commands:
    +            quick_commands = self.config.get("quick_commands", {})
    +            if base_cmd.lstrip("/") in quick_commands:
    +                qcmd = quick_commands[base_cmd.lstrip("/")]
    +                if qcmd.get("type") == "exec":
    +                    import subprocess
    +                    exec_cmd = qcmd.get("command", "")
    +                    if exec_cmd:
    +                        try:
    +                            result = subprocess.run(
    +                                exec_cmd, shell=True, capture_output=True,
    +                                text=True, timeout=30
    +                            )
    +                            output = result.stdout.strip() or result.stderr.strip()
    +                            self.console.print(output if output else "[dim]Command returned no output[/]")
    +                        except subprocess.TimeoutExpired:
    +                            self.console.print("[bold red]Quick command timed out (30s)[/]")
    +                        except Exception as e:
    +                            self.console.print(f"[bold red]Quick command error: {e}[/]")
    +                    else:
    +                        self.console.print(f"[bold red]Quick command '{base_cmd}' has no command defined[/]")
    +                else:
    +                    self.console.print(f"[bold red]Quick command '{base_cmd}' has unsupported type (only 'exec' is supported)[/]")
    +            # Check for skill slash commands (/gif-search, /axolotl, etc.)
    +            elif base_cmd in _skill_commands:
                     user_instruction = cmd_original[len(base_cmd):].strip()
                     msg = build_skill_invocation_message(base_cmd, user_instruction)
                     if msg:
    ```
  - `gateway/run.py`

    ```diff
    @@ -771,6 +771,33 @@ async def _handle_message(self, event: MessageEvent) -> Optional[str]:
             if command == "resume":
                 return await self._handle_resume_command(event)
             
    +        # User-defined quick commands (bypass agent loop, no LLM call)
    +        if command:
    +            quick_commands = self.config.get("quick_commands", {})
    +            if command in quick_commands:
    +                qcmd = quick_commands[command]
    +                if qcmd.get("type") == "exec":
    +                    import asyncio
    +                    exec_cmd = qcmd.get("command", "")
    +                    if exec_cmd:
    +                        try:
    +                            proc = await asyncio.create_subprocess_shell(
    +                                exec_cmd,
    +                                stdout=asyncio.subprocess.PIPE,
    +                                stderr=asyncio.subprocess.PIPE,
    +                            )
    +                            stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=30)
    +                            output = (stdout or stderr).decode().strip()
    +                            return output if output else "Command returned no output."
    +                        except asyncio.TimeoutError:
    +                            return "Quick command timed out (30s)."
    +                        except Exception as e:
    +                            return f"Quick command error: {e}"
    +                    else:
    +                        return f"Quick command '/{command}' has no command defined."
    +                else:
    +                    return f"Quick command '/{command}' has unsupported type (only 'exec' is supported)."
    +
             # Skill slash commands: /skill-name loads the skill and sends to agent
             if command:
                 try:
    ```
  - `hermes_cli/config.py`

    ```diff
    @@ -147,6 +147,8 @@ def ensure_hermes_home():
     
         # Permanently allowed dangerous command patterns (added via "always" approval)
         "command_allowlist": [],
    +    # User-defined quick commands that bypass the agent loop (type: exec only)
    +    "quick_commands": {},
     
         # Config schema version - bump this when adding new required fields
         "_config_version": 5,
    ```
  - `tests/test_quick_commands.py`

    ```diff
    @@ -0,0 +1,137 @@
    +"""Tests for user-defined quick commands that bypass the agent loop."""
    +import subprocess
    +from unittest.mock import MagicMock, patch, AsyncMock
    +import pytest
    +
    +
    +# ── CLI tests ──────────────────────────────────────────────────────────────
    +
    +class TestCLIQuickCommands:
    +    """Test quick command dispatch in HermesCLI.process_command."""
    +
    +    def _make_cli(self, quick_commands):
    +        from cli import HermesCLI
    +        cli = HermesCLI.__new__(HermesCLI)
    +        cli.config = {"quick_commands": quick_commands}
    +        cli.console = MagicMock()
    +        cli.agent = None
    +        cli.conversation_history = []
    +        return cli
    +
    +    def test_exec_command_runs_and_prints_output(self):
    +        cli = self._make_cli({"dn": {"type": "exec", "command": "echo daily-note"}})
    +        result = cli.process_command("/dn")
    +        assert result is True
    +        cli.console.print.assert_called_once_with("daily-note")
    +
    +    def test_exec_command_stderr_shown_on_no_stdout(self):
    +        cli = self._make_cli({"err": {"type": "exec", "command": "echo error >&2"}})
    +        result = cli.process_command("/err")
    +        assert result is True
    +        # stderr fallback — should print something
    +        cli.console.print.assert_called_once()
    +
    +    def test_exec_command_no_output_shows_fallback(self):
    +        cli = self._make_cli({"empty": {"type": "exec", "command": "true"}})
    +        cli.process_command("/empty")
    +        cli.console.print.assert_called_once()
    +        args = cli.console.print.call_args[0][0]
    +        assert "no output" in args.lower()
    +
    +    def test_unsupported_type_shows_error(self):
    +        cli = self._make_cli({"bad": {"type": "prompt", "command": "echo hi"}})
    +        cli.process_command("/bad")
    +        cli.console.print.assert_called_once()
    +        args = cli.console.print.call_args[0][0]
    +        assert "unsupported type" in args.lower()
    +
    +    def test_missing_command_field_shows_error(self):
    +        cli = self._make_cli({"oops": {"type": "exec"}})
    +        cli.process_command("/oops")
    +        cli.console.print.assert_called_once()
    +        args = cli.console.print.call_args[0][0]
    +        assert "no command defined" in args.lower()
    +
    +    def test_quick_command_takes_priority_over_skill_commands(self):
    +        """Quick commands must be checked before skill slash commands."""
    +        cli = self._make_cli({"mygif": {"type": "exec", "command": "echo overridden"}})
    +        with patch("cli._skill_commands", {"/mygif": {"name": "gif-search"}}):
    +            cli.process_command("/mygif")
    +        cli.console.print.assert_called_once_with("overridden")
    +
    +    def test_unknown_command_still_shows_error(self):
    +        cli = self._make_cli({})
    +        cli.process_command("/nonexistent")
    +        cli.console.print.assert_called()
    +        args = cli.console.print.call_args_list[0][0][0]
    +        assert "unknown command" in args.lower()
    +
    +    def test_timeout_shows_error(self):
    +        cli = self._make_cli({"slow": {"type": "exec", "command": "sleep 100"}})
    +        with patch("subprocess.run", side_effect=subprocess.TimeoutExpired("sleep", 30)):
    +            cli.process_command("/slow")
    +        cli.console.print.assert_called_once()
    +        args = cli.console.print.call_args[0][0]
    +        assert "timed out" in args.lower()
    +
    +
    +# ── Gateway tests ──────────────────────────────────────────────────────────
    +
    +class TestGatewayQuickCommands:
    +    """Test quick command dispatch in GatewayRunner._handle_message."""
    +
    +    def _make_event(self, command, args=""):
    +        event = MagicMock()
    +        event.get_command.return_value = command
    +        event.get_command_args.return_value = args
    +        event.text = f"/{command} {args}".strip()
    +        event.source = MagicMock()
    +        event.source.user_id = "test_user"
    +        event.source.user_name = "Test User"
    +        event.source.platform.value = "telegram"
    +        event.source.chat_type = "dm"
    +        event.source.chat_id = "123"
    +        return event
    +
    +    @pytest.mark.asyncio
    +    async def test_exec_command_returns_output(self):
    +        from gateway.run import GatewayRunner
    +        runner = GatewayRunner.__new__(GatewayRunner)
    +        runner.config = {"quick_commands": {"limits": {"type": "exec", "command": "echo ok"}}}
    +        runner._running_agents = {}
    +        runner._pending_messages = {}
    +        runner._is_user_authorized = MagicMock(return_value=True)
    +
    +        event = self._make_event("limits")
    +        result = await runner._handle_message(event)
    +        assert result == "ok"
    +
    +    @pytest.mark.asyncio
    +    async def test_unsupported_type_returns_error(self):
    +        from gateway.run import GatewayRunner
    +        runner = GatewayRunner.__new__(GatewayRunner)
    +        runner.config = {"quick_commands": {"bad": {"type": "prompt", "command": "echo hi"}}}
    +        runner._running_agents = {}
    +        runner._pending_messages = {}
    +        runner._is_user_authorized = MagicMock(return_value=True)
    +
    +        event = self._make_event("bad")
    +        result = await runner._handle_message(event)
    +        assert result is not None
    +        assert "unsupported type" in result.lower()
    +
    +    @pytest.mark.asyncio
    +    async def test_timeout_returns_error(self):
    +        from gateway.run import GatewayRunner
    +        import asyncio
    +        runner = GatewayRunner.__new__(GatewayRunner)
    +        runner.config = {"quick_commands": {"slow": {"type": "exec", "command": "sleep 100"}}}
    +        runner._running_agents = {}
    +        runner._pending_messages = {}
    +        runner._is_user_authorized = MagicMock(return_value=True)
    +
    +        event = self._make_event("slow")
    +        with patch("asyncio.wait_for", side_effect=asyncio.TimeoutError):
    +            result = await runner._handle_message(event)
    +        assert result is not None
    +        assert "timed out" in result.lower()
    ```
- **#7728: feat(cli): add ! prefix to run shell commands inline**. closed without merge
  - maintainer direction: category `implemented_elsewhere`; implication `superseded`; refs `9704ed86c1`, `#7545`
- **#25804: fix(gateway): run exec quick commands inline when agent is mid-turn (#25783)**. closed without merge
  - maintainer direction: category `stale_abandoned`; implication `active`
- **#28734: fix(gateway): allow exec-type quick commands to bypass draining guard**. open
  - maintainer direction: category `uncategorized`; implication `needs_llm`
  - files: `gateway/run.py`, `tests/gateway/test_exec_quick_command_draining.py`

## Fix status

- Merged to main in PR #746 (cited above). No release data is tracked. Verify downstream.
- Fix last verified (merged) `2026-03-11`. This confirmation is old and may be out of date (a revert/reopen would not have invalidated it here); re-verify.
- Subsystem: `gateway`
