{"repo":"NousResearch/hermes-agent","public_id":"3614","display_label":"Discord slash commands for approve and deny actions","summary":"These items focus on registering /approve and /deny as native Discord slash commands and wiring up related UI elements such as button-based approval interactions.","subsystem":"gateway","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":4800,"merged_at":"2026-04-03T17:24:08+00:00"},"resolution":{"state":"unresolved","by":null},"freshness":{"state":"stale","last_verified_at":"2026-04-03T17:24:08+00:00","is_stale":true},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.6666666666666666},"fix_edges":[{"issue_number":3614,"pr_number":4800,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false,"pr_merged_at":"2026-04-03T17:24:08+00:00"}],"members":[{"kind":"issue","number":3614,"title":"Add Discord slash commands for /approve and /deny","url":null,"state":"closed"},{"kind":"pr","number":3620,"title":"feat(discord): register /approve and /deny as native Discord slash commands","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#4800","number":4800}],"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"kind":"pr","number":4800,"title":"fix(discord): register /approve and /deny slash commands, wire up button-based approval UI","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["gateway/platforms/discord.py","gateway/run.py","tests/gateway/test_discord_document_handling.py","tests/gateway/test_discord_free_response.py","tests/gateway/test_discord_send.py","tests/gateway/test_voice_command.py"],"fix_hunks":[{"file":"gateway/platforms/discord.py","hunks":["@@ -1617,6 +1617,16 @@ async def slash_voice(interaction: discord.Interaction, mode: str = \"\"):\n         async def slash_update(interaction: discord.Interaction):\n             await self._run_simple_slash(interaction, \"/update\", \"Update initiated~\")\n \n+        @tree.command(name=\"approve\", description=\"Approve a pending dangerous command\")\n+        @discord.app_commands.describe(scope=\"Optional: 'all', 'session', 'always', 'all session', 'all always'\")\n+        async def slash_approve(interaction: discord.Interaction, scope: str = \"\"):\n+            await self._run_simple_slash(interaction, f\"/approve {scope}\".strip())\n+\n+        @tree.command(name=\"deny\", description=\"Deny a pending dangerous command\")\n+        @discord.app_commands.describe(scope=\"Optional: 'all' to deny all pending commands\")\n+        async def slash_deny(interaction: discord.Interaction, scope: str = \"\"):\n+            await self._run_simple_slash(interaction, f\"/deny {scope}\".strip())\n+\n         @tree.command(name=\"thread\", description=\"Create a new thread and start a Hermes session in it\")\n         @discord.app_commands.describe(\n             name=\"Thread name\",","@@ -1860,33 +1870,41 @@ async def _auto_create_thread(self, message: 'DiscordMessage') -> Optional[Any]:\n             return None\n \n     async def send_exec_approval(\n-        self, chat_id: str, command: str, approval_id: str\n+        self, chat_id: str, command: str, session_key: str,\n+        description: str = \"dangerous command\",\n+        metadata: Optional[dict] = None,\n     ) -> SendResult:\n         \"\"\"\n         Send a button-based exec approval prompt for a dangerous command.\n \n-        Returns SendResult. The approval is resolved when a user clicks a button.\n+        The buttons call ``resolve_gateway_approval()`` to unblock the waiting\n+        agent thread — this replaces the text-based ``/approve`` flow on Discord.\n         \"\"\"\n         if not self._client or not DISCORD_AVAILABLE:\n             return SendResult(success=False, error=\"Not connected\")\n \n         try:\n-            channel = self._client.get_channel(int(chat_id))\n+            # Resolve channel — use thread_id from metadata if present\n+            target_id = chat_id\n+            if metadata and metadata.get(\"thread_id\"):\n+                target_id = metadata[\"thread_id\"]\n+\n+            channel = self._client.get_channel(int(target_id))\n             if not channel:\n-                channel = await self._client.fetch_channel(int(chat_id))\n+                channel = await self._client.fetch_channel(int(target_id))\n \n             # Discord embed description limit is 4096; show full command up to that\n             max_desc = 4088\n             cmd_display = command if len(command) <= max_desc else command[: max_desc - 3] + \"...\"\n             embed = discord.Embed(\n-                title=\"Command Approval Required\",\n+                title=\"⚠️ Command Approval Required\",\n                 description=f\"```\\n{cmd_display}\\n```\",\n                 color=discord.Color.orange(),\n             )\n-            embed.set_footer(text=f\"Approval ID: {approval_id}\")\n+            embed.add_field(name=\"Reason\", value=description, inline=False)\n \n             view = ExecApprovalView(\n-                approval_id=approval_id,\n+                session_key=session_key,\n                 allowed_user_ids=self._allowed_user_ids,\n             )\n ","@@ -2219,13 +2237,15 @@ class ExecApprovalView(discord.ui.View):\n         \"\"\"\n         Interactive button view for exec approval of dangerous commands.\n \n-        Shows three buttons: Allow Once (green), Always Allow (blue), Deny (red).\n-        Only users in the allowed list can click. The view times out after 5 minutes.\n+        Shows four buttons: Allow Once, Allow Session, Always Allow, Deny.\n+        Clicking a button calls ``resolve_gateway_approval()`` to unblock the\n+        waiting agent thread — the same mechanism as the text ``/approve`` flow.\n+        Only users in the allowed list can click.  Times out after 5 minutes.\n         \"\"\"\n \n-        def __init__(self, approval_id: str, allowed_user_ids: set):\n+        def __init__(self, session_key: str, allowed_user_ids: set):\n             super().__init__(timeout=300)  # 5-minute timeout\n-            self.approval_id = approval_id\n+            self.session_key = session_key\n             self.allowed_user_ids = allowed_user_ids\n             self.resolved = False\n ","@@ -2236,9 +2256,10 @@ def _check_auth(self, interaction: discord.Interaction) -> bool:\n             return str(interaction.user.id) in self.allowed_user_ids\n \n         async def _resolve(\n-            self, interaction: discord.Interaction, action: str, color: discord.Color\n+            self, interaction: discord.Interaction, choice: str,\n+            color: discord.Color, label: str,\n         ):\n-            \"\"\"Resolve the approval and update the message.\"\"\"\n+            \"\"\"Resolve the approval via the gateway approval queue and update the embed.\"\"\"\n             if self.resolved:\n                 await interaction.response.send_message(\n                     \"This approval has already been resolved~\", ephemeral=True","@@ -2257,7 +2278,7 @@ async def _resolve(\n             embed = interaction.message.embeds[0] if interaction.message.embeds else None\n             if embed:\n                 embed.color = color\n-                embed.set_footer(text=f\"{action} by {interaction.user.display_name}\")\n+                embed.set_footer(text=f\"{label} by {interaction.user.display_name}\")\n \n             # Disable all buttons\n             for child in self.children:","@@ -2265,33 +2286,40 @@ async def _resolve(\n \n             await interaction.response.edit_message(embed=embed, view=self)\n \n-            # Store the approval decision\n+            # Unblock the waiting agent thread via the gateway approval queue\n             try:\n-                from tools.approval import approve_permanent\n-                if action == \"allow_once\":\n-                    pass  # One-time approval handled by gateway\n-                elif action == \"allow_always\":\n-                    approve_permanent(self.approval_id)\n-            except ImportError:\n-                pass\n+                from tools.approval import resolve_gateway_approval\n+                count = resolve_gateway_approval(self.session_key, choice)\n+                logger.info(\n+                    \"Discord button resolved %d approval(s) for session %s (choice=%s, user=%s)\",\n+                    count, self.session_key, choice, interaction.user.display_name,\n+                )\n+            except Exception as exc:\n+                logger.error(\"Failed to resolve gateway approval from button: %s\", exc)\n \n         @discord.ui.button(label=\"Allow Once\", style=discord.ButtonStyle.green)\n         async def allow_once(\n             self, interaction: discord.Interaction, button: discord.ui.Button\n         ):\n-            await self._resolve(interaction, \"allow_once\", discord.Color.green())\n+            await self._resolve(interaction, \"once\", discord.Color.green(), \"Approved once\")\n+\n+        @discord.ui.button(label=\"Allow Session\", style=discord.ButtonStyle.grey)\n+        async def allow_session(\n+            self, interaction: discord.Interaction, button: discord.ui.Button\n+        ):\n+            await self._resolve(interaction, \"session\", discord.Color.blue(), \"Approved for session\")\n \n         @discord.ui.button(label=\"Always Allow\", style=discord.ButtonStyle.blurple)\n         async def allow_always(\n             self, interaction: discord.Interaction, button: discord.ui.Button\n         ):\n-            await self._resolve(interaction, \"allow_always\", discord.Color.blue())\n+            await self._resolve(interaction, \"always\", discord.Color.purple(), \"Approved permanently\")\n \n         @discord.ui.button(label=\"Deny\", style=discord.ButtonStyle.red)\n         async def deny(\n             self, interaction: discord.Interaction, button: discord.ui.Button\n         ):\n-            await self._resolve(interaction, \"deny\", discord.Color.red())\n+            await self._resolve(interaction, \"deny\", discord.Color.red(), \"Denied\")\n \n         async def on_timeout(self):\n             \"\"\"Handle view timeout -- disable buttons and mark as expired.\"\"\""]},{"file":"gateway/run.py","hunks":["@@ -5829,10 +5829,39 @@ def _bg_review_send(message: str) -> None:\n             from tools.approval import register_gateway_notify, unregister_gateway_notify\n \n             def _approval_notify_sync(approval_data: dict) -> None:\n-                \"\"\"Send the approval request to the user from the agent thread.\"\"\"\n+                \"\"\"Send the approval request to the user from the agent thread.\n+\n+                If the adapter supports interactive button-based approvals\n+                (e.g. Discord's ``send_exec_approval``), use that for a richer\n+                UX.  Otherwise fall back to a plain text message with\n+                ``/approve`` instructions.\n+                \"\"\"\n                 cmd = approval_data.get(\"command\", \"\")\n-                cmd_preview = cmd[:200] + \"...\" if len(cmd) > 200 else cmd\n                 desc = approval_data.get(\"description\", \"dangerous command\")\n+\n+                # Prefer button-based approval when the adapter supports it.\n+                # Check the *class* for the method, not the instance — avoids\n+                # false positives from MagicMock auto-attribute creation in tests.\n+                if getattr(type(_status_adapter), \"send_exec_approval\", None) is not None:\n+                    try:\n+                        asyncio.run_coroutine_threadsafe(\n+                            _status_adapter.send_exec_approval(\n+                                chat_id=_status_chat_id,\n+                                command=cmd,\n+                                session_key=_approval_session_key,\n+                                description=desc,\n+                                metadata=_status_thread_metadata,\n+                            ),\n+                            _loop_for_step,\n+                        ).result(timeout=15)\n+                        return\n+                    except Exception as _e:\n+                        logger.warning(\n+                            \"Button-based approval failed, falling back to text: %s\", _e\n+                        )\n+\n+                # Fallback: plain text approval prompt\n+                cmd_preview = cmd[:200] + \"...\" if len(cmd) > 200 else cmd\n                 msg = (\n                     f\"⚠️ **Dangerous command requires approval:**\\n\"\n                     f\"```\\n{cmd_preview}\\n```\\n\""]},{"file":"tests/gateway/test_discord_document_handling.py","hunks":["@@ -34,8 +34,8 @@ def _ensure_discord_mock():\n     discord_mod.Thread = type(\"Thread\", (), {})\n     discord_mod.ForumChannel = type(\"ForumChannel\", (), {})\n     discord_mod.ui = SimpleNamespace(View=object, button=lambda *a, **k: (lambda fn: fn), Button=object)\n-    discord_mod.ButtonStyle = SimpleNamespace(success=1, primary=2, danger=3, green=1, blurple=2, red=3)\n-    discord_mod.Color = SimpleNamespace(orange=lambda: 1, green=lambda: 2, blue=lambda: 3, red=lambda: 4)\n+    discord_mod.ButtonStyle = SimpleNamespace(success=1, primary=2, secondary=2, danger=3, green=1, grey=2, blurple=2, red=3)\n+    discord_mod.Color = SimpleNamespace(orange=lambda: 1, green=lambda: 2, blue=lambda: 3, red=lambda: 4, purple=lambda: 5)\n     discord_mod.Interaction = object\n     discord_mod.Embed = MagicMock\n     discord_mod.app_commands = SimpleNamespace("]},{"file":"tests/gateway/test_discord_free_response.py","hunks":["@@ -23,8 +23,8 @@ def _ensure_discord_mock():\n     discord_mod.Thread = type(\"Thread\", (), {})\n     discord_mod.ForumChannel = type(\"ForumChannel\", (), {})\n     discord_mod.ui = SimpleNamespace(View=object, button=lambda *a, **k: (lambda fn: fn), Button=object)\n-    discord_mod.ButtonStyle = SimpleNamespace(success=1, primary=2, danger=3, green=1, blurple=2, red=3)\n-    discord_mod.Color = SimpleNamespace(orange=lambda: 1, green=lambda: 2, blue=lambda: 3, red=lambda: 4)\n+    discord_mod.ButtonStyle = SimpleNamespace(success=1, primary=2, secondary=2, danger=3, green=1, grey=2, blurple=2, red=3)\n+    discord_mod.Color = SimpleNamespace(orange=lambda: 1, green=lambda: 2, blue=lambda: 3, red=lambda: 4, purple=lambda: 5)\n     discord_mod.Interaction = object\n     discord_mod.Embed = MagicMock\n     discord_mod.app_commands = SimpleNamespace("]},{"file":"tests/gateway/test_discord_send.py","hunks":["@@ -19,8 +19,8 @@ def _ensure_discord_mock():\n     discord_mod.Thread = type(\"Thread\", (), {})\n     discord_mod.ForumChannel = type(\"ForumChannel\", (), {})\n     discord_mod.ui = SimpleNamespace(View=object, button=lambda *a, **k: (lambda fn: fn), Button=object)\n-    discord_mod.ButtonStyle = SimpleNamespace(success=1, primary=2, danger=3, green=1, blurple=2, red=3)\n-    discord_mod.Color = SimpleNamespace(orange=lambda: 1, green=lambda: 2, blue=lambda: 3, red=lambda: 4)\n+    discord_mod.ButtonStyle = SimpleNamespace(success=1, primary=2, secondary=2, danger=3, green=1, grey=2, blurple=2, red=3)\n+    discord_mod.Color = SimpleNamespace(orange=lambda: 1, green=lambda: 2, blue=lambda: 3, red=lambda: 4, purple=lambda: 5)\n     discord_mod.Interaction = object\n     discord_mod.Embed = MagicMock\n     discord_mod.app_commands = SimpleNamespace("]},{"file":"tests/gateway/test_voice_command.py","hunks":["@@ -25,8 +25,8 @@ def _ensure_discord_mock():\n     discord_mod.Thread = type(\"Thread\", (), {})\n     discord_mod.ForumChannel = type(\"ForumChannel\", (), {})\n     discord_mod.ui = SimpleNamespace(View=object, button=lambda *a, **k: (lambda fn: fn), Button=object)\n-    discord_mod.ButtonStyle = SimpleNamespace(success=1, primary=2, danger=3, green=1, blurple=2, red=3)\n-    discord_mod.Color = SimpleNamespace(orange=lambda: 1, green=lambda: 2, blue=lambda: 3, red=lambda: 4)\n+    discord_mod.ButtonStyle = SimpleNamespace(success=1, primary=2, secondary=2, danger=3, green=1, grey=2, blurple=2, red=3)\n+    discord_mod.Color = SimpleNamespace(orange=lambda: 1, green=lambda: 2, blue=lambda: 3, red=lambda: 4, purple=lambda: 5)\n     discord_mod.Interaction = object\n     discord_mod.Embed = MagicMock\n     discord_mod.app_commands = SimpleNamespace(\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}