SWE-Pair › Tasks › nousresearch-hermes-agent-32018-33241 ← prevnext →

nousresearch-hermes-agent-32018-33241

NousResearch/hermes-agentcleanPRs #32018, #33241merged 2026-05-27MITfix: 2 files, +142 −2610 fail-to-pass · 7 pass-to-pass
Results
Entrysolved / attemptsmedian stepsmedian costattempts
DeepSeek V4.1 Flash · max1/157$0.1001✓
GPT-6 Luna · max1/146$0.1411✓
DeepSeek V4.1 Flash · medium1/165$0.1441✓
GPT-5.6 Luna · medium2/214$0.0151✓ 2✓
GLM-5.3 Flash · medium1/131$0.0121✓
The bug report the agent sees

The CLI has two input-related failures:

1. **Incomplete bracketed paste can permanently freeze input.** When a terminal begins a bracketed paste but the paste-ending escape sequence is lost—for example because of an SSH interruption, terminal race, sleep/wake event, or incomplete write—the CLI keeps treating all subsequent input as part of that paste indefinitely. The prompt appears frozen, later keystrokes are ignored, and the user must terminate the process to recover.

The CLI should recover automatically after a short delay when the paste end marker does not arrive. Recovery must deliver the text already received—including multiline and otherwise buffered content—as a paste event, return the parser to ordinary input handling, and allow newly typed keys and subsequent input to work normally. A complete bracketed paste whose end marker arrives promptly must continue to work unchanged, without premature recovery or duplicate paste events. Input that has not exceeded the recovery delay must not be flushed early, and a partial or torn end marker must not prevent recovery.

2. **Destructive slash-command confirmation can freeze on Linux and macOS.** Running `/new`, `/clear`, or `/reset` from the CLI can display a confirmation modal from a background thread and then hang permanently on non-Windows systems. The prompt_toolkit application remains active while the modal waits through a separate raw terminal input path, so the user cannot select an option or return to the prompt.

On Linux and macOS, the confirmation modal must be shown and dismissed through the running CLI application's event loop even when requested by a background thread. The user's selection must be delivered back to the requesting operation, the modal state must be cleared afterward, and the CLI must remain responsive. Windows behavior must continue to support its platform-specific fallback when necessary.

Contract the tests pin for the paste recovery: `cli.py` must define a top-level function `_apply_bracketed_paste_timeout_patch()` that installs the recovery into prompt_toolkit's `Vt100Parser.feed`. The tests extract that function's source by name and execute it on its own, so it must be self-contained (every import inside its body, no reliance on other module-level names) and idempotent when called again. Idempotence is recorded as a module attribute `_hermes_bp_timeout_patched = True` on `prompt_toolkit.input.vt100_parser` (the tests reload that module, clear the flag and re-apply the patch, then assert the flag is set); when executed standalone the helper has only `time` and a `logger` available in its namespace, everything else must be imported inside it. The wrapping must target the installed prompt_toolkit's `Vt100Parser.feed` and its `_in_bracketed_paste` / `_paste_buffer` state, not attributes the installed version does not have. Once installed: entering bracketed-paste mode records the start time on the parser as `_hermes_bp_start` (a `time.monotonic()` value; the tests overwrite it to simulate elapsed time); a feed that arrives more than 2 seconds after that start without the end marker delivers the buffered text as a normal bracketed-paste key press through the parser's callback, leaves paste mode (`_in_bracketed_paste` false) and parses the new data normally; feeds within the window keep buffering without delivering anything; an end marker split across feeds within the window completes the paste normally with exactly the pasted content.

Contract the tests pin for the modal: `_prompt_text_input_modal(...)` accepts a `timeout` keyword (seconds to wait for an answer). Off the main thread on Linux/macOS it must hand the modal's setup and teardown to the running application's loop via `self._app.loop.call_soon_threadsafe` (the tests replace that with an inline call) rather than the raw-stdin path `_prompt_text_input`, which must not be called; while waiting, `self._slash_confirm_state` is a dict holding a `"response_queue"` into which the chosen value is put (the tests put `"once"` / `"cancel"` there from another thread), and that value is what the call returns. The existing `_capture_modal_input_snapshot` and `_restore_modal_input_snapshot` are each called exactly once around the modal, and `_slash_confirm_state` is `None` again after it returns.

IMPORTANT: Work in the repository at /app and commit your changes when you are done. Your submission is the diff from the starting commit.

Hidden tests · 10 fail-to-pass, 7 pass-to-passtests from both pull requests, run after the agent submits
tests.cli.test_bracketed_paste_timeout.TestBracketedPasteTimtests.cli.test_bracketed_paste_timeout.TestBracketedPasteTimtests.cli.test_bracketed_paste_timeout.TestBracketedPasteTimtests.cli.test_bracketed_paste_timeout.TestBracketedPasteTimtests.cli.test_bracketed_paste_timeout.TestBracketedPasteTimtests.cli.test_bracketed_paste_timeout.TestBracketedPasteTimtests.cli.test_bracketed_paste_timeout.TestBracketedPasteTimtests.cli.test_bracketed_paste_timeout.TestBracketedPasteTim+2 more
Test patch · 284 lines
diff --git a/tests/cli/test_bracketed_paste_timeout.py b/tests/cli/test_bracketed_paste_timeout.py
new file mode 100644
index 000000000..3e9938933
--- /dev/null
+++ b/tests/cli/test_bracketed_paste_timeout.py
@@ -0,0 +1,157 @@
+"""Tests for bracketed-paste timeout safety valve (#16263).
+
+Verifies the production helper in cli.py monkey-patches prompt_toolkit's
+Vt100Parser.feed() so the parser auto-escapes from bracketed-paste mode when
+the ESC[201~ end mark is never received.
+"""
+import ast
+import importlib
+import logging
+import time
+from pathlib import Path
+from unittest.mock import MagicMock
+
+from prompt_toolkit.keys import Keys
+
+
+ROOT = Path(__file__).resolve().parents[2]
+CLI_PATH = ROOT / "cli.py"
+
+
+def _load_production_patch_helper():
+    """Load cli._apply_bracketed_paste_timeout_patch without importing cli.
+
+    Importing cli.py pulls optional runtime deps that aren't required for this
+    parser-level regression.  AST-loading the exact helper keeps the test tied
+    to production code while avoiding unrelated import side effects.  If the
+    production helper is removed, this test fails.
+    """
+    source = CLI_PATH.read_text(encoding="utf-8")
+    tree = ast.parse(source)
+    helper_node = next(
+        (
+            node
+            for node in tree.body
+            if isinstance(node, ast.FunctionDef)
+            and node.name == "_apply_bracketed_paste_timeout_patch"
+        ),
+        None,
+    )
+    assert helper_node is not None, (
+        "cli.py must define _apply_bracketed_paste_timeout_patch()"
+    )
+    helper_source = ast.get_source_segment(source, helper_node)
+    namespace = {"time": time, "logger": logging.getLogger("test.cli")}
+    exec(helper_source, namespace)
+    return namespace["_apply_bracketed_paste_timeout_patch"]
+
+
+def _reset_and_apply_production_patch():
+    """Reload prompt_toolkit's parser and apply Hermes' production patch."""
+    import prompt_toolkit.input.vt100_parser as vt100_mod
+
+    vt100_mod = importlib.reload(vt100_mod)
+    # importlib.reload() preserves module dict entries that the reloaded source
+    # does not redefine, so clear Hermes' sentinel before re-applying.
+    if hasattr(vt100_mod, "_hermes_bp_timeout_patched"):
+        delattr(vt100_mod, "_hermes_bp_timeout_patched")
+    _load_production_patch_helper()()
+    assert getattr(vt100_mod, "_hermes_bp_timeout_patched", False)
+    return vt100_mod
+
+
+class TestBracketedPasteTimeout:
+    """Verify the Vt100Parser monkey-patch prevents frozen bracketed-paste."""
+
+    def _make_parser(self):
+        """Create a Vt100Parser after applying the production patch."""
+        vt100_mod = _reset_and_apply_production_patch()
+        callback = MagicMock()
+        parser = vt100_mod.Vt100Parser(callback)
+        return parser, callback
+
+    def test_normal_bracketed_paste_works(self):
+        """A complete bracketed-paste sequence should work normally."""
+        parser, callback = self._make_parser()
+        parser.feed("\x1b[200~hello world\x1b[201~")
+        callback.assert_called_once()
+        call_args = callback.call_args[0][0]
+        assert call_args.data == "hello world"
+
+    def test_incomplete_paste_times_out(self):
+        """If ESC[201~ is never received, parser should recover after timeout."""
+        parser, callback = self._make_parser()
+        parser.feed("\x1b[200~some pasted text")
+        assert parser._in_bracketed_paste
+
+        parser._hermes_bp_start = time.monotonic() - 3.0
+        parser.feed("more data")
+
+        assert not parser._in_bracketed_paste
+        assert callback.called
+
+    def test_timeout_preserves_buffered_content(self):
+        """Auto-escape should flush buffered content, not lose it."""
+        parser, callback = self._make_parser()
+        content = "line1\nline2\nline3"
+        parser.feed(f"\x1b[200~{content}")
+        parser._hermes_bp_start = time.monotonic() - 3.0
+        parser.feed("")
+
+        paste_events = [
+            c[0][0]
+            for c in callback.call_args_list
+            if hasattr(c[0][0], "key") and c[0][0].key == Keys.BracketedPaste
+        ]
+        assert len(paste_events) >= 1
+        assert content in paste_events[0].data
+
+    def test_normal_keys_after_timeout_recovery(self):
+        """After timeout recovery, normal key processing should resume."""
+        parser, callback = self._make_parser()
+        parser.feed("\x1b[200~stuck")
+        parser._hermes_bp_start = time.monotonic() - 3.0
+        parser.feed("")
+
+        assert not parser._in_bracketed_paste
+        callback.reset_mock()
+        parser.feed("a")
+        assert not parser._in_bracketed_paste
+
+    def test_no_timeout_when_end_mark_arrives_quickly(self):
+        """No timeout should fire if end mark arrives within the window."""
+        parser, callback = self._make_parser()
+        parser.feed("\x1b[200~quick paste\x1b[201~")
+        assert not parser._in_bracketed_paste
+        callback.assert_called_once()
+
+    def test_subsequent_data_after_incomplete_paste(self):
+        """Data arriving after a stuck paste should be processable."""
+        parser, callback = self._make_parser()
+        parser.feed("\x1b[200~content")
+        parser._hermes_bp_start = time.monotonic() - 5.0
+        parser.feed("x")
+
+        assert not parser._in_bracketed_paste
+        assert callback.call_count >= 1
+
+    def test_torn_end_mark_recovers(self):
+        """If end mark arrives split across feeds within timeout, it still works."""
+        parser, callback = self._make_parser()
+        parser.feed("\x1b[200~some content\x1b[20")
+        assert parser._in_bracketed_paste
+
+        parser.feed("1~")
+        assert not parser._in_bracketed_paste
+        callback.assert_called_once()
+        assert callback.call_args[0][0].data == "some content"
+
+    def test_no_timeout_under_threshold(self):
+        """Bracketed-paste mode should not timeout within the 2s window."""
+        parser, callback = self._make_parser()
+        parser.feed("\x1b[200~waiting")
+        parser._hermes_bp_start = time.monotonic() - 0.5
+        parser.feed("more waiting")
+
+        assert parser._in_bracketed_paste
+        assert not callback.called
diff --git a/tests/cli/test_slash_confirm_windows.py b/tests/cli/test_slash_confirm_windows.py
index 2ec341f45..980bae32d 100644
--- a/tests/cli/test_slash_confirm_windows.py
+++ b/tests/cli/test_slash_confirm_windows.py
@@ -1,14 +1,14 @@
-"""Regression tests for issue #30768: /reset and /new freeze on Windows.
+"""Regression tests for issue #30768 and #32383.
 
 ``_prompt_text_input_modal`` uses a queue-based modal that relies on
 prompt_toolkit key bindings receiving keyboard events.  On Windows the
 prompt_toolkit input channel can deadlock when the modal is entered from
 the ``process_loop`` daemon thread.  The fix falls back to the simpler
-``_prompt_text_input`` (stdin-based) prompt on Windows and non-main threads.
+``_prompt_text_input`` (stdin-based) prompt on Windows.
 
 These tests verify:
 1. Windows detection triggers the stdin fallback
-2. Non-main thread detection triggers the stdin fallback
+2. Non-Windows daemon threads still use the modal via the app loop
 3. macOS/Linux main-thread path still uses the modal (no regression)
 4. No-app path still uses the stdin fallback (existing behavior)
 5. Empty choices returns None (existing behavior)
@@ -29,6 +29,7 @@ def _make_cli():
 
     obj = object.__new__(cli_mod.HermesCLI)
     obj._app = MagicMock()
+    obj._app.loop = MagicMock()
     obj._status_bar_visible = True
     obj._last_invalidate = 0.0
     obj._modal_input_snapshot = None
@@ -66,28 +67,47 @@ class TestModalWindowsFallback:
         mock_stdin.assert_called_once_with("Choice [1/2/3]: ")
         assert result == "1"
 
-    def test_non_main_thread_falls_back_to_stdin(self):
-        """Off the main thread, _prompt_text_input_modal should use stdi
… [4075 more characters]
Reference fix · 2 files, +142 −26used only for grading calibration

Both upstream fixes combined. The agent could not see this: one commit in the repository and no network.

diff --git a/cli.py b/cli.py
index ce4f8eb43ec3..032fbb4c4754 100644
--- a/cli.py
+++ b/cli.py
@@ -2360,6 +2360,89 @@ def _strip_leaked_bracketed_paste_wrappers(text: str) -> str:
     return text
 
 
+def _apply_bracketed_paste_timeout_patch() -> None:
+    """Patch prompt_toolkit to recover from torn bracketed-paste sequences.
+
+    prompt_toolkit's ``Vt100Parser.feed()`` buffers all input while waiting
+    for the ESC[201~ end mark.  If a terminal drops that end mark (terminal
+    race, torn write, SSH glitch, macOS sleep/wake), input appears frozen
+    forever — the only recovery used to be killing the tab.
+
+    This patch wraps ``Vt100Parser.feed`` so that bracketed-paste mode
+    flushes buffered content as a normal ``BracketedPaste`` event after
+    ``_BP_TIMEOUT_S`` seconds without an end marker, then resumes normal
+    parsing.  See upstream issue #16263.
+
+    The patch is idempotent — repeated calls are no-ops via the
+    ``_hermes_bp_timeout_patched`` sentinel on the module.
+    """
+    try:
+        import prompt_toolkit.input.vt100_parser as _vt100_mod
+        from prompt_toolkit.keys import Keys as _PtKeys
+        from prompt_toolkit.key_binding.key_processor import KeyPress as _PtKeyPress
+
+        if getattr(_vt100_mod, "_hermes_bp_timeout_patched", False):
+            return
+
+        _BP_TIMEOUT_S = 2.0  # max time to wait for ESC[201~ before flushing
+
+        def _patched_vt100_feed(self_parser, data: str) -> None:
+            if self_parser._in_bracketed_paste:
+                self_parser._paste_buffer += data
+                end_mark = "\x1b[201~"
+
+                if end_mark in self_parser._paste_buffer:
+                    end_index = self_parser._paste_buffer.index(end_mark)
+                    paste_content = self_parser._paste_buffer[:end_index]
+                    self_parser.feed_key_callback(
+                        _PtKeyPress(_PtKeys.BracketedPaste, paste_content)
+                    )
+                    self_parser._in_bracketed_paste = False
+                    remaining = self_parser._paste_buffer[
+                        end_index + len(end_mark):
+                    ]
+                    self_parser._paste_buffer = ""
+                    self_parser._hermes_bp_start = None
+                    if remaining:
+                        _patched_vt100_feed(self_parser, remaining)
+                else:
+                    bp_start = getattr(self_parser, "_hermes_bp_start", None)
+                    now = time.monotonic()
+                    if bp_start is None:
+                        self_parser._hermes_bp_start = now
+                    elif now - bp_start > _BP_TIMEOUT_S:
+                        paste_content = self_parser._paste_buffer
+                        self_parser._in_bracketed_paste = False
+                        self_parser._paste_buffer = ""
+                        self_parser._hermes_bp_start = None
+                        if paste_content:
+                            self_parser.feed_key_callback(
+                                _PtKeyPress(_PtKeys.BracketedPaste, paste_content)
+                            )
+                            logger.warning(
+                                "Bracketed-paste timeout (%.1fs) — flushed %d bytes "
+                                "without end mark. Terminal may have dropped ESC[201~ "
+                                "(see #16263).",
+                                now - bp_start,
+                                len(paste_content),
+                            )
+            else:
+                # Normal mode — re-inline prompt_toolkit's normal feed path.
+                # Calling the original feed here would double-buffer after the
+                # bracketed-paste entry transition.
+                for i, c in enumerate(data):
+                    if self_parser._in_bracketed_paste:
+                        _patched_vt100_feed(self_parser, data[i:])
+                        break
+                    self_parser._input_parser.send(c)
+
+        _vt100_mod.Vt100Parser.feed = _patched_vt100_feed
+        _vt100_mod._hermes_bp_timeout_patched = True
+        logger.debug("Applied Vt100Parser bracketed-paste timeout patch (#16263)")
+    except Exception as exc:  # noqa: BLE001 — defensive: never break startup
+        logger.debug("Bracketed-paste timeout patch skipped: %s", exc)
+
+
 # Cursor Position Report (CPR / DSR) response, format ``ESC[<row>;<col>R``.
 # prompt_toolkit's _on_resize() + renderer send ``ESC[6n`` queries to the
 # terminal; under resize storms or tab switches the terminal's reply can
@@ -14151,6 +14234,10 @@ def _patched_output_screen_diff(
         except Exception:
             pass
 
+        # Apply bracketed-paste timeout recovery so torn ESC[201~ end marks
+        # don't permanently freeze the input (issue #16263). Idempotent.
+        _apply_bracketed_paste_timeout_patch()
+
         _original_on_resize = app._on_resize
 
         def _resize_clear_ghosts():
diff --git a/scripts/release.py b/scripts/release.py
index 27d2053fdc6b..61fa2971632a 100755
--- a/scripts/release.py
+++ b/scripts/release.py
@@ -1303,6 +1303,7 @@
     "490408354@qq.com": "daizhonggeng",  # PR #9020 (numbered /resume selection)
     "claw@openclaw.ai": "wanwan2qq",  # PR #10215 (strip brackets/quotes from /resume; gateway session-ID lookup)
     "simo.kiihamaki@gmail.com": "SimoKiihamaki",  # PR #30773 (Windows /reset+/new freeze; stdin fallback for modal)
+    "66773372+Tranquil-Flow@users.noreply.github.com": "Tranquil-Flow",  # PR #27518 (bracketed-paste timeout)
 }
 
 
diff --git a/cli.py b/cli.py
index e4901c572889..6c77afc07a42 100644
--- a/cli.py
+++ b/cli.py
@@ -7154,11 +7154,13 @@ def _prompt_text_input_modal(
 
         * ``sys.platform == "win32"`` — native Windows console (ConPTY /
           win32_input) does not support the modal reliably.
-        * Called from a non-main thread — the prompt_toolkit event loop only
-          runs on the main thread; key bindings can't fire from a daemon
-          thread (same rationale as the ``_prompt_text_input`` thread guard
-          in PR #23454).
         * ``self._app`` is not set — unit tests / non-interactive contexts.
+
+        On non-Windows platforms the modal itself is still safe from the
+        ``process_loop`` daemon thread as long as the main-thread event loop
+        owns the prompt_toolkit buffer mutations.  When we are off the main
+        thread, schedule the modal snapshot / restore work on ``self._app.loop``
+        via ``call_soon_threadsafe`` and keep the queue-based response path.
         """
         import threading
         import time as _time
@@ -7179,33 +7181,62 @@ def _prompt_text_input_modal(
         if sys.platform == "win32":
             return self._prompt_text_input("Choice [1/2/3]: ")
 
-        # Mirror the thread-aware guard from _prompt_text_input (PR #23454):
-        # run_in_terminal and the modal queue both depend on the main-thread
-        # event loop.  From a daemon thread the modal key bindings never fire.
-        if threading.current_thread() is not threading.main_thread():
+        try:
+            app_loop = self._app.loop
+        except Exception:
+            app_loop = None
+
+        in_main_thread = threading.current_thread() is threading.main_thread()
+        if not in_main_thread and app_loop is None:
             return self._prompt_text_input("Choice [1/2/3]: ")
 
         response_queue = queue.Queue()
-        self._capture_modal_input_snapshot()
-        self._slash_confirm_state = {
-            "title": title,
-            "detail": detail,
-            "choices": choices,
-            "selected": 0,
-            "response_queue": response_queue,
-        }
-        self._slash_confirm_deadline = _time.monotonic() + timeout
-        self._invalidate()
+
+        def _setup_modal() -> None:
+            self._capture_modal_input_snapshot()
+            self._slash_confirm_st
… [2257 more characters]

What each entry did

Submitted diff first, then every command.
DeepSeek V4.1 Flash · max · attempt 1 · solved · 57 steps · $0.100
Submitted diff · 276 lines
diff --git a/cli.py b/cli.py
index ce4f8eb..c8c60bb 100644
--- a/cli.py
+++ b/cli.py
@@ -2448,6 +2448,102 @@ def _disable_prompt_toolkit_cpr_warning(app) -> None:
         pass
 
 
+def _apply_bracketed_paste_timeout_patch() -> None:
+    """Recover from a bracketed paste whose end marker never arrives.
+
+    prompt_toolkit's ``Vt100Parser`` buffers everything after ``ESC[200~``
+    until it sees ``ESC[201~``.  If that end marker is lost (SSH interruption,
+    terminal race, sleep/wake, or a torn write), every later keystroke is
+    treated as paste content and the prompt appears permanently frozen.
+
+    This installs a small wrapper around ``Vt100Parser.feed``.  When a feed
+    arrives more than two seconds after paste mode was entered and no end
+    marker has completed the paste, the text already buffered is delivered as
+    a normal ``BracketedPaste`` key press, paste mode is left, and the newly
+    arrived input is parsed through the ordinary path.
+
+    The helper is deliberately self-contained.  The regression tests extract
+    this function's source and execute it with a namespace that only provides
+    ``time`` and ``logger``, so every import must stay inside the body.  A
+    module-level flag makes repeated calls idempotent.
+    """
+    import prompt_toolkit.input.vt100_parser as _vt100_parser
+    from prompt_toolkit.key_binding.key_processor import KeyPress
+    from prompt_toolkit.keys import Keys
+
+    _Vt100Parser = _vt100_parser.Vt100Parser
+    _original_feed = _Vt100Parser.feed
+
+    # Idempotent: a previous call already installed this exact wrapper.  Also
+    # tolerate a stale module flag after a reload whose class was replaced.
+    if getattr(_original_feed, "_hermes_bp_timeout_wrapper", False):
+        _vt100_parser._hermes_bp_timeout_patched = True
+        return
+    # If the module flag is set but this class's feed is not our wrapper (e.g.
+    # a reload without clearing the flag), fall through and install it.
+
+    _recovery_delay = 2.0
+    _paste_end_marker = "\x1b[201~"
+
+    def _feed_with_bracketed_paste_timeout(self, data):
+        now = time.monotonic()
+
+        if getattr(self, "_in_bracketed_paste", False):
+            started = getattr(self, "_hermes_bp_start", None)
+            if started is not None and (now - started) > _recovery_delay:
+                buffered = getattr(self, "_paste_buffer", "") or ""
+
+                # Leave paste mode before handing the buffered text back to
+                # the application.  A partial/torn end marker is treated as
+                # ordinary buffered text so it can never block recovery.
+                self._in_bracketed_paste = False
+                self._paste_buffer = ""
+                self._hermes_bp_start = None
+                try:
+                    self.feed_key_callback(KeyPress(Keys.BracketedPaste, buffered))
+                except Exception:
+                    logger.debug(
+                        "bracketed-paste timeout callback failed", exc_info=True
+                    )
+
+                # Parse the just-arrived data through the ordinary path.
+                _original_feed(self, data)
+                if (
+                    getattr(self, "_in_bracketed_paste", False)
+                    and getattr(self, "_hermes_bp_start", None) is None
+                ):
+                    self._hermes_bp_start = time.monotonic()
+                return
+
+        was_in_paste = bool(getattr(self, "_in_bracketed_paste", False))
+        prior_buffer = getattr(self, "_paste_buffer", "") or "" if was_in_paste else ""
+        _original_feed(self, data)
+
+        if getattr(self, "_in_bracketed_paste", False):
+            # Record only on entry (or when a prior partial patch left no
+            # timestamp).  Feeding more chunks must not extend the deadline.
+            # A feed that ends one paste and immediately begins another must
+            # start a fresh deadline for the second paste.
+            ended_and_restarted = _paste_end_marker in (prior_buffer + data)
+            if (
+                not was_in_paste
+                or getattr(self, "_hermes_bp_start", None) is None
+                or ended_and_restarted
+            ):
+                self._hermes_bp_start = time.monotonic()
+        else:
+            self._hermes_bp_start = None
+
+    _feed_with_bracketed_paste_timeout._hermes_bp_timeout_wrapper = True
+    _Vt100Parser.feed = _feed_with_bracketed_paste_timeout
+    _vt100_parser._hermes_bp_timeout_patched = True
+
+
+# Install before any prompt_toolkit input parser is created.  The helper is
+# idempotent, so repeated imports/reloads are harmless.
+_apply_bracketed_paste_timeout_patch()
+
+
 def _strip_leaked_terminal_responses_with_meta(text: str) -> tuple[str, bool]:
     """Strip leaked terminal control-response sequences from user input.
 
@@ -7049,11 +7145,14 @@ class HermesCLI:
 
         * ``sys.platform == "win32"`` — native Windows console (ConPTY /
           win32_input) does not support the modal reliably.
-        * Called from a non-main thread — the prompt_toolkit event loop only
-          runs on the main thread; key bindings can't fire from a daemon
-          thread (same rationale as the ``_prompt_text_input`` thread guard
-          in PR #23454).
         * ``self._app`` is not set — unit tests / non-interactive contexts.
+
+        On Linux/macOS the running prompt_toolkit event loop owns stdin.  Even
+        when a slash command is dispatched from a background daemon thread,
+        the modal must be opened and closed on that loop — otherwise raw stdin
+        races the active application and the confirmation freezes.  We use
+        ``loop.call_soon_threadsafe`` for setup/teardown and wait on the
+        modal's response queue from the requesting thread.
         """
         import threading
         import time as _time
@@ -7061,9 +7160,10 @@ class HermesCLI:
         if not choices:
          
… [6281 more characters]
step 1
$ cd /repo && ls -la && find . -maxdepth 2 -type f | head -100
output · 34 lines
total 2916
drwxr-xr-x 1 root root   4096 Nov  5 07:37 .
drwxr-xr-x 1 root root   4096 Nov  5 07:36 ..
-rw-r--r-- 1 root root    609 Nov  5 07:30 .dockerignore
-rw-r--r-- 1 root root  23061 Nov  5 07:30 .env.example
-rw-r--r-- 1 root root    193 Nov  5 07:30 .envrc
drwxr-xr-x 1 root root   4096 Nov  5 07:36 .git
-rw-r--r-- 1 root root    120 Nov  5 07:30 .gitattributes
drwxr-xr-x 1 root root   4096 Nov  5 07:30 .github
-rw-r--r-- 1 root root   1329 Nov  5 07:30 .gitignore
-rw-r--r-- 1 root root   1683 Nov  5 07:27 .hadolint.yaml
-rw-r--r-- 1 root root   5743 Nov  5 07:30 .mailmap
22 more lines
drwxr-xr-x 1 root root   4096 Nov  5 07:30 .plans
-rw-r--r-- 1 root root  53040 Nov  5 07:30 AGENTS.md
-rw-r--r-- 1 root root  44350 Nov  5 07:30 CONTRIBUTING.md
-rw-r--r-- 1 root root  12197 Nov  5 07:30 Dockerfile
-rw-r--r-- 1 root root   1070 Nov  5 07:27 LICENSE
-rw-r--r-- 1 root root     87 Nov  5 07:30 M
…[6205 chars omitted]…
./agent/retry_utils.py
./agent/shell_hooks.py
./agent/skill_bundles.py
./agent/skill_commands.py
./agent/skill_preprocessing.py
./agent/skill_utils.py
./agent/stream_diag.py
./agent/subdirectory_hints.py
./agent/system_prompt.py
./agent/think_scrubber.py
./agent/title_generator.py
./agent/tool_dispatch_helpers.py
./agent/tool_executor.py
./agent/tool_guardrails.py
./agent/tool_result_classification.py
step 2
$ cd /repo && grep -rln "bracketed_paste\|_apply_bracketed_paste_timeout_patch\|_prompt_text_input_modal\|_slash_confirm_state" tests hermes_cli 2>/dev/null | head -100
output · 9 lines
tests/cli/test_cli_bracketed_paste_sanitizer.py
tests/cli/test_destructive_slash_confirm.py
tests/cli/test_destructive_slash_inline_skip_e2e.py
tests/cli/test_slash_confirm_windows.py
tests/cli/test_update_command.py
tests/gateway/test_telegram_slash_confirm.py
tests/gateway/test_telegram_thread_fallback.py
tests/hermes_cli/test_setup_prompt_menus.py
tests/tools/test_ansi_strip.py
step 3
$ cd /repo && sed -n '1,400p' tests/cli/test_destructive_slash_confirm.py
output · 44 lines
"""Tests for cli.HermesCLI._confirm_destructive_slash.

Drives the helper directly via __get__ on a SimpleNamespace stand-in so we
don't have to construct a full HermesCLI (which requires extensive setup).
"""

from __future__ import annotations

import queue
from types import SimpleNamespace
from unittest.mock import patch
32 more lines
def _bound(fn, instance):
    """Bind an unbound method to a stand-in instance."""
    return fn.__get__(instance, type(instance))


def _make_self(prompt_response):
    """Build a minimal stand-in 'self' for _confirm_destructive_slash."""
    from cli import HermesCLI

    self_ = SimpleNamespace(
        _app=None,
        _prompt_text_input=lambda _prompt: prompt_response,
        _prompt_text_input_modal=lambda **_kw: prompt_response,
    )
    self_._normalize_slash_confirm_choice = _bound(
        HermesCLI._normalize_slash_confirm_choice, self_,
    )
    
…[8628 chars omitted]…
it_destructive_skip = HermesCLI._split_destructive_skip

    with patch(
        "cli.load_cli_config",
        return_value={"approvals": {"destructive_slash_confirm": True}},
    ):
        result = _bound(HermesCLI._confirm_destructive_slash, self_)(
            "new", "detail", cmd_original="/new My Session",
        )

    # Prompt was reached and returned cancel → None.
    assert result is None
step 4
$ cd /repo && sed -n '7021,7270p' cli.py
output · 36 lines
def _prompt_text_input_modal(
        self,
        *,
        title: str,
        detail: str,
        choices: list[tuple[str, str, str]],
        timeout: float = 120,
    ) -> str | None:
        """Prompt through the prompt_toolkit composer instead of raw input().

        This is for CLI slash-command confirmations.  The old raw input() path
        fought prompt_toolkit's active stdin ownership: in some terminals the
24 more lines
        prompt appeared above the TUI, choices were redrawn later, and Enter
        could be interpreted as EOF/exit.  A first-class modal state keeps the
        choices visible and lets the normal Enter key binding submit the typed
        or highlighted choice.

        **Platform note (Windows dead-lock — issue #30768):**
        The queue-based modal relies on prompt_toolkit key bindings receiving
        keyboard events and calling ``_submit_slash_confir
…[8629 chars omitted]…
     }
        self._invalidate(min_interval=0.0)

    def _close_model_picker(self) -> None:
        self._model_picker_state = None
        self._restore_modal_input_snapshot()
        self._invalidate(min_interval=0.0)

    @staticmethod
    def _compute_model_picker_viewport(
        selected: int,
        scroll_offset: int,
        n: int,
        term_rows: int,
        reserved_below: int = 6,
step 5
$ cd /repo && sed -n '1,500p' tests/cli/test_cli_bracketed_paste_sanitizer.py
output · 32 lines
"""Tests for defensive bracketed-paste wrapper stripping in the CLI."""

from cli import _strip_leaked_bracketed_paste_wrappers


class TestStripLeakedBracketedPasteWrappers:
    def test_plain_text_unchanged(self):
        text = "hello world"
        assert _strip_leaked_bracketed_paste_wrappers(text) == text

    def test_strips_canonical_escape_wrappers(self):
        text = "\x1b[200~hello\x1b[201~"
20 more lines
        assert _strip_leaked_bracketed_paste_wrappers(text) == "hello"

    def test_strips_visible_caret_escape_wrappers(self):
        text = "^[[200~hello^[[201~"
        assert _strip_leaked_bracketed_paste_wrappers(text) == "hello"

    def test_strips_degraded_bracket_only_wrappers(self):
        text = "[200~hello[201~"
        assert _strip_leaked_bracketed_paste_wrappers(text) == "hello"

    def test_strips_degraded_bracket_only_wrappers_after_whitespace(self):
        text 
…[930 chars omitted]…
le_stripping_wrappers(self):
        text = "^[[200~line 1\nline 2\nline 3^[[201~"
        assert _strip_leaked_bracketed_paste_wrappers(text) == "line 1\nline 2\nline 3"

    def test_preserves_multiline_content_while_stripping_degraded_bracket_only_wrappers(self):
        text = "[200~line 1\nline 2\nline 3[201~"
        assert _strip_leaked_bracketed_paste_wrappers(text) == "line 1\nline 2\nline 3"
step 6
$ cd /repo && sed -n '1,300p' tests/cli/test_slash_confirm_windows.py
output · 37 lines
"""Regression tests for issue #30768: /reset and /new freeze on Windows.

``_prompt_text_input_modal`` uses a queue-based modal that relies on
prompt_toolkit key bindings receiving keyboard events.  On Windows the
prompt_toolkit input channel can deadlock when the modal is entered from
the ``process_loop`` daemon thread.  The fix falls back to the simpler
``_prompt_text_input`` (stdin-based) prompt on Windows and non-main threads.

These tests verify:
1. Windows detection triggers the stdin fallback
2. Non-main thread detection triggers the stdin fallback
3. macOS/Linux main-thread path still uses the modal (no regression)
25 more lines
4. No-app path still uses the stdin fallback (existing behavior)
5. Empty choices returns None (existing behavior)
"""

import queue
import sys
import threading
import time
from unittest.mock import MagicMock, patch

import pytest


def _make_cli():
    """Minimal H
…[8628 chars omitted]…
"3"), \
             patch("cli.load_cli_config", return_value={"approvals": {"destructive_slash_confirm": True}}):
            result = cli._confirm_destructive_slash(
                "reset",
                "This starts a fresh session.\nThe current conversation history will be discarded.",
            )

        # Choice "3" normalizes to "cancel", which returns None.
        assert result is None
step 7
$ cd /repo && sed -n '11320,11395p' cli.py && sed -n '12100,12150p' cli.py && sed -n '12400,12450p' cli.py
output · 25 lines
lines = []
        lines.append(('class:approval-border', '╭' + ('─' * box_width) + '╮\n'))
        _append_panel_line(lines, 'class:approval-border', 'class:approval-title', title, box_width)
        if not use_compact_chrome:
            _append_blank_panel_line(lines, 'class:approval-border', box_width)

        for wrapped in cmd_wrapped:
            _append_panel_line(lines, 'class:approval-border', 'class:approval-cmd', wrapped, box_width)
        if not use_compact_chrome:
            _append_blank_panel_line(lines, 'class:approval-border', box_width)

        for i, wrapped in choice_wrapped:
13 more lines
            style = 'class:approval-selected' if i == selected else 'class:approval-choice'
            _append_panel_line(lines, 'class:approval-border', style, wrapped, box_width)

        if desc_wrapped:
            if not use_compact_chrome:
                _append_blank_pane
…[7167 chars omitted]…
S output is enabled
        self._voice_recorder = None     # AudioRecorder instance (lazy init)
        self._voice_recording = False   # Whether currently recording
        self._voice_processing = False  # Whether STT is in progress
        self._voice_continuous = False  # Whether to auto-restart after agent responds
        self._voice_tts_done = threading.Event()  # Signals TTS playback finished
step 8
$ cd /repo && sed -n '1,180p' cli.py
output · 37 lines
#!/usr/bin/env python3
"""
Hermes Agent CLI - Interactive Terminal Interface

A beautiful command-line interface for the Hermes Agent, inspired by Claude Code.
Features ASCII art branding, interactive REPL, toolset selection, and rich formatting.

Usage:
    python cli.py                          # Start interactive mode with all tools
    python cli.py --toolsets web,terminal  # Start with specific toolsets
    python cli.py --skills hermes-agent-dev,github-auth
    python cli.py --list-tools             # List available tools and exit
25 more lines
"""

# IMPORTANT: hermes_bootstrap must be the very first import — UTF-8 stdio
# on Windows.  No-op on POSIX.  See hermes_bootstrap.py for full rationale.
try:
    import hermes_bootstrap  # noqa: F401
except ModuleNotFoundError:
    # Graceful fallback when hermes_bootstrap isn't registered in the venv
    # yet — happens during partial ``hermes updat
…[4890 chars omitted]…
ser_debug_ready,
    manual_chrome_debug_command,
    try_launch_chrome_debug,
)
from hermes_cli.env_loader import load_hermes_dotenv
from utils import base_url_host_matches, is_truthy_value

_hermes_home = get_hermes_home()
_project_env = Path(__file__).parent / '.env'
load_hermes_dotenv(hermes_home=_hermes_home, project_env=_project_env)


_REASONING_TAGS = (
    "REASONING_SCRATCHPAD",
    "think",
step 9
$ cd /repo && grep -n "_apply_\|install_.*\|monkey\|patch" cli.py | head -200
output · 21 lines
59:from prompt_toolkit.patch_stdout import patch_stdout
77:    from hermes_cli.pt_input_extras import install_shift_enter_alias, install_ctrl_enter_alias
78:    install_shift_enter_alias()
79:    install_ctrl_enter_alias()
80:    del install_shift_enter_alias, install_ctrl_enter_alias
711:# We install a sys.meta_path finder that defers the actual import + patch
716:# still guarantees the patch is applied before any AsyncOpenAI instance can
749:            def _patched_exec(module):
758:            spec.loader.exec_module = _patched_exec  # type: ignore[method-assign]
1695:def _install_skin_light_mode_hook() -> None:
1717:_install_skin_light_mode_hook()
1991:    Raw ANSI escapes written via print() are swallowed by patch_stdout's
9 more lines
2531:    """Rich Console adapter for prompt_toolkit's patch_stdout context.
2720:    """Return plugin command names (without slash prefix) for dispatch matchi
…[2463 chars omitted]…
   _pt_renderer._output_screen_diff = _patched_output_screen_diff
14150:                _pt_renderer._hermes_osd_patched = True
14293:                        # Dispatch to a daemon thread so play_beep (sd.wait) and
14493:        # Run the application with patch_stdout for proper output handling
14495:            with patch_stdout():
14504:                    pass  # No running loop -- nothing to patch
step 10
$ cd /repo && sed -n '14440,14540p' cli.py
output · 24 lines
# neuter_async_httpx_del which disables __del__ entirely.  The
        # KeyError fix handles macOS + uv-managed Python environments where
        # fd 0 is not reliably available to the asyncio selector.
        def _suppress_closed_loop_errors(loop, context):
            exc = context.get("exception")
            if isinstance(exc, RuntimeError) and "Event loop is closed" in str(exc):
                return  # silently suppress
            if isinstance(exc, KeyError) and "is not registered" in str(exc):
                return  # suppress selector registration failures (#6393)
            if isinstance(exc, OSError) and getattr(exc, "errno", None) == errno.EIO:
                return  # suppress I/O errors from broken stdout on interrupt (#13710)
            # Fall back to default handler for everything else
12 more lines
            loop.default_exception_handler(context)

        # Vali
…[3828 chars omitted]…
making
            # API calls and exits promptly (agent_thread is daemon, so the
            # process will exit once the main thread finishes, but interrupting
            # avoids wasted API calls and lets run_conversation clean up).
            if self.agent and getattr(self, '_agent_running', False):
                try:
                    self.agent.interrupt()
                except Exception:
step 11
$ cd /repo && python - <<'PY'
output · 41 lines
3.0.52
/usr/local/lib/python3.12/site-packages/prompt_toolkit/input/vt100_parser.py
class Vt100Parser:
    """
    Parser for VT100 input stream.
    Data can be fed through the `feed` method and the given callback will be
    called with KeyPress objects.

    ::

        def callback(key):
            pass
29 more lines
        i = Vt100Parser(callback)
        i.feed('data\x01...')

    :attr feed_key_callback: Function that will be called when a key is parsed.
    """

    # Lookup table of ANSI escape sequences for a VT100 terminal
    # Hint: in order to know what sequences your terminal writes to stdin, run
    #       "od -c" and start typing.
    def __init__(self, feed_key_callback: Callable[[KeyPress], None]) -> None:
        self.feed_key_callback = feed_key_callback
        self.reset()

    def reset(self, request: bool = False) -> None:
        self._in_bracketed_paste = False
      
…[5263 chars omitted]…
 else. This flush function should be called after a
        timeout, and processes everything that's still in the buffer as-is, so
        without assuming any characters will follow.
        """
        self._input_parser.send(_Flush())

    def feed_and_flush(self, data: str) -> None:
        """
        Wrapper around ``feed`` and ``flush``.
        """
        self.feed(data)
        self.flush()
step 12
$ cd /repo && python - <<'PY'
output · 46 lines
['KeyPress', 'Keys']
<class 'prompt_toolkit.key_binding.key_processor.KeyPress'>
"""
Parser for VT100 input stream.
"""

from __future__ import annotations

import re
from typing import Callable, Dict, Generator

from ..key_binding.key_processor import KeyPress
34 more lines
from ..keys import Keys
from .ansi_escape_sequences import ANSI_SEQUENCES

__all__ = [
    "Vt100Parser",
]


# Regex matching any CPR response
# (Note that we use '\Z' instead of '$', because '$' could include a trailing
# newline.)
_cpr_response_re = re.compile("^" + re.escape("\x1b[") + r"\d+;\d+R\Z")

# Mouse events:
# Typical: "Esc[MaB*"  Urxvt: "Esc[96;14;13M" and for Xterm SGR: "Esc[<64;85;12M"
_mouse_event_re = re.compile("^" + re.escape("\x1b[") + r"(<?[\d;]+[mM]|M...)\Z")

# Regex matching any valid prefix of a CPR response.
# (Note that it doesn't contain the last character, the 'R'. The prefix has to
# be shorter.)
_cpr_response_prefix_re = re.compile("^" + re.escape("\x1b[") + r"[\d;]*\Z")

_mouse_event_prefix_re = re.compile("^" + re.escape("\x1b[") + r"(<?[\d;]*|M.{0,2})\Z")


class _Flush:
    """Helper object to indicate flush operation to the parser."""

    pass


class _IsPrefixOfLongerMatchCache(Dict[str, bool]):
    """
    Dictionary that maps input sequences to a boolean indicatin
step 13
$ cd /repo && sed -n '80,180p' tests/cli/test_slash_confirm_windows.py && sed -n '1,120p' tests/cli/test_destructive_slash_confirm.py
output · 38 lines
detail="This starts a fresh session.",
                    choices=_SAMPLE_CHOICES,
                )
                result_holder["stdin_called"] = mock_stdin.called

        t = threading.Thread(target=run_on_daemon, daemon=True)
        t.start()
        t.join(timeout=2.0)
        assert not t.is_alive(), "daemon thread hung — modal deadlocked"
        assert result_holder["stdin_called"] is True
        assert result_holder["result"] == "2"
26 more lines
    def test_main_thread_non_windows_uses_modal(self):
        """On macOS/Linux main thread, the queue-based modal is still used."""
        cli = _make_cli()

        # We need to simulate the modal receiving a response. We'll patch
        # the response_queue to immediately return a value.
        with patch.object(sys, "platform", "darwin"), \
             patch.object(cli, "_capture_modal_input_snapshot"), \
       
…[6263 chars omitted]…
from cli import HermesCLI

    self_ = _make_self(prompt_response="maybe")

    with patch(
        "cli.load_cli_config",
        return_value={"approvals": {"destructive_slash_confirm": True}},
    ):
        result = _bound(HermesCLI._confirm_destructive_slash, self_)(
            "clear", "detail",
        )

    assert result is None


def test_gate_on_choice_always_persists_and_returns_always():
step 14
$ cd /repo && grep -n "def _invalidate\|def _capture_modal_input_snapshot\|def _restore_modal_input_snapshot\|_slash_confirm_deadline" cli.py | head -60 && sed -n '11335,11390p' cli.py && sed -n '12470,12520p' cli.py
output · 27 lines
3149:        self._slash_confirm_deadline = 0
3191:    def _invalidate(self, min_interval: float = 0.25) -> None:
7092:        self._slash_confirm_deadline = _time.monotonic() + timeout
7101:                    self._slash_confirm_deadline = 0
7106:                    remaining = self._slash_confirm_deadline - _time.monotonic()
7116:                self._slash_confirm_deadline = 0
7127:        self._slash_confirm_deadline = 0
11347:    def _capture_modal_input_snapshot(self) -> None:
11361:    def _restore_modal_input_snapshot(self) -> None:
12428:        self._slash_confirm_deadline = 0
13512:                remaining = max(0, int(cli_ref._slash_confirm_deadline - time.monotonic()))
        if desc_wrapped:
15 more lines
            if not use_compact_chrome:
                _append_blank_panel_line(lines, 'class:approval-border', box_width)
            for wrapped in desc_wrapped:
               
…[3965 chars omitted]…
 picker modal ---
            if self._model_picker_state:
                try:
                    self._handle_model_picker_selection()
                except Exception as _exc:
                    _cprint(f"  ✗ Model selection failed: {_exc}")
                    self._close_model_picker()
                event.app.current_buffer.reset()
                event.app.invalidate()
                return
step 15
$ cd /repo && sed -n '3170,3225p' cli.py && sed -n '12395,12440p' cli.py && sed -n '13000,13040p' cli.py
output · 30 lines
self._voice_processing = False
        self._voice_continuous = False
        self._voice_tts_done = threading.Event()
        self._voice_tts_done.set()

        # Status bar visibility (toggled via /statusbar)
        self._status_bar_visible = True
        # When True, the input separator rules and the dynamic status bar are
        # hidden until the next user input. Set by _recover_after_resize() so a
        # SIGWINCH cannot stamp a freshly-drawn status bar on top of one that
        # the terminal just reflowed into scrollback — the cause of duplicated
        # bars / "blank line flooding" reports (#19280, #22976).
18 more lines
        self._status_bar_suppressed_after_resize = False
        self._resize_recovery_lock = threading.Lock()
        self._resize_recovery_timer = None
        self._resize_recovery_pending = False

        # Background task tracking: {task_id: threading.
…[5173 chars omitted]…
p.current_buffer.reset()
                event.app.invalidate()
                return

            # Cancel clarify prompt
            if self._clarify_state:
                self._clarify_state["response_queue"].put(
                    "The user cancelled. Use your best judgement to proceed."
                )
                self._clarify_state = None
                self._clarify_freetext = False
step 16
$ cd /repo && python - <<'PY'
output · 15 lines
False
170         self.loop: AbstractEventLoop | None = None
350         if self.loop is None or self.loop.is_closed():
362         self.loop.call_soon_threadsafe(self.on_invalidate.fire)
370                 redraw, max_postpone_time=self.max_render_postpone_time, loop=self.loop
383                 self.loop.call_soon_threadsafe(
685             self.loop = loop
691                 self.loop = None
703         def set_handle_sigint(loop: AbstractEventLoop) -> Iterator[None]:
722         def set_exception_handler_ctx(loop: AbstractEventLoop) -> Iterator[None]:
735         def set_callback_duration(loop: AbstractEventLoop) -> Iterator[None]:
747             loop: AbstractEventLoop,
3 more lines
900         self, loop: AbstractEventLoop, context: dict[str, Any]
917                 print("\nUnhandled exception in event loop:")
1046         loop = self.loop or get_running_loop()
step 17
$ cd /repo && grep -n "def _disable_prompt_toolkit_cpr_warning\|_disable_prompt_toolkit_cpr_warning" cli.py && sed -n '2350,2400p' cli.py && sed -n '4050,4180p' cli.py
output · 31 lines
2443:def _disable_prompt_toolkit_cpr_warning(app) -> None:
14086:        _disable_prompt_toolkit_cpr_warning(app)
    text = (
        text.replace("\x1b[200~", "")
        .replace("\x1b[201~", "")
        .replace("^[[200~", "")
        .replace("^[[201~", "")
    )
    text = re.sub(r"(^|[\s\n>:\]\)])\[200~", r"\1", text)
    text = re.sub(r"\[201~(?=$|[\s\n<\[\(\):;.,!?])", "", text)
    text = re.sub(r"(^|[\s\n>:\]\)])00~", r"\1", text)
    text = re.sub(r"01~(?=$|[\s\n<\[\(\):;.,!?])", "", text)
19 more lines
    return text


# Cursor Position Report (CPR / DSR) response, format ``ESC[<row>;<col>R``.
# prompt_toolkit's _on_resize() + renderer send ``ESC[6n`` queries to the
# terminal; under resize storms or tab switches the terminal's reply can
# race past the input parser and end up in the input buffer as literal
# text (see issue #14692). Also matches the visible-form ``^[[<row>;<col>R``
#
…[7393 chars omitted]…
e whenever emitted text ends with '\n'.
        if not hasattr(self, "_stream_last_was_newline"):
            self._stream_last_was_newline = True  # start of stream = boundary

        if not getattr(self, "_in_reasoning_block", False):
            for tag in _OPEN_TAGS:
                search_start = 0
                while True:
                    idx = self._stream_prefilt.find(tag, search_start)
step 18
$ cd /repo && sed -n '2435,2475p' cli.py
output · 37 lines
Enter-involving newline keystroke without terminal settings changes.
    See _preserve_ctrl_enter_newline() and issue #22379.
    """
    kb.add("enter")(handler)
    if sys.platform != "win32" and not _preserve_ctrl_enter_newline():
        kb.add("c-j")(handler)


def _disable_prompt_toolkit_cpr_warning(app) -> None:
    """Let prompt_toolkit fall back from CPR without printing into the prompt."""
    try:
        app.renderer.cpr_not_supported_callback = None
25 more lines
    except Exception:
        pass


def _strip_leaked_terminal_responses_with_meta(text: str) -> tuple[str, bool]:
    """Strip leaked terminal control-response sequences from user input.

    Covers Cursor Position Report (CPR / DSR) responses — ``ESC[<row>;<col>R``
    and the visible ``^[[<row>;<col>R`` form. These are replies the terminal
    sends back to queries prompt_toolkit makes during ``_on_resize`` /
    ``_re
…[347 chars omitted]…
had_mouse_reports)``
    so callers can trigger an in-place terminal mode recovery when needed.
    """
    if not text:
        return text, False

    has_esc = "\x1b[" in text
    has_visible = "^[" in text
    has_bare_mouse = "<" in text and ";" in text and ("M" in text or "m" in text)
    if not (has_esc or has_visible or has_bare_mouse):
        return text, False

    had_mouse_reports = False
step 19
$ cd /repo && sed -n '330,405p' ui-tui/packages/hermes-ink/src/ink/components/App.tsx && grep -rn "BracketedPaste" tests | head -50
output · 31 lines
flushIncomplete = (): void => {
    // Clear the timer reference
    this.incompleteEscapeTimer = null

    // Only proceed if we have an incomplete escape sequence or an unterminated
    // bracketed paste. Missing paste-end markers otherwise leave every later
    // keystroke trapped in the paste buffer.
    if (!this.keyParseState.incomplete && this.keyParseState.mode !== 'IN_PASTE') {
      return
    }

    // Fullscreen: if stdin has data waiting, it's almost certainly the
19 more lines
    // continuation of the buffered sequence (e.g. `[<64;74;16M` after a
    // lone ESC). Node's event loop runs the timers phase before the poll
    // phase, so when a heavy render blocks the loop past 50ms, this timer
    // fires before the queued readable event even though the bytes are
    // already buffered. Re-arm instead of flushing: handleReadable will
    // drain stdin next and clear this timer
…[1978 chars omitted]…
set DEC private modes; re-assert mouse
    // tracking. Checked before the read loop so one Date.now() covers
    // all chunks in this readable event.
    const now = Date.now()

    if (now - this.lastStdinTime > STDIN_RESUME_GAP_MS) {
      this.props.onStdinResume?.()
    }

    this.lastStdinTime = now

tests/cli/test_cli_bracketed_paste_sanitizer.py:6:class TestStripLeakedBracketedPasteWrappers:
step 20
$ cd /repo && python - <<'PY'
output · 34 lines
def invalidate(self) -> None:
        """
        Thread safe way of sending a repaint trigger to the input event loop.
        """
        if not self._is_running:
            # Don't schedule a redraw if we're not running.
            # Otherwise, `get_running_loop()` in `call_soon_threadsafe` can fail.
            # See: https://github.com/dbcli/mycli/issues/797
            return

        # `invalidate()` called if we don't have a loop yet (not running?), or
        # after the event loop was closed.
22 more lines
        if self.loop is None or self.loop.is_closed():
            return

        # Never schedule a second redraw, when a previous one has not yet been
        # executed. (This should protect against other threads calling
        # 'invalidate' many times, resulting in 100% CPU.)
        if self._invalidated:
            return
        else:
            self._invalidated = True
…[628 chars omitted]…
           async def redraw_in_future() -> None:
                    await sleep(cast(float, self.min_redraw_interval) - diff)
                    schedule_redraw()

                self.loop.call_soon_threadsafe(
                    lambda: self.create_background_task(redraw_in_future())
                )
            else:
                schedule_redraw()
        else:
            schedule_redraw()
step 21
$ cd /repo && grep -n "def _confirm_destructive_slash" cli.py && sed -n '9740,9825p' cli.py
output · 35 lines
10056:    def _confirm_destructive_slash(
                icon = "🗜️" if summary["noop"] else "✅"
                print(f"  {icon} {summary['headline']}")
                print(f"     {summary['token_line']}")
                if summary["note"]:
                    print(f"     {summary['note']}")

            except Exception as e:
                print(f"  ❌ Compression failed: {e}")

    def _handle_debug_command(self):
        """Handle /debug — upload debug report + logs and print paste URLs."""
23 more lines
        from hermes_cli.debug import run_debug_share
        from types import SimpleNamespace

        args = SimpleNamespace(lines=200, expire=7, local=False)
        run_debug_share(args)

    def _handle_update_command(self) -> bool:
        """Handle /update — update Hermes Agent to the latest version.

        In the classic CLI this exits the session and relaunches as
        ``her
…[2276 chars omitted]…
gent = self.agent
        calls = agent.session_api_calls

        if calls == 0:
            print("(._.) No API calls made yet in this session.")
            return

        # ── Rate limits (shown first when available) ────────────────
        rl_state = agent.get_rate_limit_state()
        if rl_state and rl_state.has_data:
            from agent.rate_limit_tracker import format_rate_limit_display
step 22
$ cd /repo && sed -n '10040,10140p' cli.py
output · 37 lines
return "", False
        tokens = cmd_text.strip().split()
        if not tokens:
            return "", False
        # Drop leading "/cmd" word — callers pass the full command text.
        if tokens[0].startswith("/"):
            tokens = tokens[1:]
        skip = False
        kept: list[str] = []
        for tok in tokens:
            if tok.lower() in cls._DESTRUCTIVE_SKIP_TOKENS:
                skip = True
25 more lines
                continue
            kept.append(tok)
        return " ".join(kept), skip

    def _confirm_destructive_slash(
        self,
        command: str,
        detail: str,
        cmd_original: Optional[str] = None,
    ) -> Optional[str]:
        """Prompt the user to confirm a destructive session slash command.

        Used by ``/clear``, ``/new``/``/reset``, and ``/undo`` before they
        discard conversation state.  Three-option prompt:

    
…[2931 chars omitted]…
= "always":
            if save_config_value("approvals.destructive_slash_confirm", False):
                print("🔒 Future /clear, /new, /reset, and /undo will run without confirmation.")
                print("   Re-enable via `approvals.destructive_slash_confirm: true` in config.yaml.")
            else:
                print("⚠️  Couldn't persist opt-out — proceeding once.")

        return choice
step 23
$ cd /repo && python - <<'PY'
output · 3 lines
inserted
True
<function _apply_bracketed_paste_timeout_patch.<locals>._feed_with_bracketed_paste_timeout at 0x7b06d121d6c0>
step 24
$ cd /repo && python - <<'PY'
output · 6 lines
after split True 'line1\nline2\x1b[20' True []
after complete False '' [KeyPress(key=<Keys.BracketedPaste: '<bracketed-paste>'>, data='line1\nline2')]
after timeout False [KeyPress(key=<Keys.BracketedPaste: '<bracketed-paste>'>, data='hello'), KeyPress(key='x', data='x')]
after timeout 2 False [KeyPress(key=<Keys.BracketedPaste: '<bracketed-paste>'>, data='helloworld'), KeyPress(key='!', data='!')]
[KeyPress(key=<Keys.BracketedPaste: '<bracketed-paste>'>, data='abc')]
OK
step 25
$ cd /repo && python - <<'PY'
output · 9 lines
def _apply_bracketed_paste_timeout_patch() -> None:
    """Recover from a bracketed paste whose end marker never arrives
flag before True
flag after True
<function Vt100Parser.feed at 0x7c23a798b240>
True
Traceback (most recent call last):
  File "<stdin>", line 20, in <module>
AssertionError
step 26
$ cd /repo && python - <<'PY'
output · 7 lines
flag before False
flag after False
<function Vt100Parser.feed at 0x74179e68b240>
idempotent True
Traceback (most recent call last):
  File "<stdin>", line 20, in <module>
AssertionError
step 27
$ cd /repo && python - <<'PY'
output · 5 lines
before class <class 'prompt_toolkit.input.vt100_parser.Vt100Parser'> <function _apply_bracketed_paste_timeout_patch.<locals>._feed_with_bracketed_paste_timeout at 0x784590c64180> True True
reloaded class <class 'prompt_toolkit.input.vt100_parser.Vt100Parser'> <function Vt100Parser.feed at 0x784590b5f2e0> False True
cleared None <function Vt100Parser.feed at 0x784590b5f2e0>
after exec None <function Vt100Parser.feed at 0x784590b5f2e0> False
source function globals? <function _apply_bracketed_paste_timeout_patch at 0x784590b5ede0>
step 28
$ cd /repo && python - <<'PY'
output · 6 lines
sys module same True
getattr via import
True None <function Vt100Parser.feed at 0x76fa3240f240>
before helper feed <function Vt100Parser.feed at 0x76fa3240f240>
after None <function Vt100Parser.feed at 0x76fa3240f240>
module attrs matching []
step 29
$ cd /repo && python - <<'PY'
output · 26 lines
def _apply_bracketed_paste_timeout_patch() -> None:
    """Recover from a bracketed paste whose end marker never arrives.

    prompt_toolkit's ``Vt100Parser`` buffers everything after ``ESC[200~``
    until it sees ``ESC[201~``.  If that end marker is lost (SSH interruption,
    terminal race, sleep/wake, or a torn write), every later keystroke is
    treated as paste content and the prompt appears permanently frozen.

    This installs a small wrapper around ``Vt100Parser.feed``.  When a feed
    arrives more than two seconds after paste mode was entered and no end
    marker has completed the paste, the text already buffered is delivered as
    a normal ``BracketedPaste`` key press, paste mode is left, and the newly
14 more lines
    arrived input is parsed through the ordinary path.

    The helper is deliberately self-contained.  The regression tests extract
    this function's source and exec
…[2480 chars omitted]…
t extend the deadline.
            if not was_in_paste or getattr(self, "_hermes_bp_start", None) is None:
                self._hermes_bp_start = time.monotonic()
        else:
            self._hermes_bp_start = None

    _feed_with_bracketed_paste_timeout._hermes_bp_timeout_wrapper = True
    _Vt100Parser.feed = _feed_with_bracketed_paste_timeout
    _vt100_parser._hermes_bp_timeout_patched = True
step 30
$ cd /repo && python - <<'PY'
output · 5 lines
flag after True
<function _apply_bracketed_paste_timeout_patch.<locals>._feed_with_bracketed_paste_timeout at 0x70fea9152c00>
idempotent True
[KeyPress(key=<Keys.BracketedPaste: '<bracketed-paste>'>, data='abc'), KeyPress(key='z', data='z')]
OK standalone
step 31
$ cd /repo && python - <<'PY'
output · 2 lines
replaced 4535 -> 7018
compiled
step 32
$ cd /repo && python -m pytest -q tests/cli/test_slash_confirm_windows.py tests/cli/test_destructive_slash_confirm.py tests/cli/test_destructive_slash_inline_skip_e2e.py tests/cli/test_cli_bracketed_paste_sanitizer.py -p no:cacheprovider --disable-warnings --maxfail=5
output · 2 lines
.......................................                                  [100%]
39 passed in 3.50s
step 33
$ cd /repo && python - <<'PY'
output · 4 lines
alive False result {'v': 'once'} state None
calls 2
timeout result None 1.0005364950047806
OK modal
step 34
$ cd /repo && python - <<'PY'
output · 3 lines
updated waits
.........                                                                [100%]
9 passed in 2.68s
step 35
$ cd /repo && python - <<'PY'
output · 3 lines
{'v': 'once'} None 1 1 2
timeout None 0.20065205602440983
OK
step 36
$ cd /repo && ls tests/cli | wc -l && python -m pytest -q tests/cli -p no:cacheprovider --disable-warnings --maxfail=5
output · 20 lines
69
........................................................................ [  9%]
........................................................................ [ 18%]
........................................................................ [ 27%]
................................................F....................... [ 36%]
........................................................................ [ 45%]
........................................................................ [ 54%]
........................................................................ [ 63%]
.........................FFFF
=================================== FAILURES ===================================
____________________ test_show_banner_does_not_print_skills ____________________
8 more lines
    def test_show_banner_does_not_print_skills():
        """show_banner() no longer prints the activated skills line — it moved to run()."""
…[8679 chars omitted]…
:TestGatewayPersonalityNone::test_default_clears_ephemeral_prompt
FAILED tests/cli/test_personality_none.py::TestGatewayPersonalityNone::test_list_includes_none
FAILED tests/cli/test_personality_none.py::TestGatewayPersonalityNone::test_unknown_shows_none_in_available
!!!!!!!!!!!!!!!!!!!!!!!!!! stopping after 5 failures !!!!!!!!!!!!!!!!!!!!!!!!!!!
5 failed, 528 passed, 11 warnings in 228.76s (0:03:48)
step 37
$ cd /repo && git diff --stat && git diff -- cli.py | sed -n '1,260p'
output · 30 lines
cli.py | 219 +++++++++++++++++++++++++++++++++++++++++++++++++++++++----------
 1 file changed, 185 insertions(+), 34 deletions(-)
diff --git a/cli.py b/cli.py
index ce4f8eb..2e2a76a 100644
--- a/cli.py
+++ b/cli.py
@@ -2448,6 +2448,94 @@ def _disable_prompt_toolkit_cpr_warning(app) -> None:
         pass
 
 
+def _apply_bracketed_paste_timeout_patch() -> None:
+    """Recover from a bracketed paste whose end marker never arrives.
18 more lines
+
+    prompt_toolkit's ``Vt100Parser`` buffers everything after ``ESC[200~``
+    until it sees ``ESC[201~``.  If that end marker is lost (SSH interruption,
+    terminal race, sleep/wake, or a torn write), every later keystroke is
+    treated as paste content and the prompt appears permanently frozen.
+
+    This installs a small wrapper around ``Vt100Parser.feed``.  When a feed
+    arrives more than two seconds after paste mode was entered and no end
+
…[8629 chars omitted]…
slash_confirm_deadline = 0
-                self._restore_modal_input_snapshot()
-                self._invalidate()
-        return None
+            # Clear state from the requesting thread too, so callers see a
+            # clean slate immediately even if the event-loop callback is
+            # briefly delayed.  Restore the draft on the loop thread.
+            self._slash_confirm_state = None
step 38
$ cd /repo && python - <<'PY'
output · 5 lines
True '' 788817.16913667 788817.169138554
[] 'abc\ndef'
empty timeout [KeyPress(key=<Keys.BracketedPaste: '<bracketed-paste>'>, data=''), KeyPress(key='k', data='k')]
[KeyPress(key=<Keys.BracketedPaste: '<bracketed-paste>'>, data='a\nb\nc'), KeyPress(key='d', data='d')]
ok
step 39
$ cd /repo && python - <<'PY'
output · 4 lines
guard updated
stale flag True feed wrapped? False
after True True
ok
step 40
$ cd /repo && grep -rln "Vt100Parser\|prompt_toolkit.input" tests | head -50
output · 9 lines
tests/cli/test_cli_shift_enter_newline.py
tests/cli/test_ctrl_enter_newline.py
tests/cli/test_slash_command_interrupt.py
tests/cli/test_slash_confirm_windows.py
tests/cli/__pycache__/test_cli_shift_enter_newline.cpython-312-pytest-9.2.0.pyc
tests/cli/__pycache__/test_ctrl_enter_newline.cpython-312-pytest-9.2.0.pyc
tests/cli/__pycache__/test_slash_command_interrupt.cpython-312-pytest-9.2.0.pyc
tests/cli/__pycache__/test_slash_confirm_windows.cpython-312-pytest-9.2.0.pyc
tests/hermes_cli/test_voice_wrapper.py
step 41
$ cd /repo && python -m pytest -q tests/cli/test_cli_shift_enter_newline.py tests/cli/test_ctrl_enter_newline.py tests/cli/test_slash_command_interrupt.py tests/hermes_cli/test_voice_wrapper.py -p no:cacheprovider --disable-warnings --maxfail=5
output · 2 lines
...............................................................          [100%]
63 passed in 3.89s
step 42
$ cd /repo && git diff -- cli.py | sed -n '1,360p'
output · 31 lines
diff --git a/cli.py b/cli.py
index ce4f8eb..c2ee2b2 100644
--- a/cli.py
+++ b/cli.py
@@ -2448,6 +2448,95 @@ def _disable_prompt_toolkit_cpr_warning(app) -> None:
         pass
 
 
+def _apply_bracketed_paste_timeout_patch() -> None:
+    """Recover from a bracketed paste whose end marker never arrives.
+
+    prompt_toolkit's ``Vt100Parser`` buffers everything after ``ESC[200~``
19 more lines
+    until it sees ``ESC[201~``.  If that end marker is lost (SSH interruption,
+    terminal race, sleep/wake, or a torn write), every later keystroke is
+    treated as paste content and the prompt appears permanently frozen.
+
+    This installs a small wrapper around ``Vt100Parser.feed``.  When a feed
+    arrives more than two seconds after paste mode was entered and no end
+    marker has completed the paste, the text already buffered is delivered as
+    a normal ``BracketedPaste`` key press, paste mode
…[8629 chars omitted]…
briefly delayed.  Restore the draft on the loop thread.
+            self._slash_confirm_state = None
+            self._slash_confirm_deadline = 0
+            try:
+                loop.call_soon_threadsafe(_close_modal)
+            except Exception:
+                _close_modal()
 
     def _submit_slash_confirm_response(self, value: str | None) -> None:
         state = self._slash_confirm_state
step 43
$ cd /repo && sed -n '7090,7300p' cli.py
output · 45 lines
was_visible = self._status_bar_visible
            self._status_bar_visible = False
            self._app.invalidate()
            try:
                run_in_terminal(_ask)
            except Exception:
                # WSL / Warp / certain terminal emulators silently drop the
                # scheduled coroutine.  Fall back to a direct input() so the
                # user's keystrokes don't leak into the agent buffer.
                try:
                    _ask()
                except Exception:
33 more lines
                    pass
            finally:
                self._status_bar_visible = was_visible
                self._app.invalidate()
        else:
            _ask()
        return result[0]

    def _prompt_text_input_modal(
        self,
        *,
        title: str,
        detail: str,
        choices: list[tuple[str, str, str]],
        timeout: float = 120,
  
…[7284 chars omitted]…
],
    ) -> str | None:
        if raw is None:
            return None
        choice_raw = raw.strip().lower()
        if not choice_raw:
            return None
        aliases = {
            "1": "once",
            "once": "once",
            "approve": "once",
            "yes": "once",
            "y": "once",
            "ok": "once",
            "2": "always",
            "always": "always",
step 44
$ cd /repo && python - <<'PY'
output · 1 lines
standalone comprehensive OK
step 45
$ cd /repo && python - <<'PY'
output · 3 lines
Traceback (most recent call last):
  File "<stdin>", line 39, in <module>
AttributeError: 'function' object has no attribute 'call_count'
step 46
$ cd /repo && python - <<'PY'
output · 3 lines
once ok
cancel ok
modal hidden-like OK
step 47
$ cd /repo && python - <<'PY'
output · 3 lines
cleaned
.......................................                                  [100%]
39 passed in 3.37s
step 48
$ cd /repo && python - <<'PY'
output · 3 lines
state? True
alive False result {'r': 'once'} state None cap 1 res 1
real loop OK
step 49
$ cd /repo && git status --short && python -m pytest -q tests/cli/test_slash_confirm_windows.py tests/cli/test_destructive_slash_confirm.py tests/cli/test_destructive_slash_inline_skip_e2e.py tests/cli/test_cli_bracketed_paste_sanitizer.py tests/cli/test_cli_shift_enter_newline.py tests/cli/test_ctrl_enter_newline.py tests/cli/test_slash_command_interrupt.py -p no:cacheprovider --disable-warnings
output · 3 lines
M cli.py
..........................................................               [100%]
58 passed in 4.21s
step 50
$ cd /repo && python - <<'PY'
output · 2 lines
[KeyPress(key=<Keys.BracketedPaste: '<bracketed-paste>'>, data='a'), KeyPress(key=<Keys.BracketedPaste: '<bracketed-paste>'>, data='b')] False None
multi ok
step 51
$ cd /repo && grep -n "_apply_bracketed_paste_timeout_patch\|_hermes_bp_timeout_patched\|_hermes_bp_start" cli.py
output · 11 lines
2451:def _apply_bracketed_paste_timeout_patch() -> None:
2480:        _vt100_parser._hermes_bp_timeout_patched = True
2491:            started = getattr(self, "_hermes_bp_start", None)
2500:                self._hermes_bp_start = None
2512:                    and getattr(self, "_hermes_bp_start", None) is None
2514:                    self._hermes_bp_start = time.monotonic()
2523:            if not was_in_paste or getattr(self, "_hermes_bp_start", None) is None:
2524:                self._hermes_bp_start = time.monotonic()
2526:            self._hermes_bp_start = None
2530:    _vt100_parser._hermes_bp_timeout_patched = True
2535:_apply_bracketed_paste_timeout_patch()
step 52
$ cd /repo && python - <<'PY'
output · 3 lines
restart handling added
after second start [KeyPress(key=<Keys.BracketedPaste: '<bracketed-paste>'>, data='a')] True 'b' 789359.289302423 789359.289284998
restart ok
step 53
$ cd /repo && python -m pytest -q tests/cli/test_slash_confirm_windows.py tests/cli/test_destructive_slash_confirm.py tests/cli/test_destructive_slash_inline_skip_e2e.py tests/cli/test_cli_bracketed_paste_sanitizer.py tests/cli/test_cli_shift_enter_newline.py tests/cli/test_ctrl_enter_newline.py tests/cli/test_slash_command_interrupt.py -p no:cacheprovider --disable-warnings
output · 2 lines
..........................................................               [100%]
58 passed in 4.32s
step 54
$ cd /repo && python - <<'PY'
output · 1 lines
standalone all OK
step 55
$ cd /repo && git diff --check
step 56
$ cd /repo && git diff --stat && git diff --check && python - <<'PY'
output · 3 lines
cli.py | 227 +++++++++++++++++++++++++++++++++++++++++++++++++++++++----------
 1 file changed, 193 insertions(+), 34 deletions(-)
final smoke OK
step 57
$ done: {"notes": "Implemented both CLI input fixes in cli.py:\n\n1. Added a self-contained, idempotent top-level `_apply_bracketed_paste_timeout_patch()` that wraps prompt_toolkit's `Vt100Parser.feed`. It records `_hermes_bp_start` on paste entry, buffers within the 2s window, completes split end markers normally, and on a late feed flush
output · 3 lines
…[361 chars omitted]…
ear `_slash_confirm_state`. Windows and no-app fallbacks remain intact.\n\nValidated with standalone/extracted-helper simulations and affected CLI tests (58 passed)."}
submitted.
GPT-6 Luna · max · attempt 1 · solved · 46 steps · $0.141
Submitted diff · 303 lines
diff --git a/cli.py b/cli.py
index ce4f8eb..0ce9281 100644
--- a/cli.py
+++ b/cli.py
@@ -46,6 +46,89 @@ from typing import List, Dict, Any, Optional
 
 logger = logging.getLogger(__name__)
 
+
+def _apply_bracketed_paste_timeout_patch():
+    """Recover prompt_toolkit input after an incomplete bracketed paste.
+
+    prompt_toolkit buffers all input after ``ESC[200~`` until it sees
+    ``ESC[201~``.  If the end marker is lost, that normally traps every
+    subsequent keystroke in the paste buffer.  Give the marker two seconds
+    to arrive, then submit the buffered text as a paste and resume parsing
+    the current input normally.
+
+    Keep all imports local: this helper is also used by small standalone
+    compatibility checks which execute its source independently of cli.py.
+    """
+    import time as _time
+    import prompt_toolkit.input.vt100_parser as _vt100_parser
+    from prompt_toolkit.key_binding.key_processor import KeyPress as _KeyPress
+    from prompt_toolkit.keys import Keys as _Keys
+
+    if getattr(_vt100_parser, "_hermes_bp_timeout_patched", False):
+        return
+
+    try:
+        _original_feed = _vt100_parser.Vt100Parser.feed
+
+        def _patched_feed(self, data):
+            end_mark = "\x1b[201~"
+            now = _time.monotonic()
+
+            if getattr(self, "_in_bracketed_paste", False):
+                start = getattr(self, "_hermes_bp_start", None)
+                if start is None:
+                    start = now
+                    self._hermes_bp_start = start
+
+                paste_buffer = getattr(self, "_paste_buffer", "")
+                # If this feed completes the end marker, let prompt_toolkit
+                # handle the whole paste as usual, even if the connection was
+                # slow.  Otherwise, an expired paste is delivered separately
+                # and this feed is parsed as ordinary input below.  Looking at
+                # both pieces also handles an end marker split across feeds.
+                if now - start > 2.0 and end_mark not in paste_buffer + data:
+                    self._in_bracketed_paste = False
+                    self._paste_buffer = ""
+                    try:
+                        del self._hermes_bp_start
+                    except AttributeError:
+                        pass
+                    self.feed_key_callback(
+                        _KeyPress(_Keys.BracketedPaste, paste_buffer)
+                    )
+            else:
+                # reset() in prompt_toolkit clears paste mode but does not
+                # know about our timestamp.  Do not let an old timestamp make
+                # the next paste expire immediately.
+                try:
+                    del self._hermes_bp_start
+                except AttributeError:
+                    pass
+
+            result = _original_feed(self, data)
+
+            if getattr(self, "_in_bracketed_paste", False):
+                if getattr(self, "_hermes_bp_start", None) is None:
+                    self._hermes_bp_start = _time.monotonic()
+            else:
+                try:
+                    del self._hermes_bp_start
+                except AttributeError:
+                    pass
+
+            return result
+
+        _vt100_parser.Vt100Parser.feed = _patched_feed
+        _vt100_parser._hermes_bp_timeout_patched = True
+    except Exception:
+        # The CLI should remain importable if a future prompt_toolkit release
+        # changes its parser internals.  The compatibility failure is useful
+        # in logs, but must not prevent the interactive CLI from starting.
+        logger.debug("Could not install bracketed-paste recovery patch", exc_info=True)
+
+
+_apply_bracketed_paste_timeout_patch()
+
 # Suppress startup messages for clean CLI experience
 os.environ["HERMES_QUIET"] = "1"  # Our own modules
 
@@ -7028,32 +7111,12 @@ class HermesCLI:
     ) -> str | None:
         """Prompt through the prompt_toolkit composer instead of raw input().
 
-        This is for CLI slash-command confirmations.  The old raw input() path
-        fought prompt_toolkit's active stdin ownership: in some terminals the
-        prompt appeared above the TUI, choices were redrawn later, and Enter
-        could be interpreted as EOF/exit.  A first-class modal state keeps the
-        choices visible and lets the normal Enter key binding submit the typed
-        or highlighted choice.
-
-        **Platform note (Windows dead-lock — issue #30768):**
-        The queue-based modal relies on prompt_toolkit key bindings receiving
-        keyboard events and calling ``_submit_slash_confirm_response``.  On
-        Windows (PowerShell / Windows Terminal) the prompt_toolkit input
-        channel can become unresponsive when the modal is entered from the
-        ``process_loop`` daemon thread, causing a dead-lock: the user sees the
-        confirmation panel but keystrokes never reach the key bindings and the
-        ``response_queue.get()`` blocks until the 120-second timeout expires.
-
-        To avoid this, we fall back to ``_prompt_text_input`` (a simple
-        ``input()``-based prompt) when any of these conditions hold:
-
-        * ``sys.platform == "win32"`` — native Windows console (ConPTY /
-          win32_input) does not support the modal reliably.
-        * Called from a non-main thread — the prompt_toolkit event loop only
-          runs on the main thread; key bindings can't fire from a daemon
-          thread (same rationale as the ``_prompt_text_input`` thread guard
-          in PR #23454).
-        * ``self._app`` is not set — unit tests / non-interactive contexts.
+        Slash commands are dispatched by a worker thread while prompt_toolkit
+        owns the terminal.  On POSIX, schedule modal setup and cleanup on the
+        running application's event loop; the worker waits only for the
+        response queue and never attempts to read stdin itself.  Native
+        Windows keep
… [7745 more characters]
step 1
$ pwd && ls -la && git status --short && find . -maxdepth 2 -type f | sed 's#^./##' | sort | head -200
output · 36 lines
/repo
total 2916
drwxr-xr-x 1 root root   4096 Nov  2 11:27 .
drwxr-xr-x 1 root root   4096 Nov  2 11:26 ..
-rw-r--r-- 1 root root    609 Nov  2 11:22 .dockerignore
-rw-r--r-- 1 root root  23061 Nov  2 11:22 .env.example
-rw-r--r-- 1 root root    193 Nov  2 11:22 .envrc
drwxr-xr-x 1 root root   4096 Nov  2 11:26 .git
-rw-r--r-- 1 root root    120 Nov  2 11:22 .gitattributes
drwxr-xr-x 1 root root   4096 Nov  2 11:22 .github
-rw-r--r-- 1 root root   1329 Nov  2 11:22 .gitignore
-rw-r--r-- 1 root root   1683 Nov  2 11:20 .hadolint.yaml
24 more lines
-rw-r--r-- 1 root root   5743 Nov  2 11:22 .mailmap
drwxr-xr-x 1 root root   4096 Nov  2 11:22 .plans
-rw-r--r-- 1 root root  53040 Nov  2 11:22 AGENTS.md
-rw-r--r-- 1 root root  44350 Nov  2 11:22 CONTRIBUTING.md
-rw-r--r-- 1 root root  12197 Nov  2 11:22 Dockerfile
-rw-r--r-- 1 root root   1070 Nov  2 11:20 LICENSE
-rw-r--r-- 1 root root     87 Nov  2 1
…[8335 chars omitted]…
s_compat.py
hermes_cli/auth.py
hermes_cli/auth_commands.py
hermes_cli/azure_detect.py
hermes_cli/backup.py
hermes_cli/banner.py
hermes_cli/browser_connect.py
hermes_cli/bundles.py
hermes_cli/callbacks.py
hermes_cli/checkpoints.py
hermes_cli/claw.py
hermes_cli/cli_output.py
hermes_cli/clipboard.py
hermes_cli/codex_models.py
hermes_cli/codex_runtime_plugin_migration.py
hermes_cli/codex_runtime_switch.py
step 2
$ rg -n "bracketed|paste_timeout|_prompt_text_input_modal|_capture_modal_input_snapshot|_restore_modal_input_snapshot|slash_confirm_state|_prompt_text_input" cli.py tests
output · 1 lines
bash: line 8: rg: command not found
step 3
$ grep -nE "bracketed|_prompt_text_input_modal|_capture_modal_input_snapshot|_restore_modal_input_snapshot|slash_confirm_state|_prompt_text_input" cli.py | head -200 && grep -R -nE "prompt_text_input_modal|bracketed.paste|slash_confirm_state" tests | head -200
output · 18 lines
1788:    Using Rich Text.from_ansi preserves literal bracketed text like
2337:def _strip_leaked_bracketed_paste_wrappers(text: str) -> str:
2338:    """Strip leaked bracketed-paste wrapper markers from user-visible text.
2341:    fails and bracketed-paste markers end up in the buffer as literal text.
2384:    "\x1b[?2004l"  # disable bracketed paste
3148:        self._slash_confirm_state = None
4476:        if self._sudo_state or self._secret_state or self._approval_state or getattr(self, "_slash_confirm_state", None) or self._clarify_state:
6977:    def _prompt_text_input(self, prompt_text: str) -> str | None:
7021:    def _prompt_text_input_modal(
7047:        To avoid this, we fall back to ``_prompt_text_input`` (a simple
7054:          thread (same rationale as the ``_prompt_text_input`` thread guard
7067:            return self._prompt_text_input("Choice [1/2/3]: ")
6 more lines
7075:        
…[8628 chars omitted]…
ts/gateway/test_telegram_thread_fallback.py:1004:    adapter._slash_confirm_state = {"confirm-1": "session-1"}
tests/hermes_cli/test_setup_prompt_menus.py:4:def test_prompt_strips_bracketed_paste_markers(monkeypatch):
tests/hermes_cli/test_setup_prompt_menus.py:15:def test_password_prompt_strips_bracketed_paste_markers(monkeypatch):
tests/tools/test_ansi_strip.py:40:    def test_bracketed_paste(self):
step 4
$ sed -n '1,180p' cli.py && sed -n '6950,7150p' cli.py && sed -n '11320,11395p' cli.py && sed -n '12380,12540p' cli.py
output · 30 lines
#!/usr/bin/env python3
"""
Hermes Agent CLI - Interactive Terminal Interface

A beautiful command-line interface for the Hermes Agent, inspired by Claude Code.
Features ASCII art branding, interactive REPL, toolset selection, and rich formatting.

Usage:
    python cli.py                          # Start interactive mode with all tools
    python cli.py --toolsets web,terminal  # Start with specific toolsets
    python cli.py --skills hermes-agent-dev,github-auth
    python cli.py --list-tools             # List available tools and exit
18 more lines
"""

# IMPORTANT: hermes_bootstrap must be the very first import — UTF-8 stdio
# on Windows.  No-op on POSIX.  See hermes_bootstrap.py for full rationale.
try:
    import hermes_bootstrap  # noqa: F401
except ModuleNotFoundError:
    # Graceful fallback when hermes_bootstrap isn't registered in the venv
    # yet — happens during partial ``hermes updat
…[8630 chars omitted]…
confirm the highlighted selection ---
            if self._clarify_state and not self._clarify_freetext:
                state = self._clarify_state
                selected = state["selected"]
                choices = state.get("choices") or []
                if selected < len(choices):
                    state["response_queue"].put(choices[selected])
                    self._clarify_state = None
step 5
$ sed -n '6960,7150p' cli.py; sed -n '11330,11390p' cli.py; grep -n "Application(" cli.py | head -30; grep -n "def run\|self._app\|_app.loop" cli.py | tail -80; python - <<'PY'
output · 36 lines
in_main_thread = threading.current_thread() is threading.main_thread()

        if self._app and in_main_thread:
            from prompt_toolkit.application import run_in_terminal
            was_visible = self._status_bar_visible
            self._status_bar_visible = False
            self._app.invalidate()
            try:
                run_in_terminal(_pick)
            finally:
                self._status_bar_visible = was_visible
                self._app.invalidate()
24 more lines
        else:
            _pick()

        return result[0]

    def _prompt_text_input(self, prompt_text: str) -> str | None:
        """Prompt for free-text input safely inside or outside prompt_toolkit.

        Mirrors the thread-aware guard in ``_run_curses_picker``: ``run_in_terminal``
        returns a coroutine that must be awaited by the prompt_toolkit event loop,
        which only exists on th
…[8630 chars omitted]…
 else. This flush function should be called after a
        timeout, and processes everything that's still in the buffer as-is, so
        without assuming any characters will follow.
        """
        self._input_parser.send(_Flush())

    def feed_and_flush(self, data: str) -> None:
        """
        Wrapper around ``feed`` and ``flush``.
        """
        self.feed(data)
        self.flush()
step 6
$ sed -n '7021,7145p' cli.py
output · 37 lines
def _prompt_text_input_modal(
        self,
        *,
        title: str,
        detail: str,
        choices: list[tuple[str, str, str]],
        timeout: float = 120,
    ) -> str | None:
        """Prompt through the prompt_toolkit composer instead of raw input().

        This is for CLI slash-command confirmations.  The old raw input() path
        fought prompt_toolkit's active stdin ownership: in some terminals the
25 more lines
        prompt appeared above the TUI, choices were redrawn later, and Enter
        could be interpreted as EOF/exit.  A first-class modal state keeps the
        choices visible and lets the normal Enter key binding submit the typed
        or highlighted choice.

        **Platform note (Windows dead-lock — issue #30768):**
        The queue-based modal relies on prompt_toolkit key bindings receiving
        keyboard events and calling ``_submit_slash_confir
…[3984 chars omitted]…
ce(
        self,
        raw: str | None,
        choices: list[tuple[str, str, str]],
    ) -> str | None:
        if raw is None:
            return None
        choice_raw = raw.strip().lower()
        if not choice_raw:
            return None
        aliases = {
            "1": "once",
            "once": "once",
            "approve": "once",
            "yes": "once",
            "y": "once",
step 7
$ python - <<'PY'
output · 38 lines
pt 3.0.52
parser class init:     def __init__(self, feed_key_callback: Callable[[KeyPress], None]) -> None:
        self.feed_key_callback = feed_key_callback
        self.reset()

call handler:     def _call_handler(
        self, key: str | Keys | tuple[Keys, ...], insert_text: str
    ) -> None:
        """
        Callback to handler.
        """
        if isinstance(key, tuple):
26 more lines
            # Received ANSI sequence that corresponds with multiple keys
            # (probably alt+something). Handle keys individually, but only pass
            # data payload to first KeyPress (so that we won't insert it
            # multiple times).
            for i, k in enumerate(key):
                self._call_handler(k, insert_text if i == 0 else "")
        else:
            if key == Keys.BracketedPaste:
                self._in_bracketed_paste = True
                self._paste_buffer = "
…[8629 chars omitted]…
):
    """No input (None / EOF / Ctrl-C) treated as cancel."""
    from cli import HermesCLI

    self_ = _make_self(prompt_response=None)

    with patch(
        "cli.load_cli_config",
        return_value={"approvals": {"destructive_slash_confirm": True}},
    ):
        result = _bound(HermesCLI._confirm_destructive_slash, self_)(
            "clear", "detail",
        )

    assert result is None
step 8
$ python - <<'PY'
output · 47 lines
"""
Parser for VT100 input stream.
"""

from __future__ import annotations

import re
from typing import Callable, Dict, Generator

from ..key_binding.key_processor import KeyPress
from ..keys import Keys
from .ansi_escape_sequences import ANSI_SEQUENCES
35 more lines
__all__ = [
    "Vt100Parser",
]


# Regex matching any CPR response
# (Note that we use '\Z' instead of '$', because '$' could include a trailing
# newline.)
_cpr_response_re = re.compile("^" + re.escape("\x1b[") + r"\d+;\d+R\Z")

# Mouse events:
# Typical: "Esc[MaB*"  Urxvt: "Esc[96;14;13M" and for Xterm SGR: "Esc[<64;85;12M"
_mouse_event_re = re.compile("^" + re.escape("\x1b[") + r"(<?[\d;]+[mM]|M...)\Z")

# Regex matching any valid prefix of a CPR response.
# (Note that it doesn't contain the last character, the 'R'. The prefix has to
# be shorter.)
_cpr_response_prefix_re = re.compile("^" + re.escape("\x1b[") + r"[\d;]*\Z")

_mo
…[5433 chars omitted]…
eption:
            pass

    def _submit_secret_response(self, value: str) -> None:
        if not self._secret_state:
            return
        self._secret_state["response_queue"].put(value)
        self._secret_state = None
        self._secret_deadline = 0
        self._invalidate()

    def _cancel_secret_capture(self) -> None:
3191:    def _invalidate(self, min_interval: float = 0.25) -> None:
step 9
$ sed -n '12250,12360p' cli.py; sed -n '14030,14130p' cli.py; sed -n '3060,3165p' cli.py; sed -n '1,260p' tests/cli/test_slash_confirm_windows.py
output · 37 lines
Window(height=0),
                sudo_widget,
                secret_widget,
                approval_widget,
                slash_confirm_widget,
                clarify_widget,
                model_picker_widget,
                spinner_widget,
                spacer,
                *self._get_extra_tui_widgets(),
                status_bar,
                input_rule_top,
25 more lines
                image_bar,
                input_area,
                input_rule_bot,
                voice_status_bar,
                completions_menu,
            ] if item is not None
        ]

    def run(self):
        """Run the interactive CLI loop with persistent input at bottom."""
        # Detect light/dark terminal mode now (before pt grabs the tty).
        # Caches the result so subsequent _hex_to_ansi / style calls
        # don't risk re-querying mid-render.
        try:
    
…[8630 chars omitted]…
"3"), \
             patch("cli.load_cli_config", return_value={"approvals": {"destructive_slash_confirm": True}}):
            result = cli._confirm_destructive_slash(
                "reset",
                "This starts a fresh session.\nThe current conversation history will be discarded.",
            )

        # Choice "3" normalizes to "cancel", which returns None.
        assert result is None
step 10
$ sed -n '3180,3215p' cli.py; sed -n '14065,14130p' cli.py; sed -n '1,235p' tests/cli/test_slash_confirm_windows.py
output · 27 lines
# the terminal just reflowed into scrollback — the cause of duplicated
        # bars / "blank line flooding" reports (#19280, #22976).
        self._status_bar_suppressed_after_resize = False
        self._resize_recovery_lock = threading.Lock()
        self._resize_recovery_timer = None
        self._resize_recovery_pending = False

        # Background task tracking: {task_id: threading.Thread}
        self._background_tasks: Dict[str, threading.Thread] = {}
        self._background_task_counter = 0

    def _invalidate(self, min_interval: float = 0.25) -> None:
15 more lines
        """Throttled UI repaint — prevents terminal blinking on slow/SSH connections."""
        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._l
…[8629 chars omitted]…
            patch.object(cli, "_prompt_text_input", return_value="1"), \
             patch("cli.load_cli_config", return_value={"approvals": {"destructive_slash_confirm": True}}):
            result = cli._confirm_destructive_slash(
                "new",
                "This starts a fresh session.\nThe current conversation history will be discarded.",
            )

        assert result == "once"
step 11
$ grep -nE "app\.run|app\.run_async|self\._app.*run|_app\.loop|call_soon_threadsafe" cli.py | tail -100; sed -n '14220,14370p' cli.py; sed -n '1,260p' tests/cli/test_slash_confirm_windows.py
output · 24 lines
2001:    ``loop.call_soon_threadsafe``, which pauses the input area, prints
2085:        loop.call_soon_threadsafe(_schedule)
3070:        self._app = None  # prompt_toolkit Application (set in run())
3303:                        loop.call_soon_threadsafe(_run_recovery)
14382:            # `app.exit()` scheduled via `call_soon_threadsafe` lets the
14383:            # event loop unwind normally; `app.run()` returns and our
14392:                        _loop.call_soon_threadsafe(_app.exit)
14410:            # would then unwind prompt_toolkit's app.run(), trigger
14507:                app.run()
                    # See _detect_file_drop() for details.
                    _file_drop = _detect_file_drop(user_input) if isinstance(user_input, str) else None
                    if _file_drop:
12 more lines
                        _drop_path = _file_drop["path"]
                        _remainder = _file_
…[8629 chars omitted]…
"3"), \
             patch("cli.load_cli_config", return_value={"approvals": {"destructive_slash_confirm": True}}):
            result = cli._confirm_destructive_slash(
                "reset",
                "This starts a fresh session.\nThe current conversation history will be discarded.",
            )

        # Choice "3" normalizes to "cancel", which returns None.
        assert result is None
step 12
$ sed -n '1,240p' tests/cli/test_slash_confirm_windows.py && sed -n '14340,14540p' cli.py && python - <<'PY'
output · 39 lines
"""Regression tests for issue #30768: /reset and /new freeze on Windows.

``_prompt_text_input_modal`` uses a queue-based modal that relies on
prompt_toolkit key bindings receiving keyboard events.  On Windows the
prompt_toolkit input channel can deadlock when the modal is entered from
the ``process_loop`` daemon thread.  The fix falls back to the simpler
``_prompt_text_input`` (stdin-based) prompt on Windows and non-main threads.

These tests verify:
1. Windows detection triggers the stdin fallback
2. Non-main thread detection triggers the stdin fallback
3. macOS/Linux main-thread path still uses the modal (no regression)
27 more lines
4. No-app path still uses the stdin fallback (existing behavior)
5. Empty choices returns None (existing behavior)
"""

import queue
import sys
import threading
import time
from unittest.mock import MagicMock, patch

import pytest


def _make_cli():
    """Minimal H
…[8630 chars omitted]…
           async def redraw_in_future() -> None:
                    await sleep(cast(float, self.min_redraw_interval) - diff)
                    schedule_redraw()

                self.loop.call_soon_threadsafe(
                    lambda: self.create_background_task(redraw_in_future())
                )
            else:
                schedule_redraw()
        else:
            schedule_redraw()
step 13
$ sed -n '14100,14240p' cli.py; sed -n '14370,14535p' cli.py; grep -n "def process_loop\|_process_loop" cli.py | head -30; grep -n "_process_thread\|process_loop" cli.py | tail -60
output · 23 lines
# rendered full-width rows into multiple narrower rows that get
        # pushed up — leaving ghost duplicates AND polluting scrollback.
        # Same issue as pt #29 (open since 2014), #1675, #1933.
        #
        # Surgical fix: wrap _output_screen_diff so that when its internal
        # `if current_height > previous_screen.height` branch fires (the
        # one that does the bottom-cursor-move), we make it fall through
        # by inflating previous_screen.height first.
        try:
            import prompt_toolkit.renderer as _pt_renderer
            from prompt_toolkit.renderer import _output_screen_diff as _orig_osd
11 more lines
            if not getattr(_pt_renderer, "_hermes_osd_patched", False):
                def _patched_output_screen_diff(
                    app, output, screen, current_pos, color_depth,
                    previous_screen, last_style, is_done, full
…[8629 chars omitted]…
s (process_loop is
14181:        def process_loop():
14294:                        # AudioRecorder.start (lock acquire) never block process_loop —
14318:                    logger.warning("process_loop unhandled error (msg may be lost): %s", e)
14321:        process_thread = threading.Thread(target=process_loop, daemon=True)
14600:        # terminal modes — rather than from the background process_loop
step 14
$ sed -n '14175,14340p' cli.py; python - <<'PY'
output · 29 lines
time.sleep(0.2)

        spinner_thread = threading.Thread(target=spinner_loop, daemon=True)
        spinner_thread.start()
        
        # Background thread to process inputs and run agent
        def process_loop():
            while not self._should_exit:
                try:
                    # Check for pending input with timeout
                    try:
                        user_input = self._pending_input.get(timeout=0.1)
17 more lines
                    except queue.Empty:
                        # Periodic config watcher — auto-reload MCP on mcp_servers change
                        if not self._agent_running:
                            self._check_config_mcp_changes()
                            # Check for background process notifications (completions
                            # and watch pattern matches) while agent is idle.
                            t
…[8066 chars omitted]…
before it can run one
            more poll iteration to clean up the subprocess, which was
            spawned with ``os.setsid`` and therefore survives as an orphan
            with PPID=1.

            Grace window (``HERMES_SIGTERM_GRACE``, default 1.5 s) gives
Traceback (most recent call last):
  File "<stdin>", line 4, in <module>
AttributeError: type object 'Application' has no attribute 'loop'
step 15
$ python - <<'PY'
output · 36 lines
attrs ['is_running']

    async def run_async(
        self,
        pre_run: Callable[[], None] | None = None,
        set_exception_handler: bool = True,
        handle_sigint: bool = True,
        slow_callback_duration: float = 0.5,
    ) -> _AppResult:
        """
        Run the prompt_toolkit :class:`~prompt_toolkit.application.Application`
        until :meth:`~prompt_toolkit.application.Application.exit` has been
24 more lines
        called. Return the value that was passed to
        :meth:`~prompt_toolkit.application.Application.exit`.

        This is the main entry point for a prompt_toolkit
        :class:`~prompt_toolkit.application.Application` and usually the only
        place where the event loop is actually running.

        :param pre_run: Optional callable, which is called right after the
            "reset" of the application.
        :param set_exception_handler: When set, 
…[6721 chars omitted]…
t as typeahead for next time.
                        store_typeahead(self.input, self.key_processor.empty_queue())

                return result

        @contextmanager
        def set_loop() -> Iterator[AbstractEventLoop]:
            loop = get_running_loop()
            self.loop = loop
            self._loop_thread = threading.current_thread()

            try:
                yield loop
step 16
$ sed -n '9745,9810p' cli.py; sed -n '10085,10145p' cli.py; sed -n '10150,10210p' cli.py; grep -n "_apply_.*patch" cli.py | head -100; find tests/cli -maxdepth 1 -type f -name 'test_*modal*'
output · 32 lines
except Exception as e:
                print(f"  ❌ Compression failed: {e}")

    def _handle_debug_command(self):
        """Handle /debug — upload debug report + logs and print paste URLs."""
        from hermes_cli.debug import run_debug_share
        from types import SimpleNamespace

        args = SimpleNamespace(lines=200, expire=7, local=False)
        run_debug_share(args)

    def _handle_update_command(self) -> bool:
20 more lines
        """Handle /update — update Hermes Agent to the latest version.

        In the classic CLI this exits the session and relaunches as
        ``hermes update`` so the user sees update output directly and gets
        the new version on next launch.

        Returns ``True`` when the update was confirmed (caller should trigger
        app exit so the relaunch is deferred to the main thread after
        prompt_toolkit cleans up terminal modes)
…[6778 chars omitted]…
ion.")
                print("   Re-enable via `approvals.mcp_reload_confirm: true` in config.yaml.")
            else:
                print("⚠️  Couldn't persist opt-out — reloading once.")

        with self._busy_command(self._slow_command_status(cmd_original)):
            self._reload_mcp()

    def _reload_mcp(self):
        """Reload MCP servers: disconnect all, re-read config.yaml, reconnect.
step 17
$ grep -n "_slash_confirm_deadline" cli.py
output · 8 lines
3149:        self._slash_confirm_deadline = 0
7092:        self._slash_confirm_deadline = _time.monotonic() + timeout
7101:                    self._slash_confirm_deadline = 0
7106:                    remaining = self._slash_confirm_deadline - _time.monotonic()
7116:                self._slash_confirm_deadline = 0
7127:        self._slash_confirm_deadline = 0
12428:        self._slash_confirm_deadline = 0
13512:                remaining = max(0, int(cli_ref._slash_confirm_deadline - time.monotonic()))
step 18
$ python - <<'PY'
output · 37 lines
import atexit
import errno
import tempfile
import time
import uuid
import textwrap
from collections import deque
from urllib.parse import unquote, urlparse
from contextlib import contextmanager
from pathlib import Path
from datetime import datetime
from typing import List, Dict, Any, Optional
25 more lines
logger = logging.getLogger(__name__)


def _apply_bracketed_paste_timeout_patch():
    """Recover prompt_toolkit input after an incomplete bracketed paste.

    prompt_toolkit buffers all input after ``ESC[200~`` until it sees
    ``ESC[201~``.  If the end marker is lost, that normally traps every
    subsequent keystroke in the paste buffer.  Give the marker two seconds
    to arrive, then submit the buffered text as a paste and resume parsing
    the current input normally.

    Keep all imports local: this helper is also used by small standalone
    compatibility checks which execute its sour
…[3103 chars omitted]…

from hermes_cli.fallback_config import get_fallback_chain

# prompt_toolkit for fixed input area TUI
from prompt_toolkit.history import FileHistory
from prompt_toolkit.styles import Style as PTStyle
from prompt_toolkit.patch_stdout import patch_stdout
from prompt_toolkit.application import Application
from prompt_toolkit.layout import Layout, HSplit, Window, FormattedTextControl, ConditionalContainer
step 19
$ python - <<'PY'
output · 24 lines
# Count how many messages we're removing
        removed_count = len(self.conversation_history) - last_user_idx
        removed_msg = self.conversation_history[last_user_idx].get("content", "")
        
        # Truncate history to before the last user message
        self.conversation_history = self.conversation_history[:last_user_idx]
        
        print(f"(^_^)b Undid {removed_count} message(s). Removed: \"{removed_msg[:60]}{'...' if len(removed_msg) > 60 else ''}\"")
        remaining = len(self.conversation_history)
        print(f"  {remaining} message(s) remaining in history.")
    
    def _run_curses_picker(self, title: str, items: list[str], default_index: int = 0) -> int | None:
12 more lines
        """Run curses_single_select via run_in_terminal so prompt_toolkit handles terminal ownership cleanly."""
        import threading
        from hermes_cli.curses_ui import curses_
…[6086 chars omitted]…
nfo=True)
                return None

            # A real asyncio event loop returns a Handle when it queues work.
            # If an application-like object has no running callback mechanism,
            # don't wait forever for a modal which cannot be displayed.  The
 cli.py | 256 ++++++++++++++++++++++++++++++++++++++++++++++++-----------------
 1 file changed, 189 insertions(+), 67 deletions(-)
step 20
$ sed -n '55,170p' tests/cli/test_slash_confirm_windows.py; sed -n '170,245p' tests/cli/test_slash_confirm_windows.py; git diff -- cli.py | sed -n '1,260p'
output · 32 lines
cli = _make_cli()

        with patch.object(sys, "platform", "win32"), \
             patch.object(cli, "_prompt_text_input", return_value="1") as mock_stdin:
            result = cli._prompt_text_input_modal(
                title="⚠️  /new — destroys conversation state",
                detail="This starts a fresh session.",
                choices=_SAMPLE_CHOICES,
            )

        # The stdin-based fallback was used, not the modal queue path.
        mock_stdin.assert_called_once_with("Choice [1/2/3]: ")
20 more lines
        assert result == "1"

    def test_non_main_thread_falls_back_to_stdin(self):
        """Off the main thread, _prompt_text_input_modal should use stdin fallback."""
        cli = _make_cli()
        result_holder = {}

        def run_on_daemon():
            # Patch platform to "linux" so the Windows check doesn't short-circuit.
            with patch.object
…[8629 chars omitted]…
ng = self._slash_confirm_deadline - _time.monotonic()
+                if remaining <= 0:
+                    break
                 try:
-                    result = response_queue.get(timeout=1)
-                    self._slash_confirm_state = None
-                    self._slash_confirm_deadline = 0
-                    self._restore_modal_input_snapshot()
-                    self._invalidate()
step 21
$ python - <<'PY'
output · 3 lines
after start True '' None []
after text True 'hello' []
after end False '' [KeyPress(key=<Keys.BracketedPaste: '<bracketed-paste>'>, data='hello'), KeyPress(key='x', data='x')]
step 22
$ python - <<'PY'
step 23
$ python - <<'PY'
output · 5 lines
recovered: False [KeyPress(key=<Keys.BracketedPaste: '<bracketed-paste>'>, data='line 1\nline 2'), KeyPress(key='t', data='t'), KeyPress(key='y', data='y'), KeyPress(key='p', data='p'), KeyPress(key='e', data='e'), KeyPress(key='d', data='d')]
split complete: [KeyPress(key=<Keys.BracketedPaste: '<bracketed-paste>'>, data='hello\n')]
torn marker: False [KeyPress(key=<Keys.BracketedPaste: '<bracketed-paste>'>, data='buffer')]
normal trailing chars: []
standalone and idempotent ok
step 24
$ pytest -q tests/cli/test_slash_confirm_windows.py tests/cli/test_destructive_slash_confirm.py tests/cli/test_destructive_slash_inline_skip_e2e.py tests/cli/test_cli_bracketed_paste_sanitizer.py tests/cli/test_update_command.py --disable-warnings --maxfail=2
output · 2 lines
...........................................................              [100%]
59 passed in 2.58s
step 25
$ python - <<'PY'
output · 2 lines
thread modal event loop passed
 M cli.py
step 26
$ pytest -q tests/cli --disable-warnings --maxfail=2
output · 20 lines
........................................................................ [  9%]
........................................................................ [ 18%]
........................................................................ [ 27%]
................................................F....................... [ 36%]
........................................................................ [ 45%]
........................................................................ [ 54%]
........................................................................ [ 63%]
.........................F
=================================== FAILURES ===================================
____________________ test_show_banner_does_not_print_skills ____________________

    def test_show_banner_does_not_print_skills():
8 more lines
        """show_banner() no longer prints the activated skills line — it moved to run()."""
     
…[7489 chars omitted]…
======================== short test summary info ============================
FAILED tests/cli/test_cli_preloaded_skills.py::test_show_banner_does_not_print_skills
FAILED tests/cli/test_personality_none.py::TestGatewayPersonalityNone::test_none_clears_ephemeral_prompt
!!!!!!!!!!!!!!!!!!!!!!!!!! stopping after 2 failures !!!!!!!!!!!!!!!!!!!!!!!!!!!
2 failed, 528 passed, 11 warnings in 214.84s (0:03:34)
step 27
$ git diff -- cli.py && sed -n '7130,7235p' cli.py && git diff --check
output · 35 lines
diff --git a/cli.py b/cli.py
index ce4f8eb..812387c 100644
--- a/cli.py
+++ b/cli.py
@@ -46,6 +46,90 @@ from typing import List, Dict, Any, Optional
 
 logger = logging.getLogger(__name__)
 
+
+def _apply_bracketed_paste_timeout_patch():
+    """Recover prompt_toolkit input after an incomplete bracketed paste.
+
23 more lines
+    prompt_toolkit buffers all input after ``ESC[200~`` until it sees
+    ``ESC[201~``.  If the end marker is lost, that normally traps every
+    subsequent keystroke in the paste buffer.  Give the marker two seconds
+    to arrive, then submit the buffered text as a paste and resume parsing
+    the current input normally.
+
+    Keep all imports local: this helper is also used by small standalone
+    compatibility checks which execute its source independently of cli.py.
+    """
+    import time as _time
+    import prompt_toolkit.input.vt100_parser as _vt100_parser
+   
…[8629 chars omitted]…
            result = response_queue.get(timeout=min(1.0, remaining))
                    break
                except queue.Empty:
                    now = _time.monotonic()
                    if now - last_countdown_refresh >= 5.0:
                        last_countdown_refresh = now
                        self._invalidate()
            return result
        finally:
            if in_main_thread:
step 28
$ python - <<'PY'
output · 1 lines
inline loop modal contract ok
step 29
$ python - <<'PY'
output · 1 lines
clean reload and standalone install passed
step 30
$ git diff --check && git diff --stat && git diff -- cli.py | sed -n '1,360p' && git status --short
output · 32 lines
cli.py | 261 ++++++++++++++++++++++++++++++++++++++++++++++++-----------------
 1 file changed, 194 insertions(+), 67 deletions(-)
diff --git a/cli.py b/cli.py
index ce4f8eb..812387c 100644
--- a/cli.py
+++ b/cli.py
@@ -46,6 +46,90 @@ from typing import List, Dict, Any, Optional
 
 logger = logging.getLogger(__name__)
 
+
+def _apply_bracketed_paste_timeout_patch():
20 more lines
+    """Recover prompt_toolkit input after an incomplete bracketed paste.
+
+    prompt_toolkit buffers all input after ``ESC[200~`` until it sees
+    ``ESC[201~``.  If the end marker is lost, that normally traps every
+    subsequent keystroke in the paste buffer.  Give the marker two seconds
+    to arrive, then submit the buffered text as a paste and resume parsing
+    the current input normally.
+
+    Keep all imports local: this helper is also used by small standalone
+    compatibility checks which execute its so
…[8629 chars omitted]…
              # cleanup rather than leaving the modal state stranded.
+                    logger.debug("Could not schedule slash confirmation cleanup", exc_info=True)
+                    _teardown_modal()
+                else:
+                    teardown_complete.wait()
 
     def _submit_slash_confirm_response(self, value: str | None) -> None:
         state = self._slash_confirm_state
 M cli.py
step 31
$ python - <<'PY'
step 32
$ python - <<'PY'
output · 4 lines
@property
    def is_running(self) -> bool:
        "`True` when the application is currently active/running."
        return self._is_running
step 33
$ python - <<'PY'
output · 5 lines
Traceback (most recent call last):
  File "<stdin>", line 8, in <module>
AttributeError: 'Vt100Parser' object has no attribute '_hermes_bp_start'
...........................................................              [100%]
59 passed in 2.69s
step 34
$ python - <<'PY'
output · 2 lines
...........................................................              [100%]
59 passed in 2.41s
step 35
$ python - <<'PY'
output · 5 lines
bracketed-paste recovery scenarios passed
standalone patch and idempotence passed
background modal event-loop scenario passed
...........................................................              [100%]
59 passed in 2.52s
step 36
$ grep -n "ruff\|line-length" pyproject.toml | head -20; git diff --numstat && git diff --check
output · 5 lines
87:dev = ["debugpy==1.8.20", "pytest==9.0.2", "pytest-asyncio==1.3.0", "pytest-timeout==2.4.0", "mcp==1.26.0", "ty==0.0.21", "ruff==0.15.10"]
249:[tool.ruff]
252:[tool.ruff.lint]
262:[tool.ruff.lint.per-file-ignores]
205	67	cli.py
step 37
$ python - <<'PY'
output · 37 lines
ruff unavailable
#!/usr/bin/env python3
"""
Hermes Agent CLI - Interactive Terminal Interface

A beautiful command-line interface for the Hermes Agent, inspired by Claude Code.
Features ASCII art branding, interactive REPL, toolset selection, and rich formatting.

Usage:
    python cli.py                          # Start interactive mode with all tools
    python cli.py --toolsets web,terminal  # Start with specific toolsets
    python cli.py --skills hermes-agent-dev,github-auth
25 more lines
    python cli.py --list-tools             # List available tools and exit
"""

# IMPORTANT: hermes_bootstrap must be the very first import — UTF-8 stdio
# on Windows.  No-op on POSIX.  See hermes_bootstrap.py for full rationale.
try:
    import hermes_bootstrap  # noqa: F401
except ModuleNotFoundError:
    # Graceful fallback when hermes_bootstrap isn't registered in the venv
    # yet — happens during parti
…[8629 chars omitted]…
          return
        state["response_queue"].put(value)
        self._slash_confirm_state = None
        self._slash_confirm_deadline = 0
        self._invalidate()

    def _normalize_slash_confirm_choice(
        self,
        raw: str | None,
        choices: list[tuple[str, str, str]],
    ) -> str | None:
        if raw is None:
            return None
        choice_raw = raw.strip().lower()
step 38
$ git diff --unified=40 -- cli.py | sed -n '1,460p'
output · 34 lines
diff --git a/cli.py b/cli.py
index ce4f8eb..7aa8570 100644
--- a/cli.py
+++ b/cli.py
@@ -9,80 +9,163 @@ Usage:
     python cli.py                          # Start interactive mode with all tools
     python cli.py --toolsets web,terminal  # Start with specific toolsets
     python cli.py --skills hermes-agent-dev,github-auth
     python cli.py --list-tools             # List available tools and exit
 """
 
 # IMPORTANT: hermes_bootstrap must be the very first import — UTF-8 stdio
22 more lines
 # on Windows.  No-op on POSIX.  See hermes_bootstrap.py for full rationale.
 try:
     import hermes_bootstrap  # noqa: F401
 except ModuleNotFoundError:
     # Graceful fallback when hermes_bootstrap isn't registered in the venv
     # yet — happens during partial ``hermes update`` where git-reset landed
     # new code but ``uv pip install -e .`` didn't finish.  Missing bootstrap
     # means UTF-8 stdio s
…[8629 chars omitted]…
ways",
             "always": "always",
             "remember": "always",
             "3": "cancel",
             "cancel": "cancel",
             "nevermind": "cancel",
             "no": "cancel",
             "n": "cancel",
         }
         allowed = {choice[0] for choice in choices}
         normalized = aliases.get(choice_raw)
         if normalized in allowed:
             return normalized
step 39
$ sed -n '245,265p' pyproject.toml; python - <<'PY'
output · 21 lines
[tool.ty.rules]
unknown-argument = "warn"
redundant-cast = "ignore"

[tool.ruff]
preview = true  # required for PLW1514 (unspecified-encoding) — preview rule

[tool.ruff.lint]
# All other lints are intentionally disabled (see comment history on this
# file) while we wrangle typechecks — but PLW1514 is too load-bearing to
# keep off.  Bare open()/read_text()/write_text() in text mode defaults to
# the system locale encoding on Windows (cp1252 on US-locale installs),
9 more lines
# which silently corrupts any non-ASCII file content.  We had three
# separate Windows sandbox regressions in one debug session before
# adding the explicit encoding.  This rule keeps new code honest.
select = ["PLW1514"]

[tool.ruff.lint.per-file-ignores]
# Tests can intentionally exercise locale-encoding edge cases.
"tests/**" = ["PLW1514"]
# Skills and plugins are partially user-authored — their own conventions.
step 40
$ grep -R -n "prompt-toolkit" pyproject.toml constraints-termux.txt uv.lock | head -30; git status --short
output · 4 lines
uv.lock:1600:    { name = "prompt-toolkit" },
uv.lock:1846:    { name = "prompt-toolkit", specifier = "==3.0.52" },
uv.lock:3015:name = "prompt-toolkit"
 M cli.py
step 41
$ python - <<'PY'
step 42
$ python - <<'PY'
output · 5 lines
unpatched behavior reproduced: later input remains buffered
recovery verified: buffered paste delivered and ordinary input resumed
background-thread event-loop modal verified
...........................................................              [100%]
59 passed in 2.46s
step 43
$ git diff --check && git status --short && git diff --stat && git diff -- cli.py | tail -230
output · 33 lines
M cli.py
 cli.py | 263 ++++++++++++++++++++++++++++++++++++++++++++++++-----------------
 1 file changed, 196 insertions(+), 67 deletions(-)
+                try:
+                    del self._hermes_bp_start
+                except AttributeError:
+                    pass
+
+            return result
+
+        _vt100_parser.Vt100Parser.feed = _patched_feed
+        _vt100_parser._hermes_bp_timeout_patched = True
21 more lines
+    except Exception:
+        # The CLI should remain importable if a future prompt_toolkit release
+        # changes its parser internals.  The compatibility failure is useful
+        # in logs, but must not prevent the interactive CLI from starting.
+        logger.debug("Could not install bracketed-paste recovery patch", exc_info=True)
+
+
+_apply_bracketed_paste_timeout_patch()
+
 # Suppress startup messages for clean CLI experience
 os.environ["HERMES_QUIET"] = "
…[8628 chars omitted]…
rt
+                    # cleanup rather than leaving the modal state stranded.
+                    logger.debug("Could not schedule slash confirmation cleanup", exc_info=True)
+                    _teardown_modal()
+                else:
+                    teardown_complete.wait()
 
     def _submit_slash_confirm_response(self, value: str | None) -> None:
         state = self._slash_confirm_state
step 44
$ python - <<'PY'
output · 35 lines
loop_running = None
                try:
                    loop_running = loop.is_running()
                except (AttributeError, RuntimeError):
                    pass

                if app_running is True or loop_running is True:
                    setup_complete.wait()
                else:
                    return self._prompt_text_input("Choice [1/2/3]: ")
            else:
                setup_complete.wait()
23 more lines
        result = None
        try:
            if setup_error:
                logger.debug("Could not initialize slash confirmation modal: %s", setup_error[0])
                return None

            last_countdown_refresh = _time.monotonic()
            while True:
                # _submit_slash_confirm_response clears the shared UI deadline
                # before waking this worker; keep a local copy so that a fast
                # select
…[2592 chars omitted]…
te:
            return []

        title = state.get("title") or "Confirm action"
        detail = state.get("detail") or ""
        choices = state.get("choices") or []
        selected = state.get("selected", 0)

        def _panel_box_width(title_text: str, content_lines: list[str], min_width: int = 56, max_width: int = 86) -> int:
            term_cols = shutil.get_terminal_size((100, 20)).columns
step 45
$ git diff --check && git status --short && git diff --stat && python -m py_compile cli.py && pytest -q tests/cli/test_slash_confirm_windows.py tests/cli/test_destructive_slash_confirm.py tests/cli/test_destructive_slash_inline_skip_e2e.py tests/cli/test_cli_bracketed_paste_sanitizer.py tests/cli/test_update_command.py --disable-warnings --maxfail=2
output · 5 lines
M cli.py
 cli.py | 263 ++++++++++++++++++++++++++++++++++++++++++++++++-----------------
 1 file changed, 196 insertions(+), 67 deletions(-)
...........................................................              [100%]
59 passed in 2.19s
step 46
$ done: {"notes": "Implemented idempotent prompt_toolkit bracketed-paste timeout recovery and POSIX event-loop scheduling/cleanup for background-thread confirmation modals, preserving Windows fallback. Focused tests passed (59 passed); manual reproductions covered paste recovery and background modal selection. The broader CLI run had unrelated environment/test failures (lazy dependency install timeout and missing async pytest plugin)."}
output · 1 lines
submitted.