{"repo":"NousResearch/hermes-agent","public_id":"18993","display_label":"TUI plugin slash commands running in the live gateway","summary":"One bug report describes plugin-registered TUI slash commands running in a slash-worker 'split-brain' state, and two fix pull requests concern dispatching or running those commands live in the gateway. The titles point to a single shared theme; details of the behavior and outcomes are not stated.","subsystem":null,"counts":{"members":3,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":1,"merged_prs":1,"attempt_prs":1},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":19338,"merged_at":"2026-05-04T16:57:46+00:00"},"resolution":{"state":"resolved","by":"pr-19338"},"freshness":{"state":"verified","last_verified_at":"2026-05-04T16:57:46+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.6666666666666666},"fix_edges":[{"issue_number":18993,"pr_number":19338,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true,"pr_merged_at":"2026-05-04T16:57:46+00:00"}],"members":[{"kind":"issue","number":18993,"title":"Bug: plugin-registered TUI slash commands can run in slash-worker split-brain","url":null,"state":"closed"},{"kind":"pr","number":19193,"title":"fix(tui): dispatch plugin slashes in live gateway","url":null,"state":"closed","close_reason":{"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":19338,"title":"fix(tui): run plugin slash commands live","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["tests/tui_gateway/test_protocol.py","tui_gateway/server.py"],"fix_hunks":[{"file":"tests/tui_gateway/test_protocol.py","hunks":["@@ -391,6 +391,99 @@ def test_slash_exec_rejects_skill_commands(server):\n     assert \"skill command\" in resp[\"error\"][\"message\"]\n \n \n+def test_slash_exec_handles_plugin_commands_in_live_gateway(server):\n+    \"\"\"Plugin slash commands return normal slash.exec output without using the worker.\"\"\"\n+    sid = \"test-session\"\n+\n+    class Worker:\n+        def __init__(self):\n+            self.calls = []\n+\n+        def run(self, cmd):\n+            self.calls.append(cmd)\n+            return f\"worker:{cmd}\"\n+\n+    worker = Worker()\n+    server._sessions[sid] = {\"session_key\": sid, \"agent\": None, \"slash_worker\": worker}\n+\n+    with patch(\n+        \"hermes_cli.plugins.get_plugin_command_handler\",\n+        lambda name: (lambda arg: f\"plugin:{arg}\") if name == \"plugin-cmd\" else None,\n+    ):\n+        resp = server.handle_request({\n+            \"id\": \"r-plugin-slash\",\n+            \"method\": \"slash.exec\",\n+            \"params\": {\"command\": \"plugin-cmd hello\", \"session_id\": sid},\n+        })\n+\n+    assert \"error\" not in resp\n+    assert resp[\"result\"] == {\"output\": \"plugin:hello\"}\n+    assert worker.calls == []\n+\n+\n+def test_slash_exec_plugin_lookup_failure_falls_back_to_worker(server):\n+    \"\"\"Plugin discovery failures must not break ordinary slash-worker commands.\"\"\"\n+    sid = \"test-session\"\n+\n+    class Worker:\n+        def __init__(self):\n+            self.calls = []\n+\n+        def run(self, cmd):\n+            self.calls.append(cmd)\n+            return f\"worker:{cmd}\"\n+\n+    worker = Worker()\n+    server._sessions[sid] = {\"session_key\": sid, \"agent\": None, \"slash_worker\": worker}\n+\n+    with patch(\n+        \"hermes_cli.plugins.get_plugin_command_handler\",\n+        side_effect=RuntimeError(\"discovery boom\"),\n+    ):\n+        resp = server.handle_request({\n+            \"id\": \"r-plugin-lookup-failure\",\n+            \"method\": \"slash.exec\",\n+            \"params\": {\"command\": \"help\", \"session_id\": sid},\n+        })\n+\n+    assert \"error\" not in resp\n+    assert resp[\"result\"] == {\"output\": \"worker:help\"}\n+    assert worker.calls == [\"help\"]\n+\n+\n+def test_slash_exec_plugin_handler_error_returns_output(server):\n+    \"\"\"Plugin handler failures return slash output so the TUI does not redispatch.\"\"\"\n+    sid = \"test-session\"\n+\n+    class Worker:\n+        def __init__(self):\n+            self.calls = []\n+\n+        def run(self, cmd):\n+            self.calls.append(cmd)\n+            return f\"worker:{cmd}\"\n+\n+    def handler(arg):\n+        raise RuntimeError(f\"handler boom: {arg}\")\n+\n+    worker = Worker()\n+    server._sessions[sid] = {\"session_key\": sid, \"agent\": None, \"slash_worker\": worker}\n+\n+    with patch(\n+        \"hermes_cli.plugins.get_plugin_command_handler\",\n+        lambda name: handler if name == \"plugin-cmd\" else None,\n+    ):\n+        resp = server.handle_request({\n+            \"id\": \"r-plugin-handler-error\",\n+            \"method\": \"slash.exec\",\n+            \"params\": {\"command\": \"plugin-cmd hello\", \"session_id\": sid},\n+        })\n+\n+    assert \"error\" not in resp\n+    assert resp[\"result\"] == {\"output\": \"Plugin command error: handler boom: hello\"}\n+    assert worker.calls == []\n+\n+\n @pytest.mark.parametrize(\"cmd\", [\"retry\", \"queue hello\", \"q hello\", \"steer fix the test\", \"plan\"])\n def test_slash_exec_rejects_pending_input_commands(server, cmd):\n     \"\"\"slash.exec must reject commands that use _pending_input in the CLI.\"\"\""]},{"file":"tui_gateway/server.py","hunks":["@@ -5165,9 +5165,13 @@ def _(rid, params: dict) -> dict:\n         return _err(rid, 4004, \"empty command\")\n \n     # Skill slash commands and _pending_input commands must NOT go through the\n-    # slash worker — see _PENDING_INPUT_COMMANDS definition above.\n-    _cmd_parts = cmd.split() if not cmd.startswith(\"/\") else cmd.lstrip(\"/\").split()\n-    _cmd_base = _cmd_parts[0] if _cmd_parts else \"\"\n+    # slash worker — see _PENDING_INPUT_COMMANDS definition above. Plugin\n+    # commands must also avoid the worker, but unlike skills/pending-input they\n+    # still return normal slash.exec output so the TUI keeps the pager path.\n+    _cmd_text = cmd.lstrip(\"/\") if cmd.startswith(\"/\") else cmd\n+    _cmd_parts = _cmd_text.split(maxsplit=1)\n+    _cmd_base = (_cmd_parts[0] if _cmd_parts else \"\").lower()\n+    _cmd_arg = _cmd_parts[1] if len(_cmd_parts) > 1 else \"\"\n \n     if _cmd_base in _PENDING_INPUT_COMMANDS:\n         return _err(","@@ -5185,6 +5189,27 @@ def _(rid, params: dict) -> dict:\n     except Exception:\n         pass\n \n+    plugin_handler = None\n+    resolve_plugin_command_result = None\n+    if _cmd_base:\n+        try:\n+            from hermes_cli.plugins import (\n+                get_plugin_command_handler,\n+                resolve_plugin_command_result,\n+            )\n+\n+            plugin_handler = get_plugin_command_handler(_cmd_base)\n+        except Exception:\n+            plugin_handler = None\n+            resolve_plugin_command_result = None\n+\n+    if plugin_handler and resolve_plugin_command_result:\n+        try:\n+            result = resolve_plugin_command_result(plugin_handler(_cmd_arg))\n+            return _ok(rid, {\"output\": str(result or \"(no output)\")})\n+        except Exception as e:\n+            return _ok(rid, {\"output\": f\"Plugin command error: {e}\"})\n+\n     worker = session.get(\"slash_worker\")\n     if not worker:\n         try:\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}