Merge remote-tracking branch 'origin/main' into bb/desktop-background-clarify

This commit is contained in:
Brooklyn Nicholson
2026-06-03 21:07:35 -05:00
11 changed files with 780 additions and 1 deletions

View File

@ -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({
/>
<NotificationStack />
<PromptOverlays />
<div
className="relative min-h-0 max-w-full flex-1 overflow-hidden bg-(--ui-chat-surface-background) contain-[layout_paint]"

View File

@ -19,6 +19,7 @@ import { isProviderSetupErrorMessage } from '@/lib/provider-setup-errors'
import { setClarifyRequest } from '@/store/clarify'
import { notify } from '@/store/notifications'
import { requestDesktopOnboarding } from '@/store/onboarding'
import { clearAllPrompts, setApprovalRequest, setSecretRequest, setSudoRequest } from '@/store/prompts'
import {
setCurrentBranch,
setCurrentCwd,
@ -751,6 +752,13 @@ export function useMessageStream({
return
}
// Turn ended — drop any blocking prompt that's still open (e.g. the
// agent was interrupted, or the approval already resolved). Prevents a
// stale overlay from outliving the turn that raised it.
if (isActiveEvent) {
clearAllPrompts()
}
flushQueuedDeltas(sessionId)
if (isActiveEvent) {
@ -830,10 +838,60 @@ export function useMessageStream({
})
}
}
} else if (event.type === 'approval.request') {
if (!isActiveEvent) {
return
}
// Dangerous-command / execute_code approval. The Python side is
// blocked in _await_gateway_decision() until approval.respond lands;
// without this the agent stalls until its 5-min timeout and the tool
// is BLOCKED. Approval is session-keyed (no request_id) — the overlay
// sends back {choice, session_id}.
setApprovalRequest({
command: typeof payload?.command === 'string' ? payload.command : '',
description: typeof payload?.description === 'string' ? payload.description : 'dangerous command',
sessionId: sessionId ?? null
})
} else if (event.type === 'sudo.request') {
if (!isActiveEvent) {
return
}
// Sudo password capture (tools/terminal_tool.py). Blocked on
// sudo.respond {request_id, password}.
const requestId = typeof payload?.request_id === 'string' ? payload.request_id : ''
if (requestId) {
setSudoRequest({ requestId })
}
} else if (event.type === 'secret.request') {
if (!isActiveEvent) {
return
}
// Skill credential capture (tools/skills_tool.py). Blocked on
// secret.respond {request_id, value}.
const requestId = typeof payload?.request_id === 'string' ? payload.request_id : ''
if (requestId) {
setSecretRequest({
requestId,
envVar: typeof payload?.env_var === 'string' ? payload.env_var : '',
prompt: typeof payload?.prompt === 'string' ? payload.prompt : ''
})
}
} else if (event.type === 'error') {
const errorMessage = payload?.message || 'Hermes reported an error'
const looksLikeProviderSetup = isProviderSetupErrorMessage(errorMessage)
// A turn that errors out has also ended — drop any open blocking
// prompt so an approval/sudo/secret overlay can't linger past the
// failed turn (same intent as the message.complete clear).
if (isActiveEvent) {
clearAllPrompts()
}
if (looksLikeProviderSetup) {
requestDesktopOnboarding(errorMessage)
} else if (isActiveEvent) {

View File

@ -0,0 +1,78 @@
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { HermesGateway } from '@/hermes'
import { $gateway } from '@/store/gateway'
import { $approvalRequest } from '@/store/prompts'
import { PendingToolApproval } from './tool-approval'
import type { ToolPart } from './tool-fallback-model'
function part(toolName: string): ToolPart {
return { toolName, type: `tool-${toolName}` } as unknown as ToolPart
}
function setRequest(command = 'rm -rf /tmp/x') {
$approvalRequest.set({ command, description: 'dangerous command', sessionId: 'sess-1' })
}
function mockGateway() {
const request = vi.fn().mockResolvedValue({ resolved: true })
$gateway.set({ request } as unknown as HermesGateway)
return request
}
afterEach(() => {
cleanup()
$approvalRequest.set(null)
$gateway.set(null)
})
describe('PendingToolApproval', () => {
it('renders nothing when there is no pending approval', () => {
const { container } = render(<PendingToolApproval part={part('terminal')} />)
expect(container.innerHTML).toBe('')
})
it('renders nothing for tools that never raise approval', () => {
setRequest()
const { container } = render(<PendingToolApproval part={part('read_file')} />)
expect(container.innerHTML).toBe('')
})
it('renders the inline run/reject controls on the pending terminal row', () => {
setRequest('chmod -R 777 /tmp/x')
render(<PendingToolApproval part={part('terminal')} />)
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(<PendingToolApproval part={part('terminal')} />)
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(<PendingToolApproval part={part('terminal')} />)
fireEvent.click(screen.getByRole('button', { name: /Reject/ }))
await waitFor(() => {
expect(request).toHaveBeenCalledWith('approval.respond', { choice: 'deny', session_id: 'sess-1' })
})
})
})

View File

@ -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 <ApprovalBar request={request} />
}
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<ApprovalChoice | null>(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 (
<div className="mt-1 flex items-center gap-2.5 ps-5" data-slot="tool-approval-inline">
<div className="inline-flex h-6 items-stretch overflow-hidden rounded-md border border-primary/25 bg-primary/10 text-primary">
<Button
className="h-full gap-1 rounded-none px-2 text-xs font-medium text-primary hover:bg-primary/15 hover:text-primary"
disabled={busy}
onClick={() => void respond('once')}
size="xs"
variant="ghost"
>
{submitting === 'once' ? <Loader2 className="size-3 animate-spin" /> : 'Run'}
{submitting !== 'once' && <span className="text-[0.625rem] text-primary/60">{isMac ? '⌘⏎' : 'Ctrl⏎'}</span>}
</Button>
<span aria-hidden className="w-px self-stretch bg-primary/20" />
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
aria-label="More approval options"
className="h-full w-5 rounded-none px-0 text-primary hover:bg-primary/15 hover:text-primary"
disabled={busy}
size="xs"
variant="ghost"
>
<ChevronDown className="size-3" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="min-w-44">
<DropdownMenuItem onSelect={() => void respond('session')}>Allow this session</DropdownMenuItem>
<DropdownMenuItem
onSelect={() => {
// 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
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => void respond('deny')} variant="destructive">
Reject
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
<Button
className="h-6 gap-1.5 rounded-md px-1.5 text-xs font-normal text-(--ui-text-tertiary) hover:text-foreground"
disabled={busy}
onClick={() => void respond('deny')}
size="xs"
variant="ghost"
>
{submitting === 'deny' ? <Loader2 className="size-3 animate-spin" /> : 'Reject'}
{submitting !== 'deny' && <span className="text-[0.625rem] opacity-55">Esc</span>}
</Button>
<Dialog onOpenChange={setConfirmAlways} open={confirmAlways}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>Always allow this command?</DialogTitle>
<DialogDescription>
This adds the {request.description} pattern to your permanent allowlist (
<code className="font-mono text-xs">~/.hermes/config.yaml</code>). Hermes wont ask again for commands
like this in this session or any future one.
</DialogDescription>
</DialogHeader>
{request.command.trim() && (
<pre className="max-h-32 overflow-auto whitespace-pre-wrap break-words rounded-md border border-(--ui-stroke-tertiary) bg-(--ui-chat-surface-background) px-2.5 py-1.5 font-mono text-xs leading-snug text-foreground">
{request.command.trim()}
</pre>
)}
<DialogFooter>
<Button onClick={() => setConfirmAlways(false)} size="sm" variant="ghost">
Cancel
</Button>
<Button
onClick={() => {
setConfirmAlways(false)
void respond('always')
}}
size="sm"
variant="destructive"
>
Always allow
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
}

View File

@ -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) {
</span>
</DisclosureRow>
</div>
{isPending && <PendingToolApproval part={part} />}
{open && (
<div className="grid w-full min-w-0 max-w-full gap-1.5 overflow-hidden p-1.5">
{!embedded && view.previewTarget && isPreviewableTarget(view.previewTarget) && (

View File

@ -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<HTMLFormElement>) => {
event.preventDefault()
void send(password)
},
[password, send]
)
if (!request) {
return null
}
return (
<Dialog onOpenChange={onOpenChange} open>
<DialogContent showCloseButton={false}>
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Lock className="size-4 text-primary" />
Administrator password
</DialogTitle>
<DialogDescription>
Hermes needs your sudo password to run a privileged command. It is sent only to your local agent.
</DialogDescription>
</DialogHeader>
<form className="grid gap-3" onSubmit={onSubmit}>
<Input
autoFocus
disabled={submitting}
onChange={event => setPassword(event.target.value)}
placeholder="sudo password"
type="password"
value={password}
/>
<DialogFooter>
<Button disabled={submitting} onClick={() => void send('')} type="button" variant="ghost">
Cancel
</Button>
<Button disabled={submitting} type="submit">
{submitting ? <Loader2 className="size-3.5 animate-spin" /> : 'Send'}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)
}
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<HTMLFormElement>) => {
event.preventDefault()
void send(value)
},
[send, value]
)
if (!request) {
return null
}
return (
<Dialog onOpenChange={onOpenChange} open>
<DialogContent showCloseButton={false}>
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<KeyRound className="size-4 text-primary" />
{request.envVar || 'Secret required'}
</DialogTitle>
<DialogDescription>{request.prompt || 'Hermes needs a credential to continue.'}</DialogDescription>
</DialogHeader>
<form className="grid gap-3" onSubmit={onSubmit}>
<Input
autoFocus
disabled={submitting}
onChange={event => setValue(event.target.value)}
placeholder={request.envVar || 'secret value'}
type="password"
value={value}
/>
<DialogFooter>
<Button disabled={submitting} onClick={() => void send('')} type="button" variant="ghost">
Cancel
</Button>
<Button disabled={submitting || !value} type="submit">
{submitting ? <Loader2 className="size-3.5 animate-spin" /> : 'Send'}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)
}
export function PromptOverlays() {
return (
<>
<SudoDialog />
<SecretDialog />
</>
)
}

View File

@ -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 {

View File

@ -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()
})
})

View File

@ -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<ApprovalRequest | null>(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<SudoRequest | null>(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<SecretRequest | null>(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)
}

View File

@ -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,

View File

@ -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)