diff --git a/apps/desktop/src/app/chat/index.tsx b/apps/desktop/src/app/chat/index.tsx
index 996826adf..a5c5e3833 100644
--- a/apps/desktop/src/app/chat/index.tsx
+++ b/apps/desktop/src/app/chat/index.tsx
@@ -13,6 +13,7 @@ import { useLocation } from 'react-router-dom'
import { Thread } from '@/components/assistant-ui/thread'
import { Backdrop } from '@/components/Backdrop'
import { NotificationStack } from '@/components/notifications'
+import { PromptOverlays } from '@/components/prompt-overlays'
import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
import { getGlobalModelOptions, type HermesGateway } from '@/hermes'
@@ -315,6 +316,7 @@ export function ChatView({
/>
+
{
+ cleanup()
+ $approvalRequest.set(null)
+ $gateway.set(null)
+})
+
+describe('PendingToolApproval', () => {
+ it('renders nothing when there is no pending approval', () => {
+ const { container } = render(
)
+
+ expect(container.innerHTML).toBe('')
+ })
+
+ it('renders nothing for tools that never raise approval', () => {
+ setRequest()
+ const { container } = render(
)
+
+ expect(container.innerHTML).toBe('')
+ })
+
+ it('renders the inline run/reject controls on the pending terminal row', () => {
+ setRequest('chmod -R 777 /tmp/x')
+ render(
)
+
+ expect(screen.getByRole('button', { name: /Run/ })).toBeTruthy()
+ expect(screen.getByRole('button', { name: /Reject/ })).toBeTruthy()
+ })
+
+ it('sends approval.respond {choice: "once"} and clears the request on Run', async () => {
+ const request = mockGateway()
+ setRequest()
+ render(
)
+
+ fireEvent.click(screen.getByRole('button', { name: /Run/ }))
+
+ await waitFor(() => {
+ expect(request).toHaveBeenCalledWith('approval.respond', { choice: 'once', session_id: 'sess-1' })
+ })
+ expect($approvalRequest.get()).toBeNull()
+ })
+
+ it('sends choice "deny" on Reject', async () => {
+ const request = mockGateway()
+ setRequest()
+ render(
)
+
+ fireEvent.click(screen.getByRole('button', { name: /Reject/ }))
+
+ await waitFor(() => {
+ expect(request).toHaveBeenCalledWith('approval.respond', { choice: 'deny', session_id: 'sess-1' })
+ })
+ })
+})
diff --git a/apps/desktop/src/components/assistant-ui/tool-approval.tsx b/apps/desktop/src/components/assistant-ui/tool-approval.tsx
new file mode 100644
index 000000000..8e6dfdaaf
--- /dev/null
+++ b/apps/desktop/src/components/assistant-ui/tool-approval.tsx
@@ -0,0 +1,213 @@
+'use client'
+
+import { useStore } from '@nanostores/react'
+import { type FC, useCallback, useEffect, useState } from 'react'
+
+import { Button } from '@/components/ui/button'
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle
+} from '@/components/ui/dialog'
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger
+} from '@/components/ui/dropdown-menu'
+import { triggerHaptic } from '@/lib/haptics'
+import { ChevronDown, Loader2 } from '@/lib/icons'
+import { $gateway } from '@/store/gateway'
+import { notifyError } from '@/store/notifications'
+import { $approvalRequest, type ApprovalRequest, clearApprovalRequest } from '@/store/prompts'
+
+import type { ToolPart } from './tool-fallback-model'
+
+// Inline approval control. Rendered as a compact button strip
+// under the pending tool row that raised the approval (the row already shows
+// the command, so the strip deliberately doesn't repeat it) instead of as a
+// modal overlay.
+//
+// Binding is POSITIONAL, not command-matched: the desktop `tool.start` payload
+// carries no structured args (only tool_id/name/context — see
+// tui_gateway/server.py::_on_tool_start), so we cannot join the approval to the
+// row by command string. But `approval.request` only ever fires from the
+// `terminal` / `execute_code` guards and the agent thread blocks on exactly one
+// approval at a time, so the single pending row of those tools IS the row that
+// raised it. The command/description text comes from `$approvalRequest` (the
+// event payload), which is the only place that data reliably exists.
+const APPROVAL_TOOLS = new Set(['terminal', 'execute_code'])
+
+// Canonical gateway choices (ui-tui/src/components/prompts.tsx).
+type ApprovalChoice = 'once' | 'session' | 'always' | 'deny'
+
+export const PendingToolApproval: FC<{ part: ToolPart }> = ({ part }) => {
+ const request = useStore($approvalRequest)
+
+ if (!request || !APPROVAL_TOOLS.has(part.toolName)) {
+ return null
+ }
+
+ return
+}
+
+const isMac = typeof navigator !== 'undefined' && /Mac|iP(hone|ad|od)/.test(navigator.platform)
+
+const ApprovalBar: FC<{ request: ApprovalRequest }> = ({ request }) => {
+ const gateway = useStore($gateway)
+ const [submitting, setSubmitting] = useState
(null)
+ // "Always allow" persists the pattern to ~/.hermes/config.yaml permanently, so
+ // it goes through a confirm step rather than firing straight from the menu.
+ const [confirmAlways, setConfirmAlways] = useState(false)
+ const busy = submitting !== null
+
+ const respond = useCallback(
+ async (choice: ApprovalChoice) => {
+ // Another bar (or the keyboard path) may have already resolved this
+ // approval; the atom is the single source of truth, so bail if it's gone.
+ if (busy || !$approvalRequest.get()) {
+ return
+ }
+
+ if (!gateway) {
+ notifyError(new Error('Hermes gateway is not connected'), 'Could not send approval response')
+
+ return
+ }
+
+ setSubmitting(choice)
+
+ try {
+ await gateway.request<{ resolved?: boolean }>('approval.respond', {
+ choice,
+ session_id: request.sessionId ?? undefined
+ })
+ triggerHaptic(choice === 'deny' ? 'cancel' : 'submit')
+ clearApprovalRequest()
+ } catch (error) {
+ notifyError(error, 'Could not send approval response')
+ setSubmitting(null)
+ }
+ },
+ [busy, gateway, request.sessionId]
+ )
+
+ // ⌘/Ctrl+Enter → Run, Esc → Reject.
+ // While the confirm dialog is open it owns the keyboard (Esc closes it), so
+ // the strip-level shortcuts stand down to avoid denying the whole approval.
+ useEffect(() => {
+ if (confirmAlways) {
+ return
+ }
+
+ const onKeyDown = (event: KeyboardEvent) => {
+ if (event.key === 'Enter' && (event.metaKey || event.ctrlKey)) {
+ event.preventDefault()
+ void respond('once')
+ } else if (event.key === 'Escape') {
+ event.preventDefault()
+ void respond('deny')
+ }
+ }
+
+ window.addEventListener('keydown', onKeyDown, true)
+
+ return () => window.removeEventListener('keydown', onKeyDown, true)
+ }, [confirmAlways, respond])
+
+ return (
+
+
+
+
+
+
+
+
+
+ void respond('session')}>Allow this session
+ {
+ // Defer one tick so the menu fully unmounts before the dialog
+ // mounts — otherwise Radix's focus-return races the dialog and
+ // dismisses it via onInteractOutside.
+ setTimeout(() => setConfirmAlways(true), 0)
+ }}
+ >
+ Always allow…
+
+ void respond('deny')} variant="destructive">
+ Reject
+
+
+
+
+
+
+
+
+
+ )
+}
diff --git a/apps/desktop/src/components/assistant-ui/tool-fallback.tsx b/apps/desktop/src/components/assistant-ui/tool-fallback.tsx
index cb8cd2194..32498bca4 100644
--- a/apps/desktop/src/components/assistant-ui/tool-fallback.tsx
+++ b/apps/desktop/src/components/assistant-ui/tool-fallback.tsx
@@ -24,6 +24,7 @@ import { cn } from '@/lib/utils'
import { $toolInlineDiffs } from '@/store/tool-diffs'
import { $toolDisclosureOpen, $toolViewMode, setToolDisclosureOpen } from '@/store/tool-view'
+import { PendingToolApproval } from './tool-approval'
import {
groupCopyText as buildGroupCopyText,
buildToolView,
@@ -309,6 +310,7 @@ function ToolEntry({ part }: ToolEntryProps) {
+ {isPending &&
{!embedded && view.previewTarget && isPreviewableTarget(view.previewTarget) && (
diff --git a/apps/desktop/src/components/prompt-overlays.tsx b/apps/desktop/src/components/prompt-overlays.tsx
new file mode 100644
index 000000000..1483deae5
--- /dev/null
+++ b/apps/desktop/src/components/prompt-overlays.tsx
@@ -0,0 +1,230 @@
+'use client'
+
+import { useStore } from '@nanostores/react'
+import { type FormEvent, useCallback, useEffect, useState } from 'react'
+
+import { Button } from '@/components/ui/button'
+import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
+import { Input } from '@/components/ui/input'
+import { triggerHaptic } from '@/lib/haptics'
+import { KeyRound, Loader2, Lock } from '@/lib/icons'
+import { $gateway } from '@/store/gateway'
+import { notifyError } from '@/store/notifications'
+import { $secretRequest, $sudoRequest, clearSecretRequest, clearSudoRequest } from '@/store/prompts'
+
+// Renders the modal mid-turn prompts the gateway raises and waits on: sudo
+// password and skill secret capture. (Dangerous-command / execute_code approval
+// is rendered INLINE on the pending tool row instead — see
+// components/assistant-ui/tool-approval.tsx — so it reads like an inline "Run"
+// affordance rather than a blocking modal.) Each Python-side caller blocks the
+// agent thread until the matching `*.respond` RPC lands; without a renderer the
+// agent stalls until its timeout and the tool is BLOCKED (the bug this fixes —
+// desktop handled clarify.request but not these). Any close path (Esc, backdrop
+// click) funnels through Radix's single `onOpenChange(false)` and maps to a
+// refusal, so silence is never mistaken for consent, matching the TUI. We
+// deliberately do NOT add onEscapeKeyDown / onInteractOutside handlers — they'd
+// fire a second `*.respond` alongside onOpenChange (double-send) or block the
+// backdrop-dismiss path.
+
+function SudoDialog() {
+ const request = useStore($sudoRequest)
+ const gateway = useStore($gateway)
+ const [password, setPassword] = useState('')
+ const [submitting, setSubmitting] = useState(false)
+
+ useEffect(() => {
+ setPassword('')
+ setSubmitting(false)
+ }, [request?.requestId])
+
+ const send = useCallback(
+ async (value: string) => {
+ if (!request) {
+ return
+ }
+
+ if (!gateway) {
+ notifyError(new Error('Hermes gateway is not connected'), 'Could not send sudo password')
+
+ return
+ }
+
+ setSubmitting(true)
+
+ try {
+ await gateway.request<{ status?: string }>('sudo.respond', {
+ password: value,
+ request_id: request.requestId
+ })
+ triggerHaptic('submit')
+ clearSudoRequest(request.requestId)
+ } catch (error) {
+ notifyError(error, 'Could not send sudo password')
+ setSubmitting(false)
+ }
+ },
+ [gateway, request]
+ )
+
+ // Cancel → empty password. The backend treats an empty sudo response as a
+ // failed sudo (no command runs), so closing the dialog is a safe refusal.
+ const onOpenChange = useCallback(
+ (open: boolean) => {
+ if (!open && !submitting && request) {
+ void send('')
+ }
+ },
+ [request, send, submitting]
+ )
+
+ const onSubmit = useCallback(
+ (event: FormEvent
) => {
+ event.preventDefault()
+ void send(password)
+ },
+ [password, send]
+ )
+
+ if (!request) {
+ return null
+ }
+
+ return (
+
+ )
+}
+
+function SecretDialog() {
+ const request = useStore($secretRequest)
+ const gateway = useStore($gateway)
+ const [value, setValue] = useState('')
+ const [submitting, setSubmitting] = useState(false)
+
+ useEffect(() => {
+ setValue('')
+ setSubmitting(false)
+ }, [request?.requestId])
+
+ const send = useCallback(
+ async (secret: string) => {
+ if (!request) {
+ return
+ }
+
+ if (!gateway) {
+ notifyError(new Error('Hermes gateway is not connected'), 'Could not send secret')
+
+ return
+ }
+
+ setSubmitting(true)
+
+ try {
+ await gateway.request<{ status?: string }>('secret.respond', {
+ request_id: request.requestId,
+ value: secret
+ })
+ triggerHaptic('submit')
+ clearSecretRequest(request.requestId)
+ } catch (error) {
+ notifyError(error, 'Could not send secret')
+ setSubmitting(false)
+ }
+ },
+ [gateway, request]
+ )
+
+ const onOpenChange = useCallback(
+ (open: boolean) => {
+ if (!open && !submitting && request) {
+ void send('')
+ }
+ },
+ [request, send, submitting]
+ )
+
+ const onSubmit = useCallback(
+ (event: FormEvent) => {
+ event.preventDefault()
+ void send(value)
+ },
+ [send, value]
+ )
+
+ if (!request) {
+ return null
+ }
+
+ return (
+
+ )
+}
+
+export function PromptOverlays() {
+ return (
+ <>
+
+
+ >
+ )
+}
diff --git a/apps/desktop/src/lib/chat-messages.ts b/apps/desktop/src/lib/chat-messages.ts
index 20be83c75..c6c9cee48 100644
--- a/apps/desktop/src/lib/chat-messages.ts
+++ b/apps/desktop/src/lib/chat-messages.ts
@@ -55,6 +55,12 @@ export type GatewayEventPayload = {
request_id?: string
question?: string
choices?: string[] | null
+ // approval.request (dangerous command / execute_code) — session-keyed
+ command?: string
+ description?: string
+ // secret.request (skill credential capture)
+ env_var?: string
+ prompt?: string
}
export function textPart(text: string): ChatMessagePart {
diff --git a/apps/desktop/src/store/prompts.test.ts b/apps/desktop/src/store/prompts.test.ts
new file mode 100644
index 000000000..3ed8ade8a
--- /dev/null
+++ b/apps/desktop/src/store/prompts.test.ts
@@ -0,0 +1,91 @@
+import { afterEach, describe, expect, it } from 'vitest'
+
+import {
+ $approvalRequest,
+ $secretRequest,
+ $sudoRequest,
+ clearAllPrompts,
+ clearApprovalRequest,
+ clearSecretRequest,
+ clearSudoRequest,
+ setApprovalRequest,
+ setSecretRequest,
+ setSudoRequest
+} from './prompts'
+
+afterEach(() => {
+ clearAllPrompts()
+})
+
+describe('approval prompt store', () => {
+ it('holds the most recent session-keyed approval request', () => {
+ setApprovalRequest({ command: 'rm -rf /tmp/x', description: 'recursive delete', sessionId: 's1' })
+
+ expect($approvalRequest.get()).toEqual({
+ command: 'rm -rf /tmp/x',
+ description: 'recursive delete',
+ sessionId: 's1'
+ })
+ })
+
+ it('clears unconditionally (approval is session-keyed, no request id)', () => {
+ setApprovalRequest({ command: 'x', description: 'd', sessionId: 's1' })
+ clearApprovalRequest()
+
+ expect($approvalRequest.get()).toBeNull()
+ })
+})
+
+describe('sudo prompt store', () => {
+ it('clears only when the request id matches the in-flight prompt', () => {
+ setSudoRequest({ requestId: 'abc' })
+
+ // A stale clear for a different request must NOT drop the live prompt —
+ // otherwise a late response to a prior sudo ask would dismiss the current
+ // one and leave the agent blocked.
+ clearSudoRequest('stale')
+ expect($sudoRequest.get()).toEqual({ requestId: 'abc' })
+
+ clearSudoRequest('abc')
+ expect($sudoRequest.get()).toBeNull()
+ })
+
+ it('clears unconditionally when no request id is given', () => {
+ setSudoRequest({ requestId: 'abc' })
+ clearSudoRequest()
+
+ expect($sudoRequest.get()).toBeNull()
+ })
+})
+
+describe('secret prompt store', () => {
+ it('carries env var and prompt, and clears on id match', () => {
+ setSecretRequest({ requestId: 'r1', envVar: 'OPENAI_API_KEY', prompt: 'Paste your key' })
+
+ expect($secretRequest.get()).toEqual({
+ requestId: 'r1',
+ envVar: 'OPENAI_API_KEY',
+ prompt: 'Paste your key'
+ })
+
+ clearSecretRequest('mismatch')
+ expect($secretRequest.get()).not.toBeNull()
+
+ clearSecretRequest('r1')
+ expect($secretRequest.get()).toBeNull()
+ })
+})
+
+describe('clearAllPrompts', () => {
+ it('drops every in-flight prompt at once (turn end / interrupt)', () => {
+ setApprovalRequest({ command: 'x', description: 'd', sessionId: 's1' })
+ setSudoRequest({ requestId: 'abc' })
+ setSecretRequest({ requestId: 'r1', envVar: 'E', prompt: 'p' })
+
+ clearAllPrompts()
+
+ expect($approvalRequest.get()).toBeNull()
+ expect($sudoRequest.get()).toBeNull()
+ expect($secretRequest.get()).toBeNull()
+ })
+})
diff --git a/apps/desktop/src/store/prompts.ts b/apps/desktop/src/store/prompts.ts
new file mode 100644
index 000000000..11ba3a0bb
--- /dev/null
+++ b/apps/desktop/src/store/prompts.ts
@@ -0,0 +1,86 @@
+import { atom } from 'nanostores'
+
+// Blocking interactive prompts the gateway raises mid-turn. Each maps to a
+// `*.request` event the Python side emits while it blocks the agent thread
+// waiting for a `*.respond` RPC. Without a renderer for these, the agent
+// silently stalls until its timeout (default 5 min) and the tool is BLOCKED
+// — the desktop app previously handled clarify.request but not these three,
+// so dangerous-command approval, sudo, and secret prompts never surfaced.
+
+export interface ApprovalRequest {
+ command: string
+ description: string
+ sessionId: string | null
+}
+
+// Approval is session-keyed on the backend (one in-flight approval per
+// session, resolved via approval.respond {choice, session_id}). It carries
+// no request_id, unlike sudo/secret which are _block()-style request/response.
+export const $approvalRequest = atom(null)
+
+export function setApprovalRequest(request: ApprovalRequest): void {
+ $approvalRequest.set(request)
+}
+
+export function clearApprovalRequest(): void {
+ $approvalRequest.set(null)
+}
+
+export interface SudoRequest {
+ requestId: string
+}
+
+export const $sudoRequest = atom(null)
+
+export function setSudoRequest(request: SudoRequest): void {
+ $sudoRequest.set(request)
+}
+
+export function clearSudoRequest(requestId?: string): void {
+ const current = $sudoRequest.get()
+
+ if (!current) {
+ return
+ }
+
+ if (requestId && current.requestId !== requestId) {
+ return
+ }
+
+ $sudoRequest.set(null)
+}
+
+export interface SecretRequest {
+ requestId: string
+ envVar: string
+ prompt: string
+}
+
+export const $secretRequest = atom(null)
+
+export function setSecretRequest(request: SecretRequest): void {
+ $secretRequest.set(request)
+}
+
+export function clearSecretRequest(requestId?: string): void {
+ const current = $secretRequest.get()
+
+ if (!current) {
+ return
+ }
+
+ if (requestId && current.requestId !== requestId) {
+ return
+ }
+
+ $secretRequest.set(null)
+}
+
+// Drop every in-flight prompt. Called when a turn ends (message.complete /
+// error) so a stale overlay can't linger past the turn that raised it — e.g.
+// if the agent was interrupted while a prompt was open.
+export function clearAllPrompts(): void {
+ $approvalRequest.set(null)
+ $sudoRequest.set(null)
+ $secretRequest.set(null)
+}
diff --git a/tools/skills_tool.py b/tools/skills_tool.py
index bc19ff8b5..d771226f9 100644
--- a/tools/skills_tool.py
+++ b/tools/skills_tool.py
@@ -305,7 +305,13 @@ def _capture_required_environment_variables(
}
missing_names = [entry["name"] for entry in missing_entries]
- if _is_gateway_surface():
+ # Most gateway surfaces (messaging platforms) can't prompt for a secret, so
+ # they short-circuit to the "unsupported" hint. Interactive gateway surfaces
+ # — the desktop app / TUI — set HERMES_INTERACTIVE and register a
+ # secret-capture callback that routes to a secure secret.request overlay, so
+ # they fall through and actually prompt. (HERMES_INTERACTIVE is the same flag
+ # tools/approval.py uses to tell an interactive surface from a messaging one.)
+ if _is_gateway_surface() and not env_var_enabled("HERMES_INTERACTIVE"):
return {
"missing_names": missing_names,
"setup_skipped": False,
diff --git a/tui_gateway/server.py b/tui_gateway/server.py
index 68b64e8ec..338218cd8 100644
--- a/tui_gateway/server.py
+++ b/tui_gateway/server.py
@@ -4154,6 +4154,13 @@ def _run_prompt_submit(rid, sid: str, session: dict, text: Any) -> None:
approval_token = set_current_session_key(session["session_key"])
session_tokens = _set_session_context(session["session_key"])
+ # The sudo password callback is thread-local (tools.terminal_tool
+ # _callback_tls), so wiring it on the build thread doesn't reach this
+ # turn thread — terminal sudo prompts would fall through to /dev/tty
+ # and hang the headless gateway. Re-wire here so the prompt routes to
+ # the sudo.request overlay. (secret capture is a module global, so
+ # re-running is a harmless no-op.)
+ _wire_callbacks(sid)
cwd = _session_cwd(session)
_register_session_cwd(session)
cols = session.get("cols", 80)