Summary
The dangerous-command approval prompt never renders in the classic CLI (non-TUI) mode. The user sees ⏱ Timeout — denying command after 60 seconds but never sees the approval panel. This effectively makes the approval system unusable — commands are silently denied without user interaction.
Environment
- hermes-agent 0.15.2
- RHEL 10 (Linux 6.12.0)
- prompt_toolkit (latest via pip)
- Classic CLI mode (
hermes chat), not --tui
- Provider: custom OpenAI-compatible proxy (localhost bridge)
Root Cause
_invalidate() in cli.py:3273 has a 250ms throttle:
def _invalidate(self, min_interval: float = 0.25) -> None:
if getattr(self, "_resize_recovery_pending", False):
return
now = time.monotonic()
if hasattr(self, "_app") and self._app and (now - self._last_invalidate) >= min_interval:
self._last_invalidate = now
self._app.invalidate()
When _approval_callback (cli.py:11290) fires on the tool-execution thread, it sets self._approval_state and calls self._invalidate(). If any other UI event (spinner frame, output flush, completion) triggered an invalidation within the previous 250ms, the approval's invalidation is silently dropped. The ConditionalContainer wrapping the approval widget never re-evaluates, so the panel never appears.
The retry loop in _approval_callback only calls _invalidate() every 5 seconds (line 11335), which is too infrequent — by then the user has no visual indicator that approval is pending (only the prompt icon changes to ⚠, which is easy to miss).
Code Path
- Tool thread →
check_dangerous_command() → prompt_dangerous_approval() → approval_callback()
_approval_callback sets self._approval_state (dict with command, choices, response_queue)
- Calls
self._invalidate() ← throttled, silently dropped
- Polls
response_queue.get(timeout=1) in a loop
- Retry
_invalidate() only every 5 seconds
- After 60s → timeout → deny
Proposed Fix
Bypass the throttle for approval-critical invalidations:
# In _approval_callback, replace:
self._invalidate()
# With:
if hasattr(self, "_app") and self._app:
self._app.invalidate()
app.invalidate() is prompt_toolkit's thread-safe redraw method. The throttle exists to prevent terminal flicker on slow connections, but approval is a user-blocking modal — it must render immediately regardless of throttle state.
Optionally, add a terminal bell (\a) when the approval panel first appears, as an audible signal.
Impact
Without this fix, approvals.mode: manual is non-functional in classic CLI mode. The approval system fires, waits 60 seconds for a response the user can never give, and denies. Users are forced to use --yolo / mode: off as a workaround.
Validation
- Reviewed by two independent LLMs (Granite 3.2 8B, Llama Scout 17B) via MaaS fan-out — both confirmed the throttle as root cause and the direct
app.invalidate() bypass as the correct fix.
- The existing
_force_full_redraw() method (cli.py:3282) already bypasses the throttle by calling app.invalidate() directly, establishing precedent.
Summary
The dangerous-command approval prompt never renders in the classic CLI (non-TUI) mode. The user sees
⏱ Timeout — denying commandafter 60 seconds but never sees the approval panel. This effectively makes the approval system unusable — commands are silently denied without user interaction.Environment
hermes chat), not--tuiRoot Cause
_invalidate()incli.py:3273has a 250ms throttle:When
_approval_callback(cli.py:11290) fires on the tool-execution thread, it setsself._approval_stateand callsself._invalidate(). If any other UI event (spinner frame, output flush, completion) triggered an invalidation within the previous 250ms, the approval's invalidation is silently dropped. TheConditionalContainerwrapping the approval widget never re-evaluates, so the panel never appears.The retry loop in
_approval_callbackonly calls_invalidate()every 5 seconds (line 11335), which is too infrequent — by then the user has no visual indicator that approval is pending (only the prompt icon changes to⚠, which is easy to miss).Code Path
check_dangerous_command()→prompt_dangerous_approval()→approval_callback()_approval_callbacksetsself._approval_state(dict with command, choices, response_queue)self._invalidate()← throttled, silently droppedresponse_queue.get(timeout=1)in a loop_invalidate()only every 5 secondsProposed Fix
Bypass the throttle for approval-critical invalidations:
app.invalidate()is prompt_toolkit's thread-safe redraw method. The throttle exists to prevent terminal flicker on slow connections, but approval is a user-blocking modal — it must render immediately regardless of throttle state.Optionally, add a terminal bell (
\a) when the approval panel first appears, as an audible signal.Impact
Without this fix,
approvals.mode: manualis non-functional in classic CLI mode. The approval system fires, waits 60 seconds for a response the user can never give, and denies. Users are forced to use--yolo/mode: offas a workaround.Validation
app.invalidate()bypass as the correct fix._force_full_redraw()method (cli.py:3282) already bypasses the throttle by callingapp.invalidate()directly, establishing precedent.