# NousResearch/hermes-agent · Approval overlay input handling and interrupt cleanup

## Open now

- Nothing open on GitHub.

These items address issues with approval prompts in the TUI and CLI, including terminal freezes from competing input handlers, blind keystroke acceptance, and overlay states not being cleared when an agent run is interrupted.

## TL;DR

- 5 member(s): 0 open issue(s), 1 closed issue(s), 0 open PR(s), 2 merged PR(s), 2 PR(s) closed without merging.
- A fix was merged to main in PR #13697. Verify it addresses your case.
- **Recommended:** read PR #13697 (and any competing PRs below) before filing. Confirm the merged change covers your symptom.

## Maintainer resolution verdict

- Internal-tracker verdict over this cluster's issue members: `resolved` · deciding PR(s): `pr-13697`. This is distinct from the fix status above. Weigh it yourself.

## How this cluster was grouped

- Link evidence (`STRUCTURAL_SPANNING`): every member is connected by structural evidence: shared changed files, or a closing/superseded reference.
- Pairwise link density `0.40`: the share of member pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36. `1.00` means every pair is linked.

## Closed / duplicate issues

- #13618: TUI approval overlay freezes terminal — useInput handlers compete, keystrokes never reach ApprovalPrompt

## Prior & competing PRs

- **#13697: fix: approval prompt freeze — CLI thread-local + TUI blind-keystroke**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`; implication `superseded`
  - files: `cli.py`, `tests/cli/test_cli_approval_ui.py`, `ui-tui/src/app/useInputHandlers.ts`
  - fix hunks:
  - `cli.py`

    ```diff
    @@ -8370,6 +8370,17 @@ def stream_callback(delta: str):
     
                 def run_agent():
                     nonlocal result
    +                # Set callbacks inside the agent thread so thread-local storage
    +                # in terminal_tool is populated for this thread.  The main thread
    +                # registration (run() line ~9046) is invisible here because
    +                # _callback_tls is threading.local().  Matches the pattern used
    +                # by acp_adapter/server.py for ACP sessions.
    +                set_sudo_password_callback(self._sudo_password_callback)
    +                set_approval_callback(self._approval_callback)
    +                try:
    +                    set_secret_capture_callback(self._secret_capture_callback)
    +                except Exception:
    +                    pass
                     agent_message = _voice_prefix + message if _voice_prefix else message
                     # Prepend pending model switch note so the model knows about the switch
                     _msn = getattr(self, '_pending_model_switch_note', None)
    ```

    ```diff
    @@ -8395,6 +8406,15 @@ def run_agent():
                             "failed": True,
                             "error": _summary,
                         }
    +                finally:
    +                    # Clear thread-local callbacks so a reused thread doesn't
    +                    # hold stale references to a disposed CLI instance.
    +                    try:
    +                        set_sudo_password_callback(None)
    +                        set_approval_callback(None)
    +                        set_secret_capture_callback(None)
    +                    except Exception:
    +                        pass
     
                 # Start agent in background thread (daemon so it cannot keep the
                 # process alive when the user closes the terminal tab — SIGHUP
    ```
  - `tests/cli/test_cli_approval_ui.py`

    ```diff
    @@ -254,3 +254,88 @@ def test_approval_display_truncates_giant_command_in_view_mode(self):
     
             # Command got truncated with a marker.
             assert "(command truncated" in rendered
    +
    +
    +class TestApprovalCallbackThreadLocalWiring:
    +    """Regression guard for the thread-local callback freeze (#13617 / #13618).
    +
    +    After 62348cff made _approval_callback / _sudo_password_callback thread-local
    +    (ACP GHSA-qg5c-hvr5-hjgr), the CLI agent thread could no longer see callbacks
    +    registered in the main thread — the dangerous-command prompt silently fell
    +    back to stdin input() and deadlocked against prompt_toolkit. The fix is to
    +    register the callbacks INSIDE the agent worker thread (matching the ACP
    +    pattern). These tests lock in that invariant.
    +    """
    +
    +    def test_main_thread_registration_is_invisible_to_child_thread(self):
    +        """Confirms the underlying threading.local semantics that drove the bug.
    +
    +        If this ever starts passing as "visible", the thread-local isolation
    +        is gone and the ACP race GHSA-qg5c-hvr5-hjgr may be back.
    +        """
    +        from tools.terminal_tool import (
    +            set_approval_callback,
    +            _get_approval_callback,
    +        )
    +
    +        def main_cb(_cmd, _desc):
    +            return "once"
    +
    +        set_approval_callback(main_cb)
    +        try:
    +            seen = {}
    +
    +            def _child():
    +                seen["value"] = _get_approval_callback()
    +
    +            t = threading.Thread(target=_child, daemon=True)
    +            t.start()
    +            t.join(timeout=2)
    +            assert seen["value"] is None
    +        finally:
    +            set_approval_callback(None)
    +
    +    def test_child_thread_registration_is_visible_and_cleared_in_finally(self):
    +        """The fix pattern: register INSIDE the worker thread, clear in finally.
    +
    +        This is exactly what cli.py's run_agent() closure does. If this test
    +        fails, the CLI approval prompt freeze (#13617) has regressed.
    +        """
    +        from tools.terminal_tool import (
    +            set_approval_callback,
    +            set_sudo_password_callback,
    +            _get_approval_callback,
    +            _get_sudo_password_callback,
    +        )
    +
    +        def approval_cb(_cmd, _desc):
    +            return "once"
    +
    +        def sudo_cb():
    +            return "hunter2"
    +
    +        seen = {}
    +
    +        def _worker():
    +            # Mimic cli.py's run_agent() thread target.
    +            set_approval_callback(approval_cb)
    +            set_sudo_password_callback(sudo_cb)
    +            try:
    +                seen["approval"] = _get_approval_callback()
    +                seen["sudo"] = _get_sudo_password_callback()
    +            finally:
    +                set_approval_callback(None)
    +                set_sudo_password_callback(None)
    +                seen["approval_after"] = _get_approval_callback()
    +                seen["sudo_after"] = _get_sudo_password_callback()
    +
    +        t = threading.Thread(target=_worker, daemon=True)
    +        t.start()
    +        t.join(timeout=2)
    +
    +        assert seen["approval"] is approval_cb
    +        assert seen["sudo"] is sudo_cb
    +        # Finally block must clear both slots — otherwise a reused thread
    +        # would hold a stale reference to a disposed CLI instance.
    +        assert seen["approval_after"] is None
    +        assert seen["sudo_after"] is None
    ```
  - `ui-tui/src/app/useInputHandlers.ts`

    ```diff
    @@ -172,6 +172,17 @@ export function useInputHandlers(ctx: InputHandlerContext): InputHandlerResult {
         const live = getUiState()
     
         if (isBlocked) {
    +      // When approval/clarify/confirm overlays are active, their own useInput
    +      // handlers must receive keystrokes (arrow keys, numbers, Enter).  Only
    +      // intercept Ctrl+C here so the user can deny/dismiss — all other keys
    +      // fall through to the component-level handlers.
    +      if (overlay.approval || overlay.clarify || overlay.confirm) {
    +        if (isCtrl(key, ch, 'c')) {
    +          cancelOverlayFromCtrlC()
    +        }
    +        return
    +      }
    +
           if (overlay.pager) {
             if (key.escape || isCtrl(key, ch, 'c') || ch === 'q') {
               return patchOverlayState({ pager: null })
    ```
- **#55567: fix(cli): clear input-blocking overlays when interrupting a running agent (#14026)**. merged to main · fix tier `suggested`
  - maintainer direction: category `merged`; implication `superseded`
  - _suggested-tier fix detail withheld; re-request with `?tier=all` to include it._
- **#13634: \[security\] fix(tui+cli): approval overlays accept blind keystrokes / thread-local callback bypass**. closed without merge
  - maintainer direction: category `merged_via`; implication `superseded`; refs `#13697`
- **#14026: fix(cli): clear overlay states when interrupting a running agent**. closed without merge
  - maintainer direction: category `merged_via`; implication `superseded`; refs `#55567`, `#13618`

## Fix status

- Merged to main in PR #13697 (cited above). No release data is tracked. Verify downstream.
- Fix last verified (merged) `2026-04-21`. This confirmation is old and may be out of date (a revert/reopen would not have invalidated it here); re-verify.
- Subsystem: `hermes_cli`
