Skip to content

Approval panel never renders — _invalidate() throttle drops redraw #41098

@jodonnel

Description

@jodonnel

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

  1. Tool thread → check_dangerous_command()prompt_dangerous_approval()approval_callback()
  2. _approval_callback sets self._approval_state (dict with command, choices, response_queue)
  3. Calls self._invalidate()throttled, silently dropped
  4. Polls response_queue.get(timeout=1) in a loop
  5. Retry _invalidate() only every 5 seconds
  6. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    P2Medium — degraded but workaround existscomp/cliCLI entry point, hermes_cli/, setup wizardtype/bugSomething isn't working

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions