Skip to content

fix(desktop): render approval/sudo/secret prompts so tools stop silently timing out#38578

Merged
OutThisLife merged 12 commits into
mainfrom
hermes/hermes-86a3383b
Jun 4, 2026
Merged

fix(desktop): render approval/sudo/secret prompts so tools stop silently timing out#38578
OutThisLife merged 12 commits into
mainfrom
hermes/hermes-86a3383b

Conversation

@teknium1

@teknium1 teknium1 commented Jun 4, 2026

Copy link
Copy Markdown
Contributor

Summary

Tool approvals (and sudo / secret prompts) now actually surface in the desktop app instead of silently timing out.

The desktop app's gateway event handler handled clarify.request but had no case for approval.request, sudo.request, or secret.request. When a tool needed approval, the gateway emitted approval.request and blocked the agent thread in _await_gateway_decision() for up to 5 min (approvals.gateway_timeout). The renderer dropped the unknown event, never showed a dialog, then the agent returned BLOCKED: timed out without user response. No prompt — just a stall, then a block. Reported on an m4 Mac mini, but it's platform-independent: any desktop-app session hitting a dangerous command / execute_code / sudo / skill-secret prompt was affected.

The Ink TUI already handles all three (ui-tui/.../createGatewayEventHandler.ts); the server side (tools/approval.py) was always correct — it fires the notify callback, the client just wasn't listening. This brings the Electron app to parity.

Changes

  • store/prompts.ts: approval / sudo / secret request atoms, with request-id-guarded clears (a late response to a prior prompt can't dismiss the live one) and clearAllPrompts().
  • components/prompt-overlays.tsx: Radix dialogs for all three. Approval shows the command + 4 choices (once / session / always / deny); sudo & secret are masked inputs. Closing via Esc/backdrop maps to a refusal (deny / empty) so silence is never mistaken for consent — parity with the TUI's Esc → deny.
  • app/session/hooks/use-message-stream.ts: wire the three *.request cases; clearAllPrompts() on message.complete so an overlay can't outlive the turn that raised it.
  • lib/chat-messages.ts: GatewayEventPayload gains command / description / env_var / prompt.
  • app/chat/index.tsx: mount <PromptOverlays /> in the chat shell.

Validation

Before After
Dangerous command in desktop app no prompt, ~5 min stall, BLOCKED approval dialog, runs on approve
sudo / secret prompt silently dropped, agent hangs masked-input dialog, resolves
tsc -b clean (only pre-existing missing-dep error in an untouched file)
eslint (touched files) 0 errors
store/prompts.test.ts 6 new tests pass

Test plan

  • Desktop app, default approval mode: ask Hermes to run rm -rf on a throwaway path → approval dialog appears → "Allow once" runs it, "Deny" blocks it.
  • Trigger a sudo-requiring command → password dialog → submit resolves.
  • Esc/backdrop on the approval dialog → command is denied (not left hanging).

Infographic

blocking-prompt-event-routing

@github-actions

github-actions Bot commented Jun 4, 2026

Copy link
Copy Markdown
Contributor

🔎 Lint report: hermes/hermes-86a3383b vs origin/main

ruff

Total: 0 on HEAD, 0 on base (➖ 0)

🆕 New issues: none

✅ Fixed issues: none

Unchanged: 0 pre-existing issues carried over.

ty (type checker)

Total: 9779 on HEAD, 9779 on base (➖ 0)

🆕 New issues: none

✅ Fixed issues: none

Unchanged: 5071 pre-existing issues carried over.

Diagnostics are surfaced as warnings — this check never fails the build.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR ensures the desktop Electron client surfaces blocking gateway prompts (approval / sudo / secret) so agent turns don’t silently stall until the gateway timeout, bringing desktop behavior in line with the Ink TUI. It also adds a best-effort backup mechanism for corrupted config.yaml on YAML parse failures.

Changes:

  • Desktop: add prompt state atoms + overlay dialogs, wire approval.request / sudo.request / secret.request events, and mount overlays in the chat shell.
  • Desktop: extend GatewayEventPayload typing to include approval/secret fields (command, description, env_var, prompt).
  • CLI: snapshot corrupted config.yaml to timestamped .bak files on parse failures + add tests.

Reviewed changes

Copilot reviewed 8 out of 8 changed files in this pull request and generated 7 comments.

Show a summary per file
File Description
tests/hermes_cli/test_config.py Adds regression tests validating corrupt-config backup behavior (including dedup + symlink guard).
hermes_cli/config.py Implements best-effort backup of corrupted config.yaml and appends backup location to the parse-failure warning.
apps/desktop/src/store/prompts.ts Introduces nanostores atoms for approval/sudo/secret prompts plus guarded clears and clearAllPrompts().
apps/desktop/src/store/prompts.test.ts Adds unit tests for prompt-store set/clear semantics and request-id guarding.
apps/desktop/src/lib/chat-messages.ts Extends gateway payload typing to carry approval/secret prompt fields.
apps/desktop/src/components/prompt-overlays.tsx Adds Radix dialog overlays for approval/sudo/secret prompts and sends corresponding *.respond RPCs.
apps/desktop/src/app/session/hooks/use-message-stream.ts Handles approval.request / sudo.request / secret.request events and clears prompt overlays on message.complete.
apps/desktop/src/app/chat/index.tsx Mounts PromptOverlays in the desktop chat shell so prompts are rendered.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread hermes_cli/config.py Outdated
Comment thread hermes_cli/config.py Outdated
Comment thread hermes_cli/config.py Outdated
Comment thread apps/desktop/src/components/prompt-overlays.tsx Outdated
Comment thread apps/desktop/src/components/prompt-overlays.tsx
Comment thread apps/desktop/src/app/session/hooks/use-message-stream.ts
Comment thread apps/desktop/src/components/prompt-overlays.tsx
@alt-glitch alt-glitch added type/bug Something isn't working comp/gateway Gateway runner, session dispatch, delivery P2 Medium — degraded but workaround exists labels Jun 4, 2026
…tly timing out

The desktop app's gateway event handler (use-message-stream.ts) handled
clarify.request but had no case for approval.request, sudo.request, or
secret.request. When a tool needed approval, the gateway emitted
approval.request and blocked the agent thread in _await_gateway_decision()
for up to 5 min (approvals.gateway_timeout); the desktop dropped the unknown
event, never showed a dialog, then the agent returned BLOCKED. No prompt,
just a stall then a block.

The Ink TUI already handles all three (createGatewayEventHandler.ts); this
brings the Electron app to parity.

- store/prompts.ts: approval/sudo/secret atoms (+ request-id-guarded clears)
- components/prompt-overlays.tsx: Radix dialogs; close/Esc maps to refusal so
  silence is never mistaken for consent (parity with TUI Esc->deny)
- use-message-stream.ts: wire the three *.request cases; clearAllPrompts on
  message.complete so an overlay can't outlive its turn
- chat-messages.ts: GatewayEventPayload gains command/description/env_var/prompt
- mount PromptOverlays in the chat shell
@teknium1 teknium1 force-pushed the hermes/hermes-86a3383b branch from 2bd5815 to ca0081c Compare June 4, 2026 00:54
@teknium1

teknium1 commented Jun 4, 2026

Copy link
Copy Markdown
Contributor Author

Thanks — addressed all of it.

Desktop dialog handlers (3 valid comments):

  • Removed onEscapeKeyDown on the approval dialog so Escape no longer fires a second approval.respond alongside the one onOpenChange(false) already sends. Esc now routes through onOpenChange only.
  • Removed onInteractOutside={preventDefault} from all three dialogs (approval/sudo/secret) so a backdrop click closes → onOpenChange(false) → the documented safe refusal (deny / empty). The comment now states there's a single close path and why we intentionally avoid the extra handlers.
  • Added clearAllPrompts() to the error event case (guarded by isActiveEvent), matching the message.complete clear — an overlay no longer lingers if the turn errors out.

config.yaml backup comments (3 comments): those were on an unrelated commit (Port from google-gemini/gemini-cli#21541: back up corrupted config.yaml) that rode in via a stale worktree-branch base during rebase. I've dropped it — the PR is now scoped to the desktop prompt-overlay fix only. The config-backup work can be its own PR.

Re-verified: tsc clean, eslint 0 errors on touched files, 6 unit tests pass.

Render dangerous-command / execute_code approval inline on the pending
tool row instead of as a modal. Binding is positional: the desktop
tool.start payload carries no structured args, but approval.request only
fires from the terminal/execute_code guards and the agent blocks on one
approval at a time, so the single pending row of those tools is the one
that raised it. Command/description text comes from $approvalRequest.

Drops ApprovalDialog from PromptOverlays (sudo/secret stay modal).
Drop the amber alert styling for a neutral elevated card: command on a
terminal-prefixed row up top, a divided footer with the muted description
on the left and right-aligned controls — a ghost "Reject" (Esc) plus a
split primary "Run" (⌘⏎) whose chevron opens "Allow this session" /
"Always allow" / "Reject". Wire ⌘/Ctrl+Enter → Run and Esc → Reject to
match Cursor's accept/skip bindings, guarded against double-send via the
$approvalRequest atom.
…strip

The running tool row already shows the command, so drop the whole card +
command echo + description band. What's left is a compact strip under the
row: a small split "Run ⌘⏎" button (chevron → Allow this session / Always
allow / Reject) and a ghost "Reject Esc", indented to sit under the row's
title text.
…ntrol

Swap the primary (blue) Run for a subtle outlined split control — neutral
border, transparent fill, hover-accent — so the approval strip reads as
quiet inline affordance rather than a big CTA. Reject stays ghost.
Tint the Run split control with the primary color as a badge (bg-primary/10,
primary text, primary/25 border, rounded-md, hover primary/15) instead of a
solid CTA or a neutral outline.
The chevron button had ballooned because dropping the size prop fell back
to the big default size (h-9 + has-svg px-3). Pin size=xs everywhere and
give the chevron a tight w-5/px-0. Bump the gap between the Run badge and
Reject (gap-2.5) and loosen Reject's internal spacing.
"Always allow" writes the matched pattern to ~/.hermes/config.yaml and
suppresses the prompt in every future session — too consequential to fire
straight from a menu click. Route it through a confirm dialog that names
the pattern + command and the file it touches. The dialog owns the
keyboard while open so Esc closes it instead of denying the approval.
Tek's PR added the sudo/secret overlays and callback wiring, but neither
reached the live path:

- Sudo: the sudo password callback is thread-local (terminal_tool
  _callback_tls), and _wire_callbacks runs on the agent-build thread, not
  the turn thread that executes tools. At command time the callback was
  missing, so terminal sudo fell through to /dev/tty and hung the headless
  gateway. Re-wire callbacks at the top of the prompt-submit turn thread.

- Secret: skills_tool short-circuited to the "secret entry unsupported"
  hint for any gateway surface, before invoking the callback. Interactive
  surfaces (desktop/TUI) register a secret-capture callback that routes to
  the secret.request overlay; only short-circuit when no callback exists,
  so messaging still gets the hint but the desktop prompts.
@OutThisLife

OutThisLife commented Jun 4, 2026

Copy link
Copy Markdown
Collaborator

Pushed 8 commits on top of this branch (@brooklyn) — an inline approval UX + the backend fixes that make the sudo/secret overlays actually fire in the desktop.

Inline tool-call approval

Replaced the approval modal with a compact inline control rendered under the pending tool row (apps/desktop/src/components/assistant-ui/tool-approval.tsx), wired into ToolEntry in tool-fallback.tsx. Sudo + secret stay modal (no tool row to anchor to).

  • Binding is positional, not command-matched: the desktop tool.start payload carries no structured args (tui_gateway/server.py::_on_tool_start), so we can't join the approval to a row by command string. approval.request only fires from the terminal/execute_code guards and the agent blocks on one approval at a time, so the single pending row of those tools is the one that raised it. Text comes from $approvalRequest.
  • Small primary "Run ⌘⏎" badge + chevron dropdown (Allow this session / Always allow / Reject) + ghost "Reject Esc". ⌘/Ctrl+Enter → Run, Esc → Reject.
  • Always allow goes through a confirm dialog, since it persists the pattern to ~/.hermes/config.yaml and suppresses the prompt in every future session.

Backend: sudo + secret never actually reached the live path

The overlays + callback wiring were here, but neither fired in the desktop's real threading/surface model (the tests mock _wire_callbacks, so this wasn't caught):

  • Sudo hung. The sudo password callback is thread-local (terminal_tool._callback_tls), but _wire_callbacks runs on the agent-build thread, not the turn thread that executes tools. At command time the callback was missing → terminal sudo fell through to /dev/tty → hang in the headless gateway. Fix: re-wire callbacks at the top of the prompt-submit turn thread (tui_gateway/server.py).
  • Secret never prompted. skills_tool short-circuited to the "secret entry unsupported" hint for any gateway surface, before invoking the callback. Interactive surfaces (desktop/TUI) register a secret-capture callback that routes to the secret.request overlay; only short-circuit when no callback exists, so messaging still gets the hint but the desktop prompts.

Tests

  • New tool-approval.test.tsx (5 passing); desktop tsc -b + eslint clean.
  • tests/tools/test_skills_tool.py + tests/cli/test_cli_secret_capture.py (91) green; tests/test_tui_gateway_server.py + tests/tui_gateway/ (198) green. (One unrelated Chromium-detection test fails locally because the test box has a browser installed.)

Verified live: inline approval (chmod -R 777 …) and the sudo dialog (sudo -k whoami) both render and resolve.

…allback presence

The desktop/sudo PR switched the gateway secret-capture short-circuit from
"any gateway surface" to "gateway surface with no callback registered". That
made a messaging gateway (telegram/discord/...) attempt interactive in-band
secret capture whenever any callback happened to be registered, instead of
returning the safe "setup unsupported" hint — and broke
test_gateway_still_loads_skill_but_returns_setup_guidance.

Discriminate on HERMES_INTERACTIVE instead: the desktop app / TUI set it in
_enable_gateway_prompts (alongside registering the secret.request callback),
while messaging platforms never do. This is the same flag tools/approval.py
uses to tell an interactive surface from a messaging one, so messaging keeps
the hint and desktop/TUI still prompt.
@OutThisLife OutThisLife enabled auto-merge (squash) June 4, 2026 01:50
@OutThisLife OutThisLife merged commit f66a929 into main Jun 4, 2026
21 of 22 checks passed
@OutThisLife OutThisLife deleted the hermes/hermes-86a3383b branch June 4, 2026 01:53
Yuki-14544869 pushed a commit to Yuki-14544869/hermes-agent that referenced this pull request Jun 4, 2026
…tly timing out (NousResearch#38578)

* fix(desktop): render approval/sudo/secret prompts so tools stop silently timing out

The desktop app's gateway event handler (use-message-stream.ts) handled
clarify.request but had no case for approval.request, sudo.request, or
secret.request. When a tool needed approval, the gateway emitted
approval.request and blocked the agent thread in _await_gateway_decision()
for up to 5 min (approvals.gateway_timeout); the desktop dropped the unknown
event, never showed a dialog, then the agent returned BLOCKED. No prompt,
just a stall then a block.

The Ink TUI already handles all three (createGatewayEventHandler.ts); this
brings the Electron app to parity.

- store/prompts.ts: approval/sudo/secret atoms (+ request-id-guarded clears)
- components/prompt-overlays.tsx: Radix dialogs; close/Esc maps to refusal so
  silence is never mistaken for consent (parity with TUI Esc->deny)
- use-message-stream.ts: wire the three *.request cases; clearAllPrompts on
  message.complete so an overlay can't outlive its turn
- chat-messages.ts: GatewayEventPayload gains command/description/env_var/prompt
- mount PromptOverlays in the chat shell

* feat(desktop): inline tool-call approval bar (Cursor-style "Run")

Render dangerous-command / execute_code approval inline on the pending
tool row instead of as a modal. Binding is positional: the desktop
tool.start payload carries no structured args, but approval.request only
fires from the terminal/execute_code guards and the agent blocks on one
approval at a time, so the single pending row of those tools is the one
that raised it. Command/description text comes from $approvalRequest.

Drops ApprovalDialog from PromptOverlays (sudo/secret stay modal).

* style(desktop): make inline approval bar match Cursor's command card

Drop the amber alert styling for a neutral elevated card: command on a
terminal-prefixed row up top, a divided footer with the muted description
on the left and right-aligned controls — a ghost "Reject" (Esc) plus a
split primary "Run" (⌘⏎) whose chevron opens "Allow this session" /
"Always allow" / "Reject". Wire ⌘/Ctrl+Enter → Run and Esc → Reject to
match Cursor's accept/skip bindings, guarded against double-send via the
$approvalRequest atom.

* style(desktop): shrink inline approval to a tiny Cursor-style button strip

The running tool row already shows the command, so drop the whole card +
command echo + description band. What's left is a compact strip under the
row: a small split "Run ⌘⏎" button (chevron → Allow this session / Always
allow / Reject) and a ghost "Reject Esc", indented to sit under the row's
title text.

* style(desktop): drop the loud blue Run button for a quiet outlined control

Swap the primary (blue) Run for a subtle outlined split control — neutral
border, transparent fill, hover-accent — so the approval strip reads as
quiet inline affordance rather than a big CTA. Reject stays ghost.

* style(desktop): make Run a soft primary badge

Tint the Run split control with the primary color as a badge (bg-primary/10,
primary text, primary/25 border, rounded-md, hover primary/15) instead of a
solid CTA or a neutral outline.

* style(desktop): slim the approval chevron and space out Reject

The chevron button had ballooned because dropping the size prop fell back
to the big default size (h-9 + has-svg px-3). Pin size=xs everywhere and
give the chevron a tight w-5/px-0. Bump the gap between the Run badge and
Reject (gap-2.5) and loosen Reject's internal spacing.

* feat(desktop): confirm before "Always allow" persists an approval

"Always allow" writes the matched pattern to ~/.hermes/config.yaml and
suppresses the prompt in every future session — too consequential to fire
straight from a menu click. Route it through a confirm dialog that names
the pattern + command and the file it touches. The dialog owns the
keyboard while open so Esc closes it instead of denying the approval.

* fix(gateway): make sudo + secret prompts actually fire in the desktop

Tek's PR added the sudo/secret overlays and callback wiring, but neither
reached the live path:

- Sudo: the sudo password callback is thread-local (terminal_tool
  _callback_tls), and _wire_callbacks runs on the agent-build thread, not
  the turn thread that executes tools. At command time the callback was
  missing, so terminal sudo fell through to /dev/tty and hung the headless
  gateway. Re-wire callbacks at the top of the prompt-submit turn thread.

- Secret: skills_tool short-circuited to the "secret entry unsupported"
  hint for any gateway surface, before invoking the callback. Interactive
  surfaces (desktop/TUI) register a secret-capture callback that routes to
  the secret.request overlay; only short-circuit when no callback exists,
  so messaging still gets the hint but the desktop prompts.

* docs(desktop): drop Cursor references from approval comments

* docs(desktop): drop Cursor reference from prompt-overlays comment

* fix(skills): gate in-band secret capture on HERMES_INTERACTIVE, not callback presence

The desktop/sudo PR switched the gateway secret-capture short-circuit from
"any gateway surface" to "gateway surface with no callback registered". That
made a messaging gateway (telegram/discord/...) attempt interactive in-band
secret capture whenever any callback happened to be registered, instead of
returning the safe "setup unsupported" hint — and broke
test_gateway_still_loads_skill_but_returns_setup_guidance.

Discriminate on HERMES_INTERACTIVE instead: the desktop app / TUI set it in
_enable_gateway_prompts (alongside registering the secret.request callback),
while messaging platforms never do. This is the same flag tools/approval.py
uses to tell an interactive surface from a messaging one, so messaging keeps
the hint and desktop/TUI still prompt.

---------

Co-authored-by: Brooklyn Nicholson <brooklyn.bb.nicholson@gmail.com>
davidgut1982 pushed a commit to davidgut1982/hermes-agent that referenced this pull request Jun 5, 2026
…tly timing out (NousResearch#38578)

* fix(desktop): render approval/sudo/secret prompts so tools stop silently timing out

The desktop app's gateway event handler (use-message-stream.ts) handled
clarify.request but had no case for approval.request, sudo.request, or
secret.request. When a tool needed approval, the gateway emitted
approval.request and blocked the agent thread in _await_gateway_decision()
for up to 5 min (approvals.gateway_timeout); the desktop dropped the unknown
event, never showed a dialog, then the agent returned BLOCKED. No prompt,
just a stall then a block.

The Ink TUI already handles all three (createGatewayEventHandler.ts); this
brings the Electron app to parity.

- store/prompts.ts: approval/sudo/secret atoms (+ request-id-guarded clears)
- components/prompt-overlays.tsx: Radix dialogs; close/Esc maps to refusal so
  silence is never mistaken for consent (parity with TUI Esc->deny)
- use-message-stream.ts: wire the three *.request cases; clearAllPrompts on
  message.complete so an overlay can't outlive its turn
- chat-messages.ts: GatewayEventPayload gains command/description/env_var/prompt
- mount PromptOverlays in the chat shell

* feat(desktop): inline tool-call approval bar (Cursor-style "Run")

Render dangerous-command / execute_code approval inline on the pending
tool row instead of as a modal. Binding is positional: the desktop
tool.start payload carries no structured args, but approval.request only
fires from the terminal/execute_code guards and the agent blocks on one
approval at a time, so the single pending row of those tools is the one
that raised it. Command/description text comes from $approvalRequest.

Drops ApprovalDialog from PromptOverlays (sudo/secret stay modal).

* style(desktop): make inline approval bar match Cursor's command card

Drop the amber alert styling for a neutral elevated card: command on a
terminal-prefixed row up top, a divided footer with the muted description
on the left and right-aligned controls — a ghost "Reject" (Esc) plus a
split primary "Run" (⌘⏎) whose chevron opens "Allow this session" /
"Always allow" / "Reject". Wire ⌘/Ctrl+Enter → Run and Esc → Reject to
match Cursor's accept/skip bindings, guarded against double-send via the
$approvalRequest atom.

* style(desktop): shrink inline approval to a tiny Cursor-style button strip

The running tool row already shows the command, so drop the whole card +
command echo + description band. What's left is a compact strip under the
row: a small split "Run ⌘⏎" button (chevron → Allow this session / Always
allow / Reject) and a ghost "Reject Esc", indented to sit under the row's
title text.

* style(desktop): drop the loud blue Run button for a quiet outlined control

Swap the primary (blue) Run for a subtle outlined split control — neutral
border, transparent fill, hover-accent — so the approval strip reads as
quiet inline affordance rather than a big CTA. Reject stays ghost.

* style(desktop): make Run a soft primary badge

Tint the Run split control with the primary color as a badge (bg-primary/10,
primary text, primary/25 border, rounded-md, hover primary/15) instead of a
solid CTA or a neutral outline.

* style(desktop): slim the approval chevron and space out Reject

The chevron button had ballooned because dropping the size prop fell back
to the big default size (h-9 + has-svg px-3). Pin size=xs everywhere and
give the chevron a tight w-5/px-0. Bump the gap between the Run badge and
Reject (gap-2.5) and loosen Reject's internal spacing.

* feat(desktop): confirm before "Always allow" persists an approval

"Always allow" writes the matched pattern to ~/.hermes/config.yaml and
suppresses the prompt in every future session — too consequential to fire
straight from a menu click. Route it through a confirm dialog that names
the pattern + command and the file it touches. The dialog owns the
keyboard while open so Esc closes it instead of denying the approval.

* fix(gateway): make sudo + secret prompts actually fire in the desktop

Tek's PR added the sudo/secret overlays and callback wiring, but neither
reached the live path:

- Sudo: the sudo password callback is thread-local (terminal_tool
  _callback_tls), and _wire_callbacks runs on the agent-build thread, not
  the turn thread that executes tools. At command time the callback was
  missing, so terminal sudo fell through to /dev/tty and hung the headless
  gateway. Re-wire callbacks at the top of the prompt-submit turn thread.

- Secret: skills_tool short-circuited to the "secret entry unsupported"
  hint for any gateway surface, before invoking the callback. Interactive
  surfaces (desktop/TUI) register a secret-capture callback that routes to
  the secret.request overlay; only short-circuit when no callback exists,
  so messaging still gets the hint but the desktop prompts.

* docs(desktop): drop Cursor references from approval comments

* docs(desktop): drop Cursor reference from prompt-overlays comment

* fix(skills): gate in-band secret capture on HERMES_INTERACTIVE, not callback presence

The desktop/sudo PR switched the gateway secret-capture short-circuit from
"any gateway surface" to "gateway surface with no callback registered". That
made a messaging gateway (telegram/discord/...) attempt interactive in-band
secret capture whenever any callback happened to be registered, instead of
returning the safe "setup unsupported" hint — and broke
test_gateway_still_loads_skill_but_returns_setup_guidance.

Discriminate on HERMES_INTERACTIVE instead: the desktop app / TUI set it in
_enable_gateway_prompts (alongside registering the secret.request callback),
while messaging platforms never do. This is the same flag tools/approval.py
uses to tell an interactive surface from a messaging one, so messaging keeps
the hint and desktop/TUI still prompt.

---------

Co-authored-by: Brooklyn Nicholson <brooklyn.bb.nicholson@gmail.com>
changman pushed a commit to changman/hermes-agent that referenced this pull request Jun 10, 2026
…tly timing out (NousResearch#38578)

* fix(desktop): render approval/sudo/secret prompts so tools stop silently timing out

The desktop app's gateway event handler (use-message-stream.ts) handled
clarify.request but had no case for approval.request, sudo.request, or
secret.request. When a tool needed approval, the gateway emitted
approval.request and blocked the agent thread in _await_gateway_decision()
for up to 5 min (approvals.gateway_timeout); the desktop dropped the unknown
event, never showed a dialog, then the agent returned BLOCKED. No prompt,
just a stall then a block.

The Ink TUI already handles all three (createGatewayEventHandler.ts); this
brings the Electron app to parity.

- store/prompts.ts: approval/sudo/secret atoms (+ request-id-guarded clears)
- components/prompt-overlays.tsx: Radix dialogs; close/Esc maps to refusal so
  silence is never mistaken for consent (parity with TUI Esc->deny)
- use-message-stream.ts: wire the three *.request cases; clearAllPrompts on
  message.complete so an overlay can't outlive its turn
- chat-messages.ts: GatewayEventPayload gains command/description/env_var/prompt
- mount PromptOverlays in the chat shell

* feat(desktop): inline tool-call approval bar (Cursor-style "Run")

Render dangerous-command / execute_code approval inline on the pending
tool row instead of as a modal. Binding is positional: the desktop
tool.start payload carries no structured args, but approval.request only
fires from the terminal/execute_code guards and the agent blocks on one
approval at a time, so the single pending row of those tools is the one
that raised it. Command/description text comes from $approvalRequest.

Drops ApprovalDialog from PromptOverlays (sudo/secret stay modal).

* style(desktop): make inline approval bar match Cursor's command card

Drop the amber alert styling for a neutral elevated card: command on a
terminal-prefixed row up top, a divided footer with the muted description
on the left and right-aligned controls — a ghost "Reject" (Esc) plus a
split primary "Run" (⌘⏎) whose chevron opens "Allow this session" /
"Always allow" / "Reject". Wire ⌘/Ctrl+Enter → Run and Esc → Reject to
match Cursor's accept/skip bindings, guarded against double-send via the
$approvalRequest atom.

* style(desktop): shrink inline approval to a tiny Cursor-style button strip

The running tool row already shows the command, so drop the whole card +
command echo + description band. What's left is a compact strip under the
row: a small split "Run ⌘⏎" button (chevron → Allow this session / Always
allow / Reject) and a ghost "Reject Esc", indented to sit under the row's
title text.

* style(desktop): drop the loud blue Run button for a quiet outlined control

Swap the primary (blue) Run for a subtle outlined split control — neutral
border, transparent fill, hover-accent — so the approval strip reads as
quiet inline affordance rather than a big CTA. Reject stays ghost.

* style(desktop): make Run a soft primary badge

Tint the Run split control with the primary color as a badge (bg-primary/10,
primary text, primary/25 border, rounded-md, hover primary/15) instead of a
solid CTA or a neutral outline.

* style(desktop): slim the approval chevron and space out Reject

The chevron button had ballooned because dropping the size prop fell back
to the big default size (h-9 + has-svg px-3). Pin size=xs everywhere and
give the chevron a tight w-5/px-0. Bump the gap between the Run badge and
Reject (gap-2.5) and loosen Reject's internal spacing.

* feat(desktop): confirm before "Always allow" persists an approval

"Always allow" writes the matched pattern to ~/.hermes/config.yaml and
suppresses the prompt in every future session — too consequential to fire
straight from a menu click. Route it through a confirm dialog that names
the pattern + command and the file it touches. The dialog owns the
keyboard while open so Esc closes it instead of denying the approval.

* fix(gateway): make sudo + secret prompts actually fire in the desktop

Tek's PR added the sudo/secret overlays and callback wiring, but neither
reached the live path:

- Sudo: the sudo password callback is thread-local (terminal_tool
  _callback_tls), and _wire_callbacks runs on the agent-build thread, not
  the turn thread that executes tools. At command time the callback was
  missing, so terminal sudo fell through to /dev/tty and hung the headless
  gateway. Re-wire callbacks at the top of the prompt-submit turn thread.

- Secret: skills_tool short-circuited to the "secret entry unsupported"
  hint for any gateway surface, before invoking the callback. Interactive
  surfaces (desktop/TUI) register a secret-capture callback that routes to
  the secret.request overlay; only short-circuit when no callback exists,
  so messaging still gets the hint but the desktop prompts.

* docs(desktop): drop Cursor references from approval comments

* docs(desktop): drop Cursor reference from prompt-overlays comment

* fix(skills): gate in-band secret capture on HERMES_INTERACTIVE, not callback presence

The desktop/sudo PR switched the gateway secret-capture short-circuit from
"any gateway surface" to "gateway surface with no callback registered". That
made a messaging gateway (telegram/discord/...) attempt interactive in-band
secret capture whenever any callback happened to be registered, instead of
returning the safe "setup unsupported" hint — and broke
test_gateway_still_loads_skill_but_returns_setup_guidance.

Discriminate on HERMES_INTERACTIVE instead: the desktop app / TUI set it in
_enable_gateway_prompts (alongside registering the secret.request callback),
while messaging platforms never do. This is the same flag tools/approval.py
uses to tell an interactive surface from a messaging one, so messaging keeps
the hint and desktop/TUI still prompt.

---------

Co-authored-by: Brooklyn Nicholson <brooklyn.bb.nicholson@gmail.com>
pull Bot pushed a commit to HSKIMRobert/hermes-agent that referenced this pull request Jun 11, 2026
When a tirith content-security warning is present the approval backend
forces allow_permanent=False and silently downgrades an "always" choice to
session scope (the persistence loop in check_all_command_guards only honors
"always" → permanent when no tirith finding exists). But the gateway notify
payload that drives the TUI and the Electron desktop app never carried that
flag, so both surfaces always rendered "Always allow" — offering a permanent
allow the backend would quietly refuse to persist.

Plumb allow_permanent end-to-end:
- tools/approval.py: include `allow_permanent: not has_tirith` in the gateway
  approval_data the notify callback emits as `approval.request`.
- ui-tui: thread `allowPermanent` through the event handler, gateway types,
  and ApprovalReq; ApprovalPrompt drops the "always" option (and renumbers the
  quick-pick keys) when it's false.
- apps/desktop: thread `allow_permanent` through the gateway payload type, the
  per-session approval store, and the inline ApprovalBar, which now hides the
  "Always allow…" dropdown item when permanent allow is disallowed — reusing
  the existing DropdownMenu / confirm-Dialog UI.

The desktop/TUI render path for approvals already landed in NousResearch#38578 (the root
cause of approvals not surfacing in the GUI); this completes the salvage of
NousResearch#37856 by carrying allow_permanent across both surfaces. NousResearch#37856's original
thread-local _block() approach is dropped: desktop/TUI approvals resolve via
approval.respond → resolve_gateway_approval (the per-session queue), not the
_block()/request_id correlation, so a worker-thread callback waiting on _block
would never be released by the real UI.

Tests: gateway notify payload carries allow_permanent (True without tirith,
False with a tirith warning); ui-tui approvalAction reduced option set +
event-handler allowPermanent propagation; desktop store round-trip + the
ApprovalBar showing/hiding "Always allow".

Supersedes NousResearch#37856
Closes NousResearch#37812

Co-authored-by: LeonSGP43 <cine.dreamer.one@gmail.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

comp/gateway Gateway runner, session dispatch, delivery P2 Medium — degraded but workaround exists type/bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants