NousResearch/hermes-agent

Discord slash commands for approve and deny actions

Cluster 3614 · gateway. Data as of

Nothing open on GitHub. Fix merged in #4800 on 3 Apr 2026. Check it covers your case.

The short answer

0open issues
0open PRs
3items grouped
Known?
Yes. 3 related items grouped here, 1 issue already closed.
Fix event
A fix was merged to main in PR #4800. Check it covers your case. No release data is tracked.
Tracker
The maintainers' tracker marks this unresolved. That is separate from the fix event.
Still open
Nothing open on GitHub.

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.

Open issues0

No open issues.

Open PRs0

No open pull requests.

Fixes on record

Merged to main. A stamp counts the issues a fix is linked to.

  • #4800 fix(discord): register /approve and /deny slash commands, wire up button-based approval UI

    merged to main

    files: 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

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

1 PR closed without merging

From the close comments: superseded by another PR 1

  • #3620 feat(discord): register /approve and /deny as native Discord slash commands · superseded by another PR → #4800
Closed issues · 1
  • Closed#3614Add Discord slash commands for /approve and /deny
How this cluster was grouped
  • Link evidence (structural links throughout): every member is connected by structural evidence: shared changed files, or a closing/superseded reference.
  • Pairwise link density 0.67: the share of item 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.

Pick up a Lab task

Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.

Open the Lab

At a glance

Subsystem
gateway
Merged PRs
#4800
Closed issues
1
Attempts
1 PR closed without merging
Fix link checked
3 Apr 2026. This confirmation is old and may be out of date (a revert or reopen would not have invalidated it here); re-verify.

For your agent

The same facts, typed.