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

nousresearch-hermes-agent-32018-33241

NousResearch/hermes-agentcleancompositemerged 2026-05-27MITfix: 2 files, +142 −2610 fail-to-pass · 7 pass-to-pass
Results
Modelsolved / attemptsmedian stepsmedian costattempts
GPT-5.6 Luna6/614$0.0171✓ 2✓ 3✓ 4✓ 5✓ 6✓
DeepSeek V4 Flash2/266$0.0691✓ 2✓
GLM-5.3 Flash5/527$0.0141✓ 2✓ 3✓ 4✓ 5✓
The prompt 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.

Hidden tests · 10 fail-to-pass, 7 pass-to-passrun after the agent submits, in a clean verifier
test_incomplete_paste_times_outtest_no_timeout_under_thresholdtest_no_timeout_when_end_mark_arrives_quicklytest_normal_bracketed_paste_workstest_normal_keys_after_timeout_recoverytest_subsequent_data_after_incomplete_pastetest_timeout_preserves_buffered_contenttest_torn_end_mark_recovers+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 −26the upstream merge, used only for grading calibration

The agent could not see this: the repository holds one commit and the sandbox has no network. Leak audit.

cli.py, scripts/release.py

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 model did

Submitted diff first, then every command. Outputs show 12 lines; expand for the rest.
GLM-5.3 Flash · attempt 2 · solved · 27 steps · $0.015
Submitted diff · 307 lines
diff --git a/cli.py b/cli.py
index ce4f8eb..c69ced2 100644
--- a/cli.py
+++ b/cli.py
@@ -46,6 +46,87 @@ from typing import List, Dict, Any, Optional
 
 logger = logging.getLogger(__name__)
 
+def _apply_bracketed_paste_timeout_patch() -> None:
+    """Install a recovery path for incomplete bracketed pastes.
+
+    When a terminal emits ``ESC[200~`` (paste start) but the matching
+    ``ESC[201~`` end marker is lost (SSH interruption, sleep/wake, torn
+    write), prompt_toolkit's Vt100Parser buffers every subsequent keystroke
+    forever and the CLI appears frozen.  This patch wraps
+    ``Vt100Parser.feed`` so that a paste which has not been terminated after
+    a short delay is delivered as a normal bracketed-paste key press and the
+    parser returns to ordinary input handling.
+
+    Self-contained and idempotent: safe to call multiple times.
+    """
+    import time
+    import logging as _logging
+
+    try:
+        _log = logger
+    except NameError:
+        _log = _logging.getLogger(__name__)
+
+    from prompt_toolkit.input import vt100_parser as _vt100_parser_module
+    from prompt_toolkit.input.vt100_parser import Vt100Parser as _Vt100Parser
+    from prompt_toolkit.keys import Keys as _Keys
+    from prompt_toolkit.key_binding.key_processor import KeyPress as _KeyPress
+
+    if getattr(_vt100_parser_module, "_hermes_bp_timeout_patched", False):
+        return
+    _vt100_parser_module._hermes_bp_timeout_patched = True
+
+    _BP_END_MARK = "\x1b[201~"
+    _BP_RECOVERY_DELAY = 2.0
+
+    _original_feed = _Vt100Parser.feed
+
+    def _hermes_patched_feed(self, data):
+        # Only intervene while an unterminated bracketed paste is in progress.
+        if getattr(self, "_in_bracketed_paste", False):
+            start = getattr(self, "_hermes_bp_start", None)
+            buffered = getattr(self, "_paste_buffer", "") or ""
+            if (
+                start is not None
+                and (time.monotonic() - start) > _BP_RECOVERY_DELAY
+                and _BP_END_MARK not in (buffered + data)
+            ):
+                # Recover: deliver everything buffered so far as a regular
+                # bracketed-paste key press, leave paste mode, and let the
+                # newly arrived data be parsed as ordinary input.
+                self._paste_buffer = ""
+                self._in_bracketed_paste = False
+                self._hermes_bp_start = None
+                if buffered:
+                    try:
+                        self.feed_key_callback(_KeyPress(_Keys.BracketedPaste, buffered))
+                    except Exception:
+                        _log.debug("bracketed-paste timeout recovery delivery failed", exc_info=True)
+                if data:
+                    result = _original_feed(self, data)
+                else:
+                    result = None
+                if getattr(self, "_in_bracketed_paste", False):
+                    # The new data started a fresh paste; time it from now.
+                    self._hermes_bp_start = time.monotonic()
+                return result
+            # Either the end marker is present (possibly split across feeds)
+            # or we are still inside the recovery window: normal buffering.
+            return _original_feed(self, data)
+
+        result = _original_feed(self, data)
+        # Record the paste start time when this feed entered bracketed-paste
+        # mode, so a later feed can detect a stalled paste.  Clear the
+        # timestamp when paste mode has ended.
+        if getattr(self, "_in_bracketed_paste", False):
+            self._hermes_bp_start = time.monotonic()
+        else:
+            self._hermes_bp_start = None
+        return result
+
+    _Vt100Parser.feed = _hermes_patched_feed
+
+
 # Suppress startup messages for clean CLI experience
 os.environ["HERMES_QUIET"] = "1"  # Our own modules
 
@@ -7076,9 +7157,19 @@ class HermesCLI:
 
         # 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.
+        # event loop.  From a daemon thread the modal key bindings never fire
+        # if we just wait on a queue while the app keeps running — so on
+        # POSIX systems hand the modal setup/teardown to the running
+        # application's event loop via call_soon_threadsafe.  The loop then
+        # owns the modal, key bindings can fire, and the requesting thread
+        # simply blocks on the response queue until the user chooses.
         if threading.current_thread() is not threading.main_thread():
-            return self._prompt_text_input("Choice [1/2/3]: ")
+            if sys.platform == "win32":
+                # Windows keeps its platform-specific fallback (issue #30768).
+                return self._prompt_text_input("Choice [1/2/3]: ")
+            return self._prompt_text_input_modal_via_loop(
+                title=title, detail=detail, choices=choices, timeout=timeout
+            )
 
         response_queue = queue.Queue()
         self._capture_modal_input_snapshot()
@@ -7118,6 +7209,81 @@ class HermesCLI:
                 self._invalidate()
         return None
 
+    def _prompt_text_input_modal_via_loop(
+        self,
+        *,
+        title: str,
+        detail: str,
+        choices: list[tuple[str, str, str]],
+        timeout: float = 120,
+    ) -> str | None:
+        """Show the confirmation modal from a background thread safely.
+
+        The modal state is installed and removed on the running
+        prompt_toolkit application's event loop (via
+        ``call_soon_threadsafe``) so the loop — not a raw-stdin read — owns
+        the modal and its key bindings stay live.  The calling (background)
+        thread blocks on the response queue until a choice is made or the
+        timeout expires.
+        """
+        import time as _time
+
+        
… [7359 more characters]
step 1
$ ls /repo && grep -n "_prompt_text_input_modal\|_apply_bracketed_paste\|_capture_modal_input_snapshot\|_slash_confirm_state" /repo/cli.py | head -50
output · 70 lines
AGENTS.md
CONTRIBUTING.md
Dockerfile
LICENSE
MANIFEST.in
README.md
README.zh-CN.md
RELEASE_v0.10.0.md
RELEASE_v0.11.0.md
RELEASE_v0.12.0.md
RELEASE_v0.13.0.md
RELEASE_v0.14.0.md
58 more lines
RELEASE_v0.2.0.md
RELEASE_v0.3.0.md
RELEASE_v0.4.0.md
RELEASE_v0.5.0.md
RELEASE_v0.6.0.md
RELEASE_v0.7.0.md
RELEASE_v0.8.0.md
RELEASE_v0.9.0.md
SECURITY.md
acp_adapter
acp_registry
agent
assets
batch_runner.py
cli-config.yaml.example
cli.py
constraints-termux.txt
cron
datagen-config-examples
docker
docker-compose.yml
docs
flake.lock
flake.nix
gateway
hermes
hermes-already-has-routines.md
hermes_agent.egg-info
hermes_bootstrap.py
hermes_cli
hermes_constants.py
hermes_logging.py
hermes_state.py
hermes_time.py
infographic
locales
mcp_serve.py
mini_swe_runner.py
model_tools.py
nix
optional-skills
package-lock.json
package.json
packaging
plans
plugins
providers
pyproject.toml
run_agent.py
scripts
setup-hermes.sh
se
…[2876 chars omitted]…
ate or cli_ref._slash_confirm_state:
13467:            if cli_ref._slash_confirm_state:
13511:            if cli_ref._slash_confirm_state:
13540:            if cli_ref._sudo_state or cli_ref._secret_state or cli_ref._approval_state or cli_ref._slash_confirm_state or cli_ref._clarify_state or cli_ref._command_running:
13852:            filter=Condition(lambda: cli_ref._slash_confirm_state is not None),
step 2
$ sed -n 7000,7200p /repo/cli.py
output · 38 lines
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(_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()
26 more lines
                except Exception:
                    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,
        ch
…[6994 chars omitted]…
e:
            inner_width = max(0, box_width - 2)
            lines.append((border_style, "│ "))
            lines.append((content_style, text.ljust(inner_width)))
            lines.append((border_style, " │\n"))

        def _append_blank_panel_line(lines, border_style: str, box_width: int) -> None:
            lines.append((border_style, "│" + (" " * box_width) + "│\n"))

        preview_lines = []
step 3
$ python -c "import prompt_toolkit, inspect; from prompt_toolkit.input.vt100_parser import Vt100Parser; print(prompt_toolkit.__version__); print(inspect.getsource(Vt100Parser))"
output · 44 lines
3.0.52
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
        i = Vt100Parser(callback)
32 more lines
        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
        self._start_parser()

    def _start_parser(self) -> None:
        """
    
…[5186 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 4
$ grep -n "^logger\|^import time\|^import sys\|_patch\|def main(" /repo/cli.py | head -30; grep -rn "bracketed" /repo/cli.py | head
output · 20 lines
29:import sys
37:import time
47:logger = logging.getLogger(__name__)
749:            def _patched_exec(module):
758:            spec.loader.exec_module = _patched_exec  # type: ignore[method-assign]
14112:            if not getattr(_pt_renderer, "_hermes_osd_patched", False):
14113:                def _patched_output_screen_diff(
14149:                _pt_renderer._output_screen_diff = _patched_output_screen_diff
14150:                _pt_renderer._hermes_osd_patched = True
14612:def main(
1788:    Using Rich Text.from_ansi preserves literal bracketed text like
2337:def _strip_leaked_bracketed_paste_wrappers(text: str) -> str:
8 more lines
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
13217:            When the terminal supports bracketed paste, Ctrl+V / Cmd+V
13236:            pasted_text = _strip_leaked_bracketed_paste_wrappers(pasted_text)
13239:                self._recover_terminal_input_modes(reason="mouse reports leaked into bracketed paste payload")
13266:                    "Slow bracketed-paste handler: %.1fms to process %d bytes "
13277:            """Fallback image paste for terminals without bracketed paste.
step 5
$ sed -n 14612,14680p /repo/cli.py
output · 45 lines
def main(
    query: str = None,
    q: str = None,
    image: str = None,
    toolsets: str = None,
    skills: str | list[str] | tuple[str, ...] = None,
    model: str = None,
    provider: str = None,
    api_key: str = None,
    base_url: str = None,
    max_turns: int = None,
    verbose: Optional[bool] = None,
33 more lines
    quiet: bool = False,
    compact: bool = False,
    list_tools: bool = False,
    list_toolsets: bool = False,
    gateway: bool = False,
    resume: str = None,
    worktree: bool = False,
    w: bool = False,
    checkpoints: bool = False,
    pass_session_id: bool = False,
    ignore_user_config: bool = False,
    ignore_rules: bool = False,
):
    """
    Hermes Agent CLI - Interactive AI Assistant
    
    Args:
        query: Single query to execute (then exit). Alias: -q
        q: Shorthand for --query
        image: Optional local image path to attach to a sin
…[1571 chars omitted]…
  # Single query in worktree
    """
    global _active_worktree

    # Force UTF-8 stdio on Windows before any banner/print() runs — the
    # Rich console prints Unicode box-drawing characters that would
    # UnicodeEncodeError on cp1252.  No-op on Linux/macOS.
    try:
        from hermes_cli.stdio import configure_windows_stdio
        configure_windows_stdio()
    except Exception:
        pass
step 6
$ sed -n 40,60p /repo/cli.py
output · 21 lines
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

logger = logging.getLogger(__name__)

# Suppress startup messages for clean CLI experience
os.environ["HERMES_QUIET"] = "1"  # Our own modules
9 more lines
import yaml

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
step 7
$ python - <<'PY'
output · 1 lines
OK
step 8
$ python - <<'PY'
output · 1 lines
OK
step 9
$ cd /tmp && cat > repro_bp.py <<'PY'
output · 6 lines
Traceback (most recent call last):
  File "/tmp/repro_bp.py", line 4, in <module>
    _apply_bracketed_paste_timeout_patch()
  File "/repo/cli.py", line 73, in _apply_bracketed_paste_timeout_patch
    from prompt_toolkit.key_processor import KeyPress as _KeyPress
ModuleNotFoundError: No module named 'prompt_toolkit.key_processor'
step 10
$ python -c "from prompt_toolkit.input import vt100_parser as m; import inspect; print([l for l in inspect.getsource(m).splitlines() if 'import' in l][:15])"
output · 1 lines
['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']
step 11
$ cd /repo && sed -i 's/from prompt_toolkit.key_processor import KeyPress as _KeyPress/from prompt_toolkit.key_binding.key_processor import KeyPress as _KeyPress/' cli.py && python /tmp/repro_bp.py
output · 5 lines
Traceback (most recent call last):
  File "/tmp/repro_bp.py", line 31, in <module>
    kp = [k for k in keys if k.key.name == 'BracketedPaste' or str(k.key)=='<bracketed-paste>']
                             ^^^^^^^^^^
AttributeError: 'str' object has no attribute 'name'
step 12
$ cd /repo && sed -i "s/k.key.name == 'BracketedPaste' or str(k.key)=='<bracketed-paste>'/k.key == '\x1b[200~' or getattr(k.key, 'name', '') == 'BracketedPaste'/" /tmp/repro_bp.py && python /tmp/repro_bp.py
output · 4 lines
[(<Keys.BracketedPaste: '<bracketed-paste>'>, 'hello\nworld more'), ('t', 't'), ('a', 'a'), ('i', 'i'), ('l', 'l')]
[('a', 'a'), ('b', 'b'), ('c', 'c')]
[(<Keys.BracketedPaste: '<bracketed-paste>'>, 'abc')]
ALL OK
step 13

Paste patch works. Now the modal fix:

$ python - <<'PY'
output · 1 lines
OK
step 14
$ cd /tmp && cat > repro_modal.py <<'PY'
output · 3 lines
1813:class _SkinAwareAnsi:
2612:class ChatConsole:
2881:class HermesCLI:
step 15
$ cd /tmp && cat > repro_modal.py <<'PY'
output · 1 lines
MODAL OK
step 16

Now wire the patch into startup and run existing tests:

$ cd /repo && python - <<'PY'
output · 47 lines
__init__.py
acp
acp_adapter
agent
cli
conftest.py
cron
docker
e2e
fakes
gateway
hermes_cli
35 more lines
hermes_state
honcho_plugin
integration
openviking_plugin
plugins
providers
run_agent
run_interrupt_test.py
scripts
skills
stress
test_account_usage.py
test_atomic_replace_symlinks.py
test_base_url_hostname.py
test_batch_runner_checkpoint.py
test_bitwarden_secrets.py
test_cli_file_drop.py
test_cli_manual_compress.py
test_cli_skin_integration.py
test_ctx_halving_fix.py
test_empty_model_fallback.py
test_env_loader_secret_sources.py
test_evidence_store.py
test_gateway_streaming_nested_config.py
test_get_tool_definitions_cache_isolation.py
test_hermes_bootstrap.py
test_hermes_constants.py
test_hermes_home_profile_warning.py
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/hermes_cli/test_setup_prompt_menus.py
tests/tools/test_ansi_strip.py
step 17
$ cd /repo && python -m pytest tests/cli/test_destructive_slash_confirm.py tests/cli/test_slash_confirm_windows.py tests/cli/test_cli_bracketed_paste_sanitizer.py tests/cli/test_destructive_slash_inline_skip_e2e.py -q 2>&1 | tail -15
output · 15 lines
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"
E       AssertionError: daemon thread hung — modal deadlocked
E       assert not True
E        +  where True = is_alive()
E        +    where is_alive = <Thread(Thread-1 (run_on_daemon), started daemon 130006463153856)>.is_alive

tests/cli/test_slash_confirm_windows.py:88: AssertionError
3 more lines
=========================== short test summary info ============================
FAILED tests/cli/test_slash_confirm_windows.py::TestModalWindowsFallback::test_non_main_thread_falls_back_to_stdin
1 failed, 38 passed in 6.79s
step 18
$ cd /repo && sed -n 1,110p 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
…[3174 chars omitted]…
ve a response
            # immediately via the queue.
            original_queue = queue.Queue
            original_time = time.monotonic

            def _fake_modal_flow(*args, **kwargs):
                """Simulate the modal flow: set state, put response, return."""
                # We'll directly test that the modal path is entered by
                # checking that _slash_confirm_state was set.
step 19

The old test contradicts the new contract (it expects stdin fallback off-thread on Linux). I'll make the helper robust to non-consuming loops, then update that outdated test to the new behavior.

$ cd /repo && python - <<'PY'
output · 2 lines
MODAL OK
REPRO-OK
step 20
$ cd /repo && python - <<'PY'
output · 4 lines
tests/cli/test_slash_confirm_windows.py:226: AssertionError
=========================== short test summary info ============================
FAILED tests/cli/test_slash_confirm_windows.py::TestModalWindowsFallback::test_non_main_thread_fallback_does_not_set_modal_state
1 failed, 38 passed in 4.30s
step 21
$ cd /repo && sed -n 195,240p tests/cli/test_slash_confirm_windows.py
output · 35 lines
cli._prompt_text_input_modal(
                title="⚠️  /reset",
                detail="This starts a fresh session.",
                choices=_SAMPLE_CHOICES,
            )

        assert cli._slash_confirm_state is None

    def test_non_main_thread_fallback_does_not_set_modal_state(self):
        """Verify daemon-thread fallback doesn't leave modal state set."""
        cli = _make_cli()
        errors = []
23 more lines
        def run_on_daemon():
            try:
                with patch.object(sys, "platform", "linux"), \
                     patch.object(cli, "_prompt_text_input", return_value="1"):
                    cli._prompt_text_input_modal(
                        title="⚠️  /new",
                        detail="This starts a fresh session.",
                        choices=_SAMPLE_CHOICES,
                    )
                if cli._slash_confirm_state is not N
…[480 chars omitted]…
 Windows."""

    def test_confirm_destructive_slash_bypasses_modal_on_windows(self):
        """_confirm_destructive_slash should work on Windows via stdin fallback."""
        cli = _make_cli()
        cli.model = "test-model"
        cli._agent_running = False
        cli._spinner_text = ""
        cli._should_exit = False
        cli._command_running = False
        cli.session_id = "test-session"
step 22
$ cd /repo && python - <<'PY'
output · 2 lines
.......................................                                  [100%]
39 passed in 2.13s
step 23
$ cd /repo && python /tmp/repro_bp.py >/dev/null && python /tmp/repro_modal.py >/dev/null && echo REPROS-OK && python -m pytest tests/cli -q -x 2>&1 | tail -3
output · 4 lines
REPROS-OK
FAILED tests/cli/test_cli_preloaded_skills.py::test_show_banner_does_not_print_skills
!!!!!!!!!!!!!!!!!!!!!!!!!! stopping after 1 failures !!!!!!!!!!!!!!!!!!!!!!!!!!!
1 failed, 264 passed, 11 warnings in 213.69s (0:03:33)
step 24
$ cd /repo && git stash && python -m pytest tests/cli/test_cli_preloaded_skills.py::test_show_banner_does_not_print_skills -q 2>&1 | tail -3; git stash pop
output · 13 lines
Saved working directory and index state WIP on master: 6b34f76 base
=========================== short test summary info ============================
FAILED tests/cli/test_cli_preloaded_skills.py::test_show_banner_does_not_print_skills
1 failed in 31.97s
On branch master
Changes not staged for commit:
  (use "git add <file>..." to update what will be committed)
  (use "git restore <file>..." to discard changes in working directory)
	modified:   cli.py
	modified:   tests/cli/test_slash_confirm_windows.py

no changes added to commit (use "git add" and/or "git commit -a")
1 more lines
Dropped refs/stash@{0} (d9e5b5cb8cd3b7b0ba34338005879046121f5be0)
step 25

Pre-existing failure, unrelated. Reviewing the diff:

$ cd /repo && git diff --stat && git diff cli.py | head -220
output · 30 lines
cli.py                                  | 178 +++++++++++++++++++++++++++++++-
 tests/cli/test_slash_confirm_windows.py |  46 +++++++--
 2 files changed, 213 insertions(+), 11 deletions(-)
diff --git a/cli.py b/cli.py
index ce4f8eb..fdd333e 100644
--- a/cli.py
+++ b/cli.py
@@ -46,6 +46,88 @@ from typing import List, Dict, Any, Optional
 
 logger = logging.getLogger(__name__)
 
+def _apply_bracketed_paste_timeout_patch() -> None:
18 more lines
+    """Install a recovery path for incomplete bracketed pastes.
+
+    When a terminal emits ``ESC[200~`` (paste start) but the matching
+    ``ESC[201~`` end marker is lost (SSH interruption, sleep/wake, torn
+    write), prompt_toolkit's Vt100Parser buffers every subsequent keystroke
+    forever and the CLI appears frozen.  This patch wraps
+    ``Vt100Parser.feed`` so that a paste which has not been terminated after
+    a short delay is delivered as a n
…[7600 chars omitted]…
 end marker) so a torn paste can never freeze input permanently.
+    try:
+        _apply_bracketed_paste_timeout_patch()
+    except Exception:
+        logger.debug("bracketed-paste timeout patch not installed", exc_info=True)
+
     # Signal to terminal_tool that we're in interactive mode
     # This enables interactive sudo password prompts with timeout
     os.environ["HERMES_INTERACTIVE"] = "1"
step 26

Minor cleanup of the helper's logger handling and stray blank lines:

$ cd /repo && python - <<'PY'
output · 2 lines
.......................................                                  [100%]
39 passed in 2.64s
step 27
$ done: {"notes": "Fixed both CLI input failures in cli.py:\n\n1. Bracketed-paste timeout recovery: added top-level, self-contained, idempotent `_apply_bracketed_paste_timeout_patch()` (installed at startup in `main()`). It wraps prompt_toolkit's `Vt100Parser.feed`, records `_hermes_bp_start` (time.monotonic()) when paste mode is entered,
output · 3 lines
…[1007 chars omitted]…
e new loop-based behavior. All cli modal/paste tests pass (39/39); only pre-existing unrelated failure remains (test_cli_preloaded_skills, fails on base commit too)."}
submitted.
GLM-5.3 Flash · attempt 3 · solved · 24 steps · $0.014
Submitted diff · 223 lines
diff --git a/cli.py b/cli.py
index ce4f8eb..71fc710 100644
--- a/cli.py
+++ b/cli.py
@@ -2329,6 +2329,118 @@ def _format_image_attachment_badges(attached_images: list[Path], image_counter:
     )
 
 
+
+def _apply_bracketed_paste_timeout_patch() -> bool:
+    """Install incomplete-bracketed-paste recovery into prompt_toolkit.
+
+    A terminal that emits ``ESC[200~`` (paste start) but whose ``ESC[201~``
+    (paste end) marker is lost — SSH interruption, sleep/wake, terminal race,
+    torn write — leaves prompt_toolkit's Vt100Parser buffering input forever:
+    the prompt freezes and every keystroke is swallowed.
+
+    This patch wraps ``Vt100Parser.feed`` so that a paste which has not been
+    terminated within a short grace period is flushed as a normal bracketed
+    paste key press, and ordinary input handling resumes immediately.
+
+    Self-contained and idempotent: safe to call multiple times.
+    """
+    import importlib
+    import time as _time
+
+    try:
+        vt100_parser_module = importlib.import_module(
+            "prompt_toolkit.input.vt100_parser"
+        )
+    except Exception as _exc:
+        logger.debug("bracketed-paste timeout patch: import failed: %s", _exc)
+        return False
+
+    parser_cls = getattr(vt100_parser_module, "Vt100Parser", None)
+    if parser_cls is None or not hasattr(parser_cls, "feed"):
+        return False
+
+    if getattr(vt100_parser_module, "_hermes_bp_timeout_patched", False):
+        return True
+
+    try:
+        from prompt_toolkit.key_binding.key_processor import KeyPress
+        from prompt_toolkit.keys import Keys
+    except Exception as _exc:
+        logger.debug("bracketed-paste timeout patch: import failed: %s", _exc)
+        return False
+
+    if getattr(parser_cls, "_hermes_bp_timeout_patched", False):
+        vt100_parser_module._hermes_bp_timeout_patched = True
+        return True
+
+    _original_feed = parser_cls.feed
+    _PASTE_END_MARK = "\x1b[201~"
+    _PASTE_TIMEOUT_SECONDS = 2.0
+
+    def _feed_with_paste_timeout(self, data):
+        """feed() wrapper recovering from unterminated bracketed pastes."""
+        try:
+            if getattr(self, "_in_bracketed_paste", False):
+                buffered = getattr(self, "_paste_buffer", "") or ""
+                combined = buffered + (data or "")
+                if _PASTE_END_MARK in combined:
+                    # Complete paste: let the original implementation finish
+                    # it and clear any pending recovery timer.
+                    self._hermes_bp_start = None
+                    return _original_feed(self, data)
+
+                start = getattr(self, "_hermes_bp_start", None)
+                now = _time.monotonic()
+                if start is None:
+                    self._hermes_bp_start = now
+                    return _original_feed(self, data)
+
+                if now - start > _PASTE_TIMEOUT_SECONDS:
+                    # The end marker never arrived — recover.  Deliver the
+                    # text buffered so far as a regular bracketed paste key
+                    # press, leave paste mode, and parse the new data
+                    # normally so freshly typed keys keep working.
+                    paste_content = buffered
+                    self._in_bracketed_paste = False
+                    self._paste_buffer = ""
+                    self._hermes_bp_start = None
+                    if paste_content:
+                        try:
+                            self.feed_key_callback(
+                                KeyPress(Keys.BracketedPaste, paste_content)
+                            )
+                        except Exception:
+                            logger.exception(
+                                "bracketed-paste timeout recovery: "
+                                "failed to deliver buffered paste"
+                            )
+                    if data:
+                        _original_feed(self, data)
+                        if getattr(self, "_in_bracketed_paste", False):
+                            self._hermes_bp_start = _time.monotonic()
+                    return None
+
+                # Still inside the grace window: keep buffering.
+                return _original_feed(self, data)
+
+            result = _original_feed(self, data)
+            if getattr(self, "_in_bracketed_paste", False):
+                # Just entered bracketed paste mode: start the recovery
+                # timer.
+                self._hermes_bp_start = _time.monotonic()
+            return result
+        except Exception:
+            logger.exception("bracketed-paste timeout patch: feed failed")
+            return _original_feed(self, data)
+
+    _feed_with_paste_timeout.__name__ = "feed"
+    _feed_with_paste_timeout.__doc__ = _original_feed.__doc__
+    parser_cls.feed = _feed_with_paste_timeout
+    parser_cls._hermes_bp_timeout_patched = True
+    vt100_parser_module._hermes_bp_timeout_patched = True
+    return True
+
+
 def _should_auto_attach_clipboard_image_on_paste(pasted_text: str) -> bool:
     """Auto-attach clipboard images only for image-only paste gestures."""
     return not pasted_text.strip()
@@ -7076,8 +7188,17 @@ class HermesCLI:
 
         # 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.
+        # event loop.  From a daemon thread the modal key bindings never fire
+        # when driven directly — instead, hand the modal setup/teardown to
+        # the running application's event loop via call_soon_threadsafe and
+        # block this thread on the response queue.  The prompt_toolkit app
+        # stays active and its key bindings can submit the answer.
         if threading.current_thread() is not threading.main_thread():
+            loop = getattr(getattr(self, "_app", None),
… [3324 more characters]
step 1
$ ls /repo; ls /repo | head; grep -n "_apply_bracketed_paste_timeout_patch\|_prompt_text_input_modal\|_slash_confirm_state" -r /repo --include=*.py | head -40
output · 69 lines
AGENTS.md
CONTRIBUTING.md
Dockerfile
LICENSE
MANIFEST.in
README.md
README.zh-CN.md
RELEASE_v0.10.0.md
RELEASE_v0.11.0.md
RELEASE_v0.12.0.md
RELEASE_v0.13.0.md
RELEASE_v0.14.0.md
57 more lines
RELEASE_v0.2.0.md
RELEASE_v0.3.0.md
RELEASE_v0.4.0.md
RELEASE_v0.5.0.md
RELEASE_v0.6.0.md
RELEASE_v0.7.0.md
RELEASE_v0.8.0.md
RELEASE_v0.9.0.md
SECURITY.md
acp_adapter
acp_registry
agent
assets
batch_runner.py
cli-config.yaml.example
cli.py
constraints-termux.txt
cron
datagen-config-examples
docker
docker-compose.yml
docs
flake.lock
flake.nix
gateway
hermes
hermes-already-has-routines.md
hermes_agent.egg-info
hermes_bootstrap.py
hermes_cli
hermes_constants.py
hermes_logging.py
hermes_state.py
hermes_time.py
infographic
locales
mcp_serve.py
mini_swe_runner.py
model_tools.py
nix
optional-skills
package-lock.json
package.json
packaging
plans
plugins
providers
pyproject.toml
run_agent.py
scripts
setup-hermes.sh
se
…[3500 chars omitted]…
ef._approval_state or cli_ref._slash_confirm_state or cli_ref._clarify_state or cli_ref._command_running:
/repo/cli.py:13852:            filter=Condition(lambda: cli_ref._slash_confirm_state is not None),
/repo/gateway/platforms/telegram.py:465:        self._slash_confirm_state: Dict[str, str] = {}
/repo/gateway/platforms/telegram.py:2547:            self._slash_confirm_state[confirm_id] = session_key
step 2
$ sed -n 7000,7200p /repo/cli.py
output · 38 lines
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(_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()
26 more lines
                except Exception:
                    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,
        ch
…[6994 chars omitted]…
e:
            inner_width = max(0, box_width - 2)
            lines.append((border_style, "│ "))
            lines.append((content_style, text.ljust(inner_width)))
            lines.append((border_style, " │\n"))

        def _append_blank_panel_line(lines, border_style: str, box_width: int) -> None:
            lines.append((border_style, "│" + (" " * box_width) + "│\n"))

        preview_lines = []
step 3
$ grep -rn "bracketed_paste\|_hermes_bp" /repo/tests /repo/cli.py | head -30; ls /repo/tests | head -50
output · 22 lines
/repo/tests/cli/test_cli_bracketed_paste_sanitizer.py:3:from cli import _strip_leaked_bracketed_paste_wrappers
/repo/tests/cli/test_cli_bracketed_paste_sanitizer.py:9:        assert _strip_leaked_bracketed_paste_wrappers(text) == text
/repo/tests/cli/test_cli_bracketed_paste_sanitizer.py:13:        assert _strip_leaked_bracketed_paste_wrappers(text) == "hello"
/repo/tests/cli/test_cli_bracketed_paste_sanitizer.py:17:        assert _strip_leaked_bracketed_paste_wrappers(text) == "hello"
/repo/tests/cli/test_cli_bracketed_paste_sanitizer.py:21:        assert _strip_leaked_bracketed_paste_wrappers(text) == "hello"
/repo/tests/cli/test_cli_bracketed_paste_sanitizer.py:25:        assert _strip_leaked_bracketed_paste_wrappers(text) == "prefix hello suffix"
/repo/tests/cli/test_cli_bracketed_paste_sanitizer.py:29:        assert _strip_leaked_bracketed_paste_wrappers(text) == "hello world"
/r
…[2008 chars omitted]…
rmes_bootstrap.py
test_hermes_constants.py
test_hermes_home_profile_warning.py
10 more lines
test_hermes_logging.py
test_hermes_state.py
test_hermes_state_wal_fallback.py
test_honcho_client_config.py
test_install_sh_browser_install.py
test_install_sh_pythonpath_sanitization.py
test_install_sh_setup_wizard_tty_probe.py
test_install_sh_symlink_stomp.py
test_install_sh_termux_network_prereqs.py
test_ipv4_preference.py
step 4
$ python -c "import prompt_toolkit; print(prompt_toolkit.__version__, prompt_toolkit.__file__)"; sed -n 1,200p $(python -c "import prompt_toolkit.input.vt100_parser as m; print(m.__file__)")
output · 45 lines
3.0.52 /usr/local/lib/python3.12/site-packages/prompt_toolkit/__init__.py
"""
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
33 more lines
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_re
…[5175 chars omitted]…
   self._paste_buffer = ""
            else:
                self.feed_key_callback(KeyPress(key, insert_text))

    def feed(self, data: str) -> None:
        """
        Feed the input stream.

        :param data: Input string (unicode).
        """
        # Handle bracketed paste. (We bypass the parser that matches all other
        # key presses and keep reading input until we see the end mark.)
step 5
$ sed -n 200,260p /usr/local/lib/python3.12/site-packages/prompt_toolkit/input/vt100_parser.py
output · 35 lines
# key presses and keep reading input until we see the end mark.)
        # This is much faster then parsing character by character.
        if self._in_bracketed_paste:
            self._paste_buffer += data
            end_mark = "\x1b[201~"

            if end_mark in self._paste_buffer:
                end_index = self._paste_buffer.index(end_mark)

                # Feed content to key bindings.
                paste_content = self._paste_buffer[:end_index]
                self.feed_key_callback(KeyPress(Keys.BracketedPaste, paste_content))
23 more lines
                # Quit bracketed paste mode and handle remaining input.
                self._in_bracketed_paste = False
                remaining = self._paste_buffer[end_index + len(end_mark) :]
                self._paste_buffer = ""

                self.feed(remaining)

        # Handle normal input character by character.
       
…[775 chars omitted]…
g 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
$ grep -n "_capture_modal_input_snapshot\|_restore_modal_input_snapshot\|def _invalidate\|def main\|_strip_leaked_bracketed_paste_wrappers(" /repo/cli.py | head; grep -n "vt100\|apply.*patch\|InputParser\|input_hook" /repo/cli.py | head -20
output · 10 lines
2337:def _strip_leaked_bracketed_paste_wrappers(text: str) -> str:
3191:    def _invalidate(self, min_interval: float = 0.25) -> None:
7084:        self._capture_modal_input_snapshot()
7102:                    self._restore_modal_input_snapshot()
7117:                self._restore_modal_input_snapshot()
7246:        self._capture_modal_input_snapshot()
7261:        self._restore_modal_input_snapshot()
11038:        self._capture_modal_input_snapshot()
11051:                self._restore_modal_input_snapshot()
11066:        self._restore_modal_input_snapshot()
step 7
$ grep -n "def main\|if __name__\|def run(\|def _run_cli\|Application(" /repo/cli.py | head; sed -n 2320,2360p /repo/cli.py
output · 38 lines
12270:    def run(self):
14078:        app = Application(
14612:def main(
14968:if __name__ == "__main__":
            return f"[📎 {_trunc(attached_images[0].name, 32)}]"
        first = _trunc(attached_images[0].name, 20)
        extra = len(attached_images) - 1
        return f"[📎 {first}] [+{extra}]"

    base = image_counter - len(attached_images) + 1
    return " ".join(
        f"[📎 Image #{base + i}]"
26 more lines
        for i in range(len(attached_images))
    )


def _should_auto_attach_clipboard_image_on_paste(pasted_text: str) -> bool:
    """Auto-attach clipboard images only for image-only paste gestures."""
    return not pasted_text.strip()


def _strip_leaked_bracketed_paste_wrappers(text: str) -> str:
    """Strip leaked bracketed-paste wrapper markers from user-visible text.

    Defensive normalization for cases where terminal/prompt_toolkit parsing
    fails and bracketed-paste
…[310 chars omitted]…
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)
    return text
step 8
$ sed -n 14612,14660p /repo/cli.py; sed -n 12270,12300p /repo/cli.py
output · 43 lines
def main(
    query: str = None,
    q: str = None,
    image: str = None,
    toolsets: str = None,
    skills: str | list[str] | tuple[str, ...] = None,
    model: str = None,
    provider: str = None,
    api_key: str = None,
    base_url: str = None,
    max_turns: int = None,
    verbose: Optional[bool] = None,
31 more lines
    quiet: bool = False,
    compact: bool = False,
    list_tools: bool = False,
    list_toolsets: bool = False,
    gateway: bool = False,
    resume: str = None,
    worktree: bool = False,
    w: bool = False,
    checkpoints: bool = False,
    pass_session_id: bool = False,
    ignore_user_config: bool = False,
    ignore_rules: bool = False,
):
    """
    Hermes Agent CLI - Interactive AI Assistant
    
    Args:
        query: Single query to execute (then exit). Alias: -q
        q: Shorthand for --query
        image: Optional local image path to attach to a sin
…[1991 chars omitted]…
ht after the
        # welcome banner. Quiet/single-query paths call this themselves.
        self._show_security_advisories()
        # If resuming a session, load history and display it immediately
        # so the user has context before typing their first message.
        if self._resumed:
            if self._preload_resumed_session():
                self._display_resumed_history()

        try:
step 9

Now let me add the paste-timeout patch function to cli.py:

$ cat > /tmp/patch_fn.py <<'EOF'
output · 1 lines
OK
step 10

Now hook the patch call into `run()` and fix the modal:

$ cd /repo && python - <<'PY'
output · 1 lines
OK
step 11
$ cd /repo && cat > /tmp/test_bp.py <<'EOF'
output · 5 lines
Traceback (most recent call last):
  File "/tmp/test_bp.py", line 42, in <module>
    assert presses == [KeyPress(Keys.BracketedPaste, "line1\nline2")], presses
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
AssertionError: [KeyPress(key=<Keys.BracketedPaste: '<bracketed-paste>'>, data='line1\nline2'), KeyPress(key='x', data='x')]
step 12

That's correct behavior — the new data is parsed normally. Fixing my test assertion:

$ cd /repo && sed -i 's/assert presses == \[KeyPress(Keys.BracketedPaste, "line1\\nline2")\], presses/assert presses[0] == KeyPress(Keys.BracketedPaste, "line1\\nline2") and len(presses) == 2, presses/' /tmp/test_bp.py && python /tmp/test_bp.py
output · 5 lines
Traceback (most recent call last):
  File "/tmp/test_bp.py", line 65, in <module>
    assert not hasattr(m, "_hermes_bp_timeout_patched") or m._hermes_bp_timeout_patched is not True
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
AssertionError
step 13
$ cd /repo && sed -i 's/assert not hasattr(m, "_hermes_bp_timeout_patched") or m._hermes_bp_timeout_patched is not True/m._hermes_bp_timeout_patched = False/' /tmp/test_bp.py && python /tmp/test_bp.py
output · 1 lines
ALL BP TESTS PASS
step 14

Now test the modal path:

$ cd /repo && cat > /tmp/test_modal.py <<'EOF'
output · 1 lines
ALL MODAL TESTS PASS
step 15
$ cd /repo && python -m pytest tests/cli -x -q 2>&1 | tail -5; python -m pytest tests -q -k "slash or confirm or modal or paste" 2>&1 | tail -5
output · 10 lines
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
=========================== short test summary info ============================
FAILED tests/cli/test_cli_preloaded_skills.py::test_show_banner_does_not_print_skills
!!!!!!!!!!!!!!!!!!!!!!!!!! stopping after 1 failures !!!!!!!!!!!!!!!!!!!!!!!!!!!
1 failed, 264 passed, 11 warnings in 214.00s (0:03:33)
INTERNALERROR>     raise SystemExit(
INTERNALERROR> SystemExit: Web UI requires fastapi and uvicorn.
INTERNALERROR> Install with: /usr/local/bin/python -m pip install 'fastapi' 'uvicorn[standard]'

1 skipped, 1826 warnings, 17 errors in 56.66s
step 16
$ cd /repo && git stash && python -m pytest tests/cli/test_cli_preloaded_skills.py::test_show_banner_does_not_print_skills -q 2>&1 | tail -3; git stash pop
output · 12 lines
Saved working directory and index state WIP on master: ec8c5d7 base
=========================== short test summary info ============================
FAILED tests/cli/test_cli_preloaded_skills.py::test_show_banner_does_not_print_skills
1 failed in 31.83s
On branch master
Changes not staged for commit:
  (use "git add <file>..." to update what will be committed)
  (use "git restore <file>..." to discard changes in working directory)
	modified:   cli.py

no changes added to commit (use "git add" and/or "git commit -a")
Dropped refs/stash@{0} (26e2122309a76d885ab5cc7442777015cbbe2646)
step 17

Pre-existing failure and pre-existing collection errors (missing fastapi). Run the relevant test files directly:

$ cd /repo && python -m pytest tests/cli/test_cli_bracketed_paste_sanitizer.py -q 2>&1 | tail -2 && python -m pytest tests/cli -q --ignore=tests/cli/test_cli_preloaded_skills.py -k "not webui" 2>&1 | tail -3
output · 5 lines
...........                                                              [100%]
11 passed in 1.76s
FAILED tests/cli/test_resume_quiet_stderr.py::TestResumeQuietStderr::test_session_not_found_goes_to_stdout_in_full_mode
FAILED tests/cli/test_slash_confirm_windows.py::TestModalWindowsFallback::test_non_main_thread_falls_back_to_stdin
13 failed, 782 passed, 11 warnings in 220.28s (0:03:40)
step 18
$ cd /repo && python -m pytest tests/cli -q --ignore=tests/cli/test_cli_preloaded_skills.py -k "not webui" 2>&1 | grep FAILED
output · 14 lines
FAILED tests/cli/test_personality_none.py::TestGatewayPersonalityNone::test_none_clears_ephemeral_prompt
FAILED tests/cli/test_personality_none.py::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
FAILED tests/cli/test_personality_none.py::TestGatewayPersonalityNone::test_empty_personality_list_uses_profile_display_path
FAILED tests/cli/test_quick_commands.py::TestGatewayQuickCommands::test_exec_command_returns_output
FAILED tests/cli/test_quick_commands.py::TestGatewayQuickCommands::test_exec_command_does_not_leak_credentials
FAILED tests/cli/test_quick_commands.py::TestGatewayQuickCommands::test_exec_command_output_is_redacted
FAILED tests/cli/test_quick_commands.py::
…[104 chars omitted]…
estGatewayQuickCommands::test_timeout_returns_error
FAILED tests/cli/test_quick_commands.py::TestGatewayQuickCommands::test_gateway_config_object_supports_quick_commands
2 more lines
FAILED tests/cli/test_resume_quiet_stderr.py::TestResumeQuietStderr::test_session_not_found_goes_to_stdout_in_full_mode
FAILED tests/cli/test_slash_confirm_windows.py::TestModalWindowsFallback::test_non_main_thread_falls_back_to_stdin
step 19
$ cd /repo && git stash -q && python -m pytest tests/cli/test_slash_confirm_windows.py tests/cli/test_quick_commands.py tests/cli/test_resume_quiet_stderr.py -q 2>&1 | grep -E "FAILED|passed"; git stash pop -q
output · 8 lines
FAILED tests/cli/test_quick_commands.py::TestGatewayQuickCommands::test_exec_command_returns_output
FAILED tests/cli/test_quick_commands.py::TestGatewayQuickCommands::test_exec_command_does_not_leak_credentials
FAILED tests/cli/test_quick_commands.py::TestGatewayQuickCommands::test_exec_command_output_is_redacted
FAILED tests/cli/test_quick_commands.py::TestGatewayQuickCommands::test_unsupported_type_returns_error
FAILED tests/cli/test_quick_commands.py::TestGatewayQuickCommands::test_timeout_returns_error
FAILED tests/cli/test_quick_commands.py::TestGatewayQuickCommands::test_gateway_config_object_supports_quick_commands
FAILED tests/cli/test_resume_quiet_stderr.py::TestResumeQuietStderr::test_session_not_found_goes_to_stdout_in_full_mode
7 failed, 24 passed, 6 warnings in 4.44s
step 20
$ cd /repo && sed -n 1,120p tests/cli/test_slash_confirm_windows.py; python -m pytest tests/cli/test_personality_none.py -q 2>&1 | tail -2
output · 35 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)
23 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
…[3814 chars omitted]…
cli, "_prompt_text_input") as mock_stdin:
                # Set up a response that will be put into the queue
                # after the modal starts waiting.
                def _submit_after_delay():
                    time.sleep(0.2)
FAILED tests/cli/test_personality_none.py::TestGatewayPersonalityNone::test_empty_personality_list_uses_profile_display_path
5 failed, 14 passed, 5 warnings in 1.50s
step 21
$ cd /repo && git stash -q && python -m pytest tests/cli/test_personality_none.py -q 2>&1 | tail -1; git stash pop -q && git diff --stat
output · 3 lines
5 failed, 14 passed, 5 warnings in 1.72s
 cli.py | 190 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
 1 file changed, 189 insertions(+), 1 deletion(-)
step 22
$ cd /repo && python -m pytest tests/cli/test_slash_confirm_windows.py -q 2>&1 | tail -3
output · 3 lines
=========================== short test summary info ============================
FAILED tests/cli/test_slash_confirm_windows.py::TestModalWindowsFallback::test_non_main_thread_falls_back_to_stdin
1 failed, 8 passed in 5.88s
step 23
$ cd /repo && git diff | head -120
output · 34 lines
diff --git a/cli.py b/cli.py
index ce4f8eb..71fc710 100644
--- a/cli.py
+++ b/cli.py
@@ -2329,6 +2329,118 @@ def _format_image_attachment_badges(attached_images: list[Path], image_counter:
     )
 
 
+
+def _apply_bracketed_paste_timeout_patch() -> bool:
+    """Install incomplete-bracketed-paste recovery into prompt_toolkit.
+
22 more lines
+    A terminal that emits ``ESC[200~`` (paste start) but whose ``ESC[201~``
+    (paste end) marker is lost — SSH interruption, sleep/wake, terminal race,
+    torn write — leaves prompt_toolkit's Vt100Parser buffering input forever:
+    the prompt freezes and every keystroke is swallowed.
+
+    This patch wraps ``Vt100Parser.feed`` so that a paste which has not been
+    terminated within a short grace period is flushed as a normal bracketed
+    paste key press, and ordinary input handling resumes immediately.
+
+    Self-contained and idempotent: safe to 
…[3728 chars omitted]…
    logger.exception("bracketed-paste timeout patch: feed failed")
+            return _original_feed(self, data)
+
+    _feed_with_paste_timeout.__name__ = "feed"
+    _feed_with_paste_timeout.__doc__ = _original_feed.__doc__
+    parser_cls.feed = _feed_with_paste_timeout
+    parser_cls._hermes_bp_timeout_patched = True
+    vt100_parser_module._hermes_bp_timeout_patched = True
+    return True
+
+
step 24

The diff is complete and correct. Summary of the fix:

$ done: {"notes": "Added self-contained idempotent _apply_bracketed_paste_timeout_patch() wrapping Vt100Parser.feed for 2s incomplete-paste recovery (called from run()), and loop-driven modal path (_run_slash_confirm_modal_via_loop) for background-thread slash confirmations on Linux/macOS. Reproducers pass; only pre-existing tests fail (plus one outdated test asserting the old stdin fallback the issue replaces)."}
output · 1 lines
submitted.
GLM-5.3 Flash · attempt 4 · solved · 36 steps · $0.019
Submitted diff · 268 lines
diff --git a/cli.py b/cli.py
index ce4f8eb..be3f457 100644
--- a/cli.py
+++ b/cli.py
@@ -2448,6 +2448,105 @@ def _disable_prompt_toolkit_cpr_warning(app) -> None:
         pass
 
 
+def _apply_bracketed_paste_timeout_patch() -> None:
+    """Recover from incomplete bracketed paste sequences.
+
+    When a terminal starts a bracketed paste (``ESC[200~``) but the matching
+    end marker (``ESC[201~``) is lost — SSH interruption, terminal race,
+    sleep/wake, incomplete write — prompt_toolkit's ``Vt100Parser`` keeps
+    buffering every subsequent keystroke as paste content forever, which
+    freezes the prompt.
+
+    This installs a wrapper around ``Vt100Parser.feed`` that tracks when
+    bracketed-paste mode was entered (``_hermes_bp_start``).  If a feed
+    arrives more than 2 seconds later while still in paste mode and the end
+    marker has not been seen, the already-buffered text is delivered as a
+    normal bracketed-paste key press, paste mode is left, and the new data is
+    parsed as ordinary input.
+
+    The patch is idempotent: it is recorded via the
+    ``_hermes_bp_timeout_patched`` module attribute on
+    ``prompt_toolkit.input.vt100_parser`` and is a no-op when already applied.
+    """
+    # Self-contained on purpose: every import lives inside this function so
+    # the source can be extracted and executed standalone.
+    import time
+
+    try:
+        import prompt_toolkit.input.vt100_parser as _vt100_parser_mod
+        from prompt_toolkit.key_binding.key_processor import KeyPress
+        from prompt_toolkit.keys import Keys
+    except Exception:  # pragma: no cover - prompt_toolkit missing
+        return
+
+    if getattr(_vt100_parser_mod, "_hermes_bp_timeout_patched", False):
+        return
+
+    _vt100_parser = getattr(_vt100_parser_mod, "Vt100Parser", None)
+    if _vt100_parser is None:
+        return
+
+    _original_feed = _vt100_parser.feed
+    if getattr(_original_feed, "_hermes_bp_timeout_wrapped", False):
+        _vt100_parser_mod._hermes_bp_timeout_patched = True
+        return
+
+    _PASTE_END_MARK = "\x1b[201~"
+    _PASTE_TIMEOUT_SECONDS = 2.0
+
+    def _feed_with_paste_timeout(parser, data):
+        """Wrap ``Vt100Parser.feed`` with incomplete-paste recovery."""
+        try:
+            if getattr(parser, "_in_bracketed_paste", False):
+                start = getattr(parser, "_hermes_bp_start", None)
+                buffered = getattr(parser, "_paste_buffer", "") or ""
+                if (
+                    start is not None
+                    and (time.monotonic() - start) > _PASTE_TIMEOUT_SECONDS
+                    and _PASTE_END_MARK not in buffered
+                    and _PASTE_END_MARK not in (data or "")
+                ):
+                    # The paste end marker never arrived — deliver what we
+                    # have as a bracketed-paste key press and leave paste
+                    # mode so the new data is parsed normally.
+                    parser._in_bracketed_paste = False
+                    parser._paste_buffer = ""
+                    parser._hermes_bp_start = None
+                    if buffered:
+                        try:
+                            parser.feed_key_callback(
+                                KeyPress(Keys.BracketedPaste, buffered)
+                            )
+                        except Exception:
+                            logger.debug(
+                                "bracketed-paste timeout recovery: failed to "
+                                "deliver buffered paste text",
+                                exc_info=True,
+                            )
+        except Exception:
+            logger.debug(
+                "bracketed-paste timeout recovery failed", exc_info=True
+            )
+
+        result = _original_feed(parser, data)
+
+        try:
+            if getattr(parser, "_in_bracketed_paste", False):
+                if getattr(parser, "_hermes_bp_start", None) is None:
+                    parser._hermes_bp_start = time.monotonic()
+            else:
+                parser._hermes_bp_start = None
+        except Exception:
+            pass
+
+        return result
+
+    _feed_with_paste_timeout._hermes_bp_timeout_wrapped = True
+    _vt100_parser.feed = _feed_with_paste_timeout
+    _vt100_parser_mod._hermes_bp_timeout_patched = True
+    logger.debug("Installed bracketed-paste timeout recovery patch")
+
+
 def _strip_leaked_terminal_responses_with_meta(text: str) -> tuple[str, bool]:
     """Strip leaked terminal control-response sequences from user input.
 
@@ -7049,10 +7148,10 @@ 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).
+        * Called from a non-main thread *on Windows* — the prompt_toolkit
+          event loop only runs on the main thread there; on Linux/macOS the
+          modal setup/teardown is scheduled onto the running application's
+          loop via ``call_soon_threadsafe`` instead.
         * ``self._app`` is not set — unit tests / non-interactive contexts.
         """
         import threading
@@ -7074,33 +7173,89 @@ class HermesCLI:
         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():
-            return self._prompt_text_input("Choice [1/2/3]: ")
-
         response_queue = queue.Queue()
-       
… [5533 more characters]
step 1
$ ls /repo; ls /repo | head; grep -n "_apply_bracketed_paste_timeout_patch\|_prompt_text_input_modal\|_capture_modal_input_snapshot" -r /repo --include=*.py | head -30
output · 69 lines
AGENTS.md
CONTRIBUTING.md
Dockerfile
LICENSE
MANIFEST.in
README.md
README.zh-CN.md
RELEASE_v0.10.0.md
RELEASE_v0.11.0.md
RELEASE_v0.12.0.md
RELEASE_v0.13.0.md
RELEASE_v0.14.0.md
57 more lines
RELEASE_v0.2.0.md
RELEASE_v0.3.0.md
RELEASE_v0.4.0.md
RELEASE_v0.5.0.md
RELEASE_v0.6.0.md
RELEASE_v0.7.0.md
RELEASE_v0.8.0.md
RELEASE_v0.9.0.md
SECURITY.md
acp_adapter
acp_registry
agent
assets
batch_runner.py
cli-config.yaml.example
cli.py
constraints-termux.txt
cron
datagen-config-examples
docker
docker-compose.yml
docs
flake.lock
flake.nix
gateway
hermes
hermes-already-has-routines.md
hermes_agent.egg-info
hermes_bootstrap.py
hermes_cli
hermes_constants.py
hermes_logging.py
hermes_state.py
hermes_time.py
infographic
locales
mcp_serve.py
mini_swe_runner.py
model_tools.py
nix
optional-skills
package-lock.json
package.json
packaging
plans
plugins
providers
pyproject.toml
run_agent.py
scripts
setup-hermes.sh
se
…[2864 chars omitted]…
n ``_prompt_text_input_modal`` returns None (timeout /
/repo/tests/cli/test_update_command.py:38:    ``_prompt_text_input_modal`` is stubbed to return *modal_response*
/repo/tests/cli/test_update_command.py:45:        _prompt_text_input_modal=lambda **_kw: modal_response,
/repo/tests/cli/test_update_command.py:70:        _prompt_text_input_modal=lambda **_kw: pytest.fail("Modal should not be called"),
step 2
$ sed -n 6990,7160p /repo/cli.py
output · 37 lines
def _ask():
            try:
                result[0] = input(prompt_text).strip() or None
            except (KeyboardInterrupt, EOFError):
                pass

        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
25 more lines
            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:
               
…[5538 chars omitted]…
ways": "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
        if choice_raw in allowed:
step 3
$ sed -n 1,220p /repo/tests/cli/test_slash_confirm_windows.py
output · 38 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)
26 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
…[7432 chars omitted]…
lashWindows:
    """Integration-level tests for _confirm_destructive_slash on Windows."""

    def test_confirm_destructive_slash_bypasses_modal_on_windows(self):
        """_confirm_destructive_slash should work on Windows via stdin fallback."""
        cli = _make_cli()
        cli.model = "test-model"
        cli._agent_running = False
        cli._spinner_text = ""
        cli._should_exit = False
step 4
$ python -c "import prompt_toolkit, inspect; from prompt_toolkit.input import vt100_parser; print(prompt_toolkit.__version__); print(inspect.getsource(vt100_parser))" | sed -n 1,120p; grep -n "^import time\|^import \|logger = " /repo/cli.py | head -20
output · 54 lines
3.0.52
"""
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
42 more lines
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
…[2605 chars omitted]…
se, use the mappings.
        try:
            return ANSI_SEQUENCES[prefix]
26:import logging
27:import os
28:import shutil
29:import sys
30:import json
31:import re
32:import concurrent.futures
33:import base64
34:import atexit
35:import errno
36:import tempfile
37:import time
38:import uuid
39:import textwrap
47:logger = logging.getLogger(__name__)
52:import yaml
83:import threading
84:import queue
step 5
$ python - <<'PY'
output · 33 lines
c = yield

                if isinstance(c, _Flush):
                    flush = True
                else:
                    prefix += c

            # If we have some data, check for matches.
            if prefix:
                is_prefix_of_longer_match = _IS_PREFIX_OF_LONGER_MATCH_CACHE[prefix]
                match = self._get_match(prefix)
21 more lines
                # Exact matches found, call handlers..
                if (flush or not is_prefix_of_longer_match) and match:
                    self._call_handler(match, prefix)
                    prefix = ""

                # No exact match found.
                elif (flush or not is_prefix_of_longer_match) and not match:
                    found = False
                    retry = True

                    # Loop over the input, try the longest match first and
                    # shift.
                    for i in ran
…[3871 chars omitted]…
buffer.index(end_mark)
210:                paste_content = self._paste_buffer[:end_index]
211:                self.feed_key_callback(KeyPress(Keys.BracketedPaste, paste_content))
214:                self._in_bracketed_paste = False
215:                remaining = self._paste_buffer[end_index + len(end_mark) :]
216:                self._paste_buffer = ""
223:                if self._in_bracketed_paste:
step 6
$ grep -n "PromptSession\|create_input\|vt100\|_apply_.*patch\|def main\|Application(" /repo/cli.py | head -30
output · 2 lines
14078:        app = Application(
14612:def main(
step 7
$ grep -n "prompt_toolkit" /repo/cli.py | head -30; sed -n 14050,14120p /repo/cli.py
output · 23 lines
56:# prompt_toolkit for fixed input area TUI
57:from prompt_toolkit.history import FileHistory
58:from prompt_toolkit.styles import Style as PTStyle
59:from prompt_toolkit.patch_stdout import patch_stdout
60:from prompt_toolkit.application import Application
61:from prompt_toolkit.layout import Layout, HSplit, Window, FormattedTextControl, ConditionalContainer
62:from prompt_toolkit.layout.processors import Processor, Transformation, PasswordProcessor, ConditionalProcessor
63:from prompt_toolkit.filters import Condition
64:from prompt_toolkit.layout.dimension import Dimension
65:from prompt_toolkit.layout.menus import CompletionsMenu
66:from prompt_toolkit.widgets import TextArea
67:from prompt_toolkit.key_binding import KeyBindings
11 more lines
68:from prompt_toolkit import print_formatted_text as _pt_print
69:from prompt_toolkit.formatted_text import ANSI as _PT_ANSI
71:    from prompt_toolkit.c
…[4229 chars omitted]…
              app, output, screen, current_pos, color_depth,
                    previous_screen, last_style, is_done, full_screen,
                    attrs_for_style_string, style_string_has_style,
                    size, previous_width,
                ):
                    """Wraps pt's _output_screen_diff to suppress the
                    reserve-vertical-space scroll (renderer.py L232-242).
step 8

Now let me implement the paste patch function. I'll add it near other module-level helpers:

$ grep -n "^def _disable_prompt_toolkit_cpr_warning" /repo/cli.py; sed -n 2440,2470p /repo/cli.py
output · 32 lines
2443:def _disable_prompt_toolkit_cpr_warning(app) -> None:
        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
    except Exception:
        pass

20 more lines
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`` /
    ``_request_absolute_cursor_position``. When the input parser drops one
    (resize storms, multiplexer focus changes, slow PTYs) the response
    lands in the input buffer as literal text and corrupts what the user
    typed.

    Also strips leaked SGR mouse-report fragments (``ESC[<...M/m`` and
    degraded visible forms). Returns ``(cleaned_text, 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
step 9
$ cat > /tmp/bp_patch.py <<'EOF'
output · 1 lines
True
step 10

Now a reproducer for the paste recovery:

$ cat > /tmp/test_bp.py <<'EOF'
output · 5 lines
Traceback (most recent call last):
  File "/tmp/test_bp.py", line 28, in <module>
    assert len(events) == 1 and events[0].data == "stuck paste", events
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
AssertionError: [KeyPress(key=<Keys.BracketedPaste: '<bracketed-paste>'>, data='stuck paste'), KeyPress(key='x', data='x')]
step 11

That's correct behavior — the new data "x" is parsed normally as a key press. Fixing my test assertion:

$ sed -i 's/assert \[e.data for e in events\] == \["stuck paste", "a", "b", "c"\], events/assert [e.data for e in events] == ["stuck paste", "x", "a", "b", "c"], events/' /tmp/test_bp.py && python /tmp/test_bp.py
output · 5 lines
Traceback (most recent call last):
  File "/tmp/test_bp.py", line 28, in <module>
    assert len(events) == 1 and events[0].data == "stuck paste", events
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
AssertionError: [KeyPress(key=<Keys.BracketedPaste: '<bracketed-paste>'>, data='stuck paste'), KeyPress(key='x', data='x')]
step 12
$ sed -i '28s/len(events) == 1/len(events) == 2/' /tmp/test_bp.py && python /tmp/test_bp.py
output · 1 lines
OK
step 13

Now hook the patch into the TUI setup:

$ grep -n "self._app = app" /repo/cli.py;
output · 1 lines
14186:        self._app = app  # Store reference for clarify_callback
step 14
$ sed -n 14184,14190p /repo/cli.py
output · 7 lines
)
        _disable_prompt_toolkit_cpr_warning(app)
        self._app = app  # Store reference for clarify_callback

        # ── Fix ghost status-bar lines on terminal resize ──────────────
        # Resize handling: monkey-patch prompt_toolkit's _output_screen_diff
        # to suppress the deliberate "reserve vertical space" scroll-up.
step 15
$ python - <<'PY'
output · 1 lines
ok
step 16

Now the modal fix. Replace the thread-guard and wait-loop section:

$ grep -n "Mirror the thread-aware guard\|_last_countdown_refresh = _time.monotonic()" /repo/cli.py | head; sed -n 7148,7160p /repo/cli.py
output · 17 lines
7176:        # Mirror the thread-aware guard from _prompt_text_input (PR #23454):
7194:        _last_countdown_refresh = _time.monotonic()
11094:        _last_countdown_refresh = _time.monotonic()
11202:            _last_countdown_refresh = _time.monotonic()

        * ``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.
5 more lines
        """
        import threading
        import time as _time

        if not choices:
step 17
$ sed -n 7170,7225p /repo/cli.py
output · 32 lines
# never reach the key bindings, so response_queue.get() blocks for
        # the full timeout (issue #30768).  Fall back to the simpler
        # stdin-based prompt which works reliably on Windows.
        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():
            return self._prompt_text_input("Choice [1/2/3]: ")
20 more lines
        response_queue = queue.Queue()
        self._capture_modal_input_snapshot()
        self._slash_confirm_state = {
            "title": title,
            "detail": detail,
            "choices": choices,
            
…[1072 chars omitted]…
= None
                self._slash_confirm_deadline = 0
                self._restore_modal_input_snapshot()
                self._invalidate()
        return None

    def _submit_slash_confirm_response(self, value: str | None) -> None:
        state = self._slash_confirm_state
        if not state:
            return
        state["response_queue"].put(value)
        self._slash_confirm_state = None
step 18
$ cat > /tmp/modal_new.py <<'EOF'
output · 12 lines
ok
        # never reach the key bindings, so response_queue.get() blocks for
        # the full timeout (issue #30768).  Fall back to the simpler
        # stdin-based prompt which works reliably on Windows.
        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():
            return self._prompt_text_input("Choice [1/2/3]: ")
step 19
$ git diff --stat && sed -n 7176,7186p cli.py && grep -n "response_queue = queue.Queue()" cli.py
output · 17 lines
cli.py | 208 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------
 1 file changed, 189 insertions(+), 19 deletions(-)
        # 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():
            return self._prompt_text_input("Choice [1/2/3]: ")

        response_queue = queue.Queue()
        on_main_thread = threading.current_thread() is threading.main_thread()

        def _setup_modal_state() -> None:
5 more lines
            self._capture_modal_input_snapshot()
7182:        response_queue = queue.Queue()
11127:        response_queue = queue.Queue()
11193:        response_queue = queue.Queue()
11247:            response_queue = queue.Queue()
step 20
$ python - <<'PY'
output · 1 lines
ok
step 21

Now write a reproducer for the modal contract:

$ cat > /tmp/test_modal.py <<'EOF'
output · 5 lines
Traceback (most recent call last):
  File "/tmp/test_modal.py", line 33, in <module>
    assert isinstance(cli._slash_confirm_state, dict) and "response_queue" in cli._slash_confirm_state
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
AssertionError
step 22
$ cd /tmp && python - <<'EOF'
output · 1 lines
{'r': 'bad'}
step 23
$ cd /tmp && python - <<'EOF'
output · 39 lines
on_main_thread = threading.current_thread() is threading.main_thread()

        def _setup_modal_state() -> None:
            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
27 more lines
            self._invalidate()

        def _teardown_modal_state() -> None:
            self._slash_confirm_state = None
            self._slash_confirm_deadline = 0
            self._restore_modal_input_snapshot()
            self._invalidate()

        _finished = False

        def _finish_modal() -> None:
            """Tear the modal down through the app loop when off-main-thread."""
            nonlocal _finished

…[218 chars omitted]…
loop.call_soon_threadsafe(_teardown_modal_state)
                except Exception:
                    _teardown_modal_state()

        if on_main_thread:
            _setup_modal_state()
        else:
            # The prompt_toolkit event loop only runs on the main thread, so
            # the modal state must be installed (and later removed) from that
            # loop.  Hand se
STDIN
{'r': 'bad'}
step 24
$ sed -n 7130,7180p /repo/cli.py
output · 23 lines
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
11 more lines
        ``process_loop`` daemon thread, causing a dead-lock: the user sees the
        confirmation panel but
…[1497 chars omitted]…

            return self._prompt_text_input("Choice [1/2/3]: ")

        # On Linux/macOS the modal is shown through the running application's
        # event loop (see below), even when requested from a background
        # thread such as the process_loop daemon thread.
        if threading.current_thread() is not threading.main_thread():
            return self._prompt_text_input("Choice [1/2/3]: ")
step 25
$ python - <<'PY'
output · 1 lines
OK
step 26

Now run the existing related tests:

$ python -m pytest 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_update_command.py -q 2>&1 | tail -20
output · 4 lines
ERROR: file or directory not found: tests/cli/test_slash_confirm_windows.py


no tests ran in 0.00s
step 27
$ cd /repo && python -m pytest 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_update_command.py -q 2>&1 | tail -25
output · 2 lines
................................................                         [100%]
48 passed in 4.43s
step 28
$ timeout 900 python -m pytest tests/cli -q -x 2>&1 | tail -8
output · 8 lines
/repo/tests/cli/test_quick_commands.py:233: PytestUnknownMarkWarning: Unknown pytest.mark.asyncio - is this a typo?  You can register custom marks to avoid this warning - for details, see https://docs.pytest.org/en/stable/how-to/mark.html
    @pytest.mark.asyncio

-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
=========================== short test summary info ============================
FAILED tests/cli/test_cli_preloaded_skills.py::test_show_banner_does_not_print_skills
!!!!!!!!!!!!!!!!!!!!!!!!!! stopping after 1 failures !!!!!!!!!!!!!!!!!!!!!!!!!!!
1 failed, 264 passed, 11 warnings in 215.77s (0:03:35)
step 29
$ git stash -q && python -m pytest tests/cli/test_cli_preloaded_skills.py::test_show_banner_does_not_print_skills -q 2>&1 | tail -3; git stash pop -q
output · 3 lines
=========================== short test summary info ============================
FAILED tests/cli/test_cli_preloaded_skills.py::test_show_banner_does_not_print_skills
1 failed in 32.02s
step 30

Pre-existing failure, unrelated. Run the rest without -x:

$ timeout 1800 python -m pytest tests/cli -q --deselect tests/cli/test_cli_preloaded_skills.py::test_show_banner_does_not_print_skills 2>&1 | tail -5
output · 5 lines
FAILED tests/cli/test_quick_commands.py::TestGatewayQuickCommands::test_unsupported_type_returns_error
FAILED tests/cli/test_quick_commands.py::TestGatewayQuickCommands::test_timeout_returns_error
FAILED tests/cli/test_quick_commands.py::TestGatewayQuickCommands::test_gateway_config_object_supports_quick_commands
FAILED tests/cli/test_resume_quiet_stderr.py::TestResumeQuietStderr::test_session_not_found_goes_to_stdout_in_full_mode
12 failed, 785 passed, 1 deselected, 11 warnings in 210.22s (0:03:30)
step 31
$ git stash -q && timeout 1800 python -m pytest tests/cli -q --deselect tests/cli/test_cli_preloaded_skills.py::test_show_banner_does_not_print_skills 2>&1 | tail -16; git stash pop -q
output · 15 lines
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
=========================== short test summary info ============================
FAILED tests/cli/test_personality_none.py::TestGatewayPersonalityNone::test_none_clears_ephemeral_prompt
FAILED tests/cli/test_personality_none.py::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
FAILED tests/cli/test_personality_none.py::TestGatewayPersonalityNone::test_empty_personality_list_uses_profile_display_path
FAILED tests/cli/test_quick_commands.py::TestGatewayQuickCommands::test_exec_command_returns_output
FAILED tests/cli/test_quick_commands.py::TestGatewayQuickCommands::test_exec_command_does_not_leak_cre
…[213 chars omitted]…
or
FAILED tests/cli/test_quick_commands.py::TestGatewayQuickCommands::test_timeout_returns_error
3 more lines
FAILED tests/cli/test_quick_commands.py::TestGatewayQuickCommands::test_gateway_config_object_supports_quick_commands
FAILED tests/cli/test_resume_quiet_stderr.py::TestResumeQuietStderr::test_session_not_found_goes_to_stdout_in_full_mode
12 failed, 785 passed, 1 deselected, 11 warnings in 208.09s (0:03:28)
step 32

Same 12 pre-existing failures. Now verify the standalone-execution contract for the patch helper:

$ python - <<'PY'
output · 6 lines
File "<stdin>", line 13
    del m._hermes_bp_timeout_patched if hasattr(m,'_hermes_bp_timeout_patched') else None
        ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
SyntaxError: cannot delete conditional expression
OK
OK
step 33
$ python - <<'PY'
output · 1 lines
standalone OK
step 34
$ git diff | head -120
output · 28 lines
diff --git a/cli.py b/cli.py
index ce4f8eb..be3f457 100644
--- a/cli.py
+++ b/cli.py
@@ -2448,6 +2448,105 @@ def _disable_prompt_toolkit_cpr_warning(app) -> None:
         pass
 
 
+def _apply_bracketed_paste_timeout_patch() -> None:
+    """Recover from incomplete bracketed paste sequences.
+
+    When a terminal starts a bracketed paste (``ESC[200~``) but the matching
16 more lines
+    end marker (``ESC[201~``) is lost — SSH interruption, terminal race,
+    sleep/wake, incomplete write — prompt_toolkit's ``Vt100Parser`` keeps
+    buffering every subsequent keystroke as paste content forever, which
+    freezes the prompt.
+
+    This installs a wrapper around ``Vt100Parser.feed`` that tracks when
+    bracketed-paste mode was entered (``_hermes_bp_start``).  If a feed
+    arrives more than 2 seconds later while still in paste mode and the end
+    marker has not been seen, the already-buffere
…[3887 chars omitted]…
       * 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).
+        * Called from a non-main thread *on Windows* — the prompt_toolkit
+          event loop only runs on the main thread there; on Linux/macOS the
step 35
$ git diff | sed -n 120,260p
output · 27 lines
+          event loop only runs on the main thread there; on Linux/macOS the
+          modal setup/teardown is scheduled onto the running application's
+          loop via ``call_soon_threadsafe`` instead.
         * ``self._app`` is not set — unit tests / non-interactive contexts.
         """
         import threading
@@ -7074,33 +7173,89 @@ class HermesCLI:
         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
15 more lines
-        # event loop.  From a daemon thread the modal key bindings never fire.
-        if threading.current_thread() is not threading.main_thread():
-            return self._prompt_text_input("Choice [1/2/3]: ")
-
         response_queue = queue.Queue()
-        self._ca
…[4749 chars omitted]…
─────
+        # If a terminal starts a bracketed paste but the end marker is lost
+        # (SSH interruption, sleep/wake, torn write), prompt_toolkit would
+        # buffer every later keystroke as paste content forever, freezing
+        # the prompt.  Install a timeout-based recovery on Vt100Parser.feed.
+        try:
+            _apply_bracketed_paste_timeout_patch()
+        except Exception:
step 36

All checks pass. Summary of the fix:

$ done: {"notes": "Added self-contained _apply_bracketed_paste_timeout_patch() wrapping Vt100Parser.feed for 2s incomplete-bracketed-paste recovery (idempotent via _hermes_bp_timeout_patched), installed at TUI app creation; reworked _prompt_text_input_modal so off-main-thread Linux/macOS confirmations run through self._app.loop.call_soon_t
output · 3 lines
…[24 chars omitted]…
ed answer, single capture/restore, state cleared afterward, Windows/loop-down stdin fallbacks preserved. Reproducers and tests/cli pass (only pre-existing failures)."}
submitted.
GLM-5.3 Flash · attempt 5 · solved · 26 steps · $0.012
Submitted diff · 150 lines
diff --git a/cli.py b/cli.py
index ce4f8eb..d968049 100644
--- a/cli.py
+++ b/cli.py
@@ -2448,6 +2448,88 @@ def _disable_prompt_toolkit_cpr_warning(app) -> None:
         pass
 
 
+def _apply_bracketed_paste_timeout_patch() -> bool:
+    """Recover from incomplete bracketed paste sequences.
+
+    When a terminal starts a bracketed paste (``ESC[200~``) but the closing
+    ``ESC[201~`` marker is lost (SSH interruption, sleep/wake, torn write),
+    prompt_toolkit's ``Vt100Parser`` buffers every subsequent keystroke as
+    paste content forever, freezing the prompt.  This patch wraps
+    ``Vt100Parser.feed`` so that if the end marker has not arrived within a
+    short delay, the buffered text is delivered as a bracketed-paste key
+    press and the parser returns to ordinary input handling.
+
+    Self-contained and idempotent: safe to call multiple times.
+    """
+    import time
+
+    import prompt_toolkit.input.vt100_parser as _vt100_module
+
+    if getattr(_vt100_module, "_hermes_bp_timeout_patched", False):
+        return True
+
+    parser_cls = getattr(_vt100_module, "Vt100Parser", None)
+    if parser_cls is None or not hasattr(parser_cls, "feed"):
+        return False
+
+    original_feed = parser_cls.feed
+    if getattr(original_feed, "_hermes_bp_timeout_wrapped", False):
+        _vt100_module._hermes_bp_timeout_patched = True
+        return True
+
+    _PASTE_START = "\x1b[200~"
+    _PASTE_END = "\x1b[201~"
+    _PASTE_TIMEOUT = 2.0  # seconds without the end marker before recovery
+
+    def _feed_with_paste_timeout(self, data):
+        """``Vt100Parser.feed`` with stuck-paste recovery."""
+        if getattr(self, "_in_bracketed_paste", False):
+            start = getattr(self, "_hermes_bp_start", None)
+            if start is not None and (time.monotonic() - start) > _PASTE_TIMEOUT:
+                buffered = getattr(self, "_paste_buffer", "") or ""
+                combined = buffered + (data or "")
+                if _PASTE_END not in combined:
+                    # The end marker never arrived: deliver what we have as a
+                    # paste event and return to normal input handling.
+                    self._in_bracketed_paste = False
+                    self._paste_buffer = ""
+                    self._hermes_bp_start = None
+                    if buffered:
+                        try:
+                            from prompt_toolkit.key_binding.key_processor import KeyPress
+                            from prompt_toolkit.keys import Keys
+                            self.feed_key_callback(
+                                KeyPress(Keys.BracketedPaste, buffered)
+                            )
+                        except Exception:
+                            logger.debug(
+                                "bracketed paste timeout recovery failed",
+                                exc_info=True,
+                            )
+                    if data:
+                        # Parse the newly arrived data as ordinary input.
+                        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
+
+        original_feed(self, data)
+
+        # Record when bracketed-paste mode was entered so we can time out.
+        if (
+            getattr(self, "_in_bracketed_paste", False)
+            and getattr(self, "_hermes_bp_start", None) is None
+        ):
+            self._hermes_bp_start = time.monotonic()
+
+    _feed_with_paste_timeout._hermes_bp_timeout_wrapped = True
+    parser_cls.feed = _feed_with_paste_timeout
+    _vt100_module._hermes_bp_timeout_patched = True
+    return True
+
+
 def _strip_leaked_terminal_responses_with_meta(text: str) -> tuple[str, bool]:
     """Strip leaked terminal control-response sequences from user input.
 
@@ -7078,7 +7160,47 @@ class HermesCLI:
         # 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():
-            return self._prompt_text_input("Choice [1/2/3]: ")
+            # On Windows the raw-stdin fallback remains the only reliable
+            # path (issue #30768).  On Linux/macOS, hand the modal's setup
+            # and teardown to the running application's event loop via
+            # call_soon_threadsafe so the modal is shown and dismissed by
+            # the live prompt_toolkit app instead of a competing raw-stdin
+            # read that dead-locks against it.
+            response_queue = queue.Queue()
+            self._capture_modal_input_snapshot()
+
+            def _setup_modal() -> None:
+                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()
+
+            self._app.loop.call_soon_threadsafe(_setup_modal)
+
+            result = None
+            try:
+                try:
+                    result = response_queue.get(timeout=timeout)
+                except queue.Empty:
+                    result = None
+            finally:
+                def _teardown_modal() -> None:
+                    self._slash_confirm_state = None
+                    self._slash_confirm_deadline = 0
+                    self._invalidate()
+
+                try:
+                    self._app.loop.call_soon_threadsafe(_teardown_modal)
+                except RuntimeError:
+                    # Event loop already closed -- clear state directly.
+           
… [570 more characters]
step 1
$ ls /repo; ls /repo/*; grep -n "_prompt_text_input_modal\|_apply_bracketed_paste\|_capture_modal_input_snapshot" -r /repo --include=*.py | head -30
output · 69 lines
AGENTS.md
CONTRIBUTING.md
Dockerfile
LICENSE
MANIFEST.in
README.md
README.zh-CN.md
RELEASE_v0.10.0.md
RELEASE_v0.11.0.md
RELEASE_v0.12.0.md
RELEASE_v0.13.0.md
RELEASE_v0.14.0.md
57 more lines
RELEASE_v0.2.0.md
RELEASE_v0.3.0.md
RELEASE_v0.4.0.md
RELEASE_v0.5.0.md
RELEASE_v0.6.0.md
RELEASE_v0.7.0.md
RELEASE_v0.8.0.md
RELEASE_v0.9.0.md
SECURITY.md
acp_adapter
acp_registry
agent
assets
batch_runner.py
cli-config.yaml.example
cli.py
constraints-termux.txt
cron
datagen-config-examples
docker
docker-compose.yml
docs
flake.lock
flake.nix
gateway
hermes
hermes-already-has-routines.md
hermes_agent.egg-info
hermes_bootstrap.py
hermes_cli
hermes_constants.py
hermes_logging.py
hermes_state.py
hermes_time.py
infographic
locales
mcp_serve.py
mini_swe_runner.py
model_tools.py
nix
optional-skills
package-lock.json
package.json
packaging
plans
plugins
providers
pyproject.toml
run_agent.py
scripts
setup-hermes.sh
se
…[8629 chars omitted]…
n ``_prompt_text_input_modal`` returns None (timeout /
/repo/tests/cli/test_update_command.py:38:    ``_prompt_text_input_modal`` is stubbed to return *modal_response*
/repo/tests/cli/test_update_command.py:45:        _prompt_text_input_modal=lambda **_kw: modal_response,
/repo/tests/cli/test_update_command.py:70:        _prompt_text_input_modal=lambda **_kw: pytest.fail("Modal should not be called"),
step 2
$ sed -n 6990,7130p /repo/cli.py
output · 37 lines
def _ask():
            try:
                result[0] = input(prompt_text).strip() or None
            except (KeyboardInterrupt, EOFError):
                pass

        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
25 more lines
            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:
               
…[4664 chars omitted]…
               self._invalidate()
        return None

    def _submit_slash_confirm_response(self, value: str | None) -> None:
        state = self._slash_confirm_state
        if not state:
            return
        state["response_queue"].put(value)
        self._slash_confirm_state = None
        self._slash_confirm_deadline = 0
        self._invalidate()

    def _normalize_slash_confirm_choice(
step 3
$ python -c "import prompt_toolkit, inspect; print(prompt_toolkit.__version__); import prompt_toolkit.input.vt100_parser as m; print(inspect.getsource(m.Vt100Parser))"
output · 44 lines
3.0.52
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
        i = Vt100Parser(callback)
32 more lines
        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
        self._start_parser()

    def _start_parser(self) -> None:
        """
    
…[5186 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 4
$ grep -n "^logger\|^import\|^from" /repo/cli.py | head -40; grep -rn "bracketed_paste\|_apply_bracketed" /repo/tests /repo/cli.py /repo/hermes_cli | head; ls /repo/tests/cli | head -50
output · 43 lines
26:import logging
27:import os
28:import shutil
29:import sys
30:import json
31:import re
32:import concurrent.futures
33:import base64
34:import atexit
35:import errno
36:import tempfile
37:import time
31 more lines
38:import uuid
39:import textwrap
40:from collections import deque
41:from urllib.parse import unquote, urlparse
42:from contextlib import contextmanager
43:from pathlib import Path
44:from datetime import datetime
45:from typing import List, Dict, Any, Optional
47:logger = logging.getLogger(__name__)
52:import yaml
54:from hermes_cli.fallback_config import get_fallback_chain
57:from prompt_toolkit.history import FileHistory
58:from prompt_toolkit.styles import Style as PTStyle
59:from prompt_toolkit.patch_stdout import patch_stdout
60:from prompt_toolkit.application import Application
61:from prompt_toolkit.layout import Layout, HSplit, Window, FormattedTextControl, ConditionalContain
…[2993 chars omitted]…
li_status_command.py
test_cli_steer_busy_path.py
test_cli_terminal_response_sanitizer.py
test_cli_tools_command.py
test_cli_user_message_preview.py
test_compress_focus.py
test_cprint_bg_thread.py
test_ctrl_enter_newline.py
test_cwd_env_respect.py
test_destructive_slash_confirm.py
test_destructive_slash_inline_skip_e2e.py
test_exit_delete_session.py
test_exit_summary_resume_hint.py
test_fast_command.py
step 5
$ grep -n "def main\|Application(\|app.run()\|\.run()\|def _run_cli\|def run_cli\|create_input\|PromptSession" /repo/cli.py | head -30
output · 7 lines
14078:        app = Application(
14383:            # event loop unwind normally; `app.run()` returns and our
14410:            # would then unwind prompt_toolkit's app.run(), trigger
14507:                app.run()
14612:def main(
14797:    # mode registers its own inside HermesCLI.run(), but `-q` runs
14965:    cli.run()
step 6
$ sed -n 14060,14100p /repo/cli.py; sed -n 14480,14515p /repo/cli.py
output · 33 lines
'sudo-text': '#FFF8DC',
            # Dangerous command approval panel
            'approval-border': '#CD7F32',
            'approval-title': '#FF8C00 bold',
            'approval-desc': '#FFF8DC bold',
            'approval-cmd': '#AAAAAA italic',
            'approval-choice': '#AAAAAA',
            'approval-selected': '#FFD700 bold',
            # Voice mode
            'voice-prompt': '#87CEEB',
            'voice-recording': '#FF4444 bold',
            'voice-processing': '#FFA500 italic',
21 more lines
            'voice-status': 'bg:#1a1a2e #87CEEB',
            'voice-status-recording': 'bg:#1a1a2e #FF4444 bold',
        }
        style = PTStyle.from_dict(self._build_tui_style_dict())
        
        # Create the application
        app = Application(
            layout=layout,
            key_bindings=kb,
            style=style,
            full_screen=False,
            m
…[2346 chars omitted]…
enPipeError):
            pass
        except (KeyError, OSError) as _stdin_err:
            # Catch selector registration failures from broken stdin (#6393)
            # and I/O errors from broken stdout during interrupt (#13710).
            _errno = getattr(_stdin_err, "errno", None) if isinstance(_stdin_err, OSError) else None
            _msg = str(_stdin_err)
            if _errno == errno.EIO:
step 7
$ grep -n "_disable_prompt_toolkit_cpr_warning\|_strip_leaked_bracketed_paste_wrappers" /repo/cli.py | head
output · 6 lines
2337:def _strip_leaked_bracketed_paste_wrappers(text: str) -> str:
2443:def _disable_prompt_toolkit_cpr_warning(app) -> None:
13236:            pasted_text = _strip_leaked_bracketed_paste_wrappers(pasted_text)
13394:            text = _strip_leaked_bracketed_paste_wrappers(buf.text)
14086:        _disable_prompt_toolkit_cpr_warning(app)
14214:                        user_input = _strip_leaked_bracketed_paste_wrappers(user_input)
step 8
$ sed -n 2443,2470p /repo/cli.py
output · 28 lines
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
    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``
16 more lines
    and the visible ``^[[<row>;<col>R`` form. These are replies the terminal
    sends back to queries prompt_toolkit makes during ``_on_resize`` /
    ``_request_absolute_cursor_position``. When the input parser drops one
    (resize storms, multiplexer focus changes, slow PTYs) the response
    lands in the input buffer as literal text and corrupts what the user
    typed.

    Also strips leaked SGR mouse-report fragments (``ESC[<...M/m`` and
    degraded visible forms). Returns ``(cleaned_text, 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
step 9
$ cat > /tmp/patch_fn.py <<'PY'
output · 1 lines
ok
step 10
$ python - <<'PY'
output · 1 lines
2502:                                KeyPress(Keys.BracketedPaste, buffered)
step 11
$ python - <<'PY'
output · 2 lines
2451:def _apply_bracketed_paste_timeout_patch() -> bool:
14169:        _apply_bracketed_paste_timeout_patch()
step 12
$ cat > /tmp/repro_paste.py <<'PY'
output · 5 lines
Traceback (most recent call last):
  File "/tmp/repro_paste.py", line 28, in <module>
    assert len(pastes) == 1 and pastes[0].data == "hello world", pastes
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
AssertionError: []
step 13
$ python -c "from prompt_toolkit.input import KeyPress; print('ok')"; python -c "
output · 4 lines
Traceback (most recent call last):
  File "<string>", line 1, in <module>
ImportError: cannot import name 'KeyPress' from 'prompt_toolkit.input' (/usr/local/lib/python3.12/site-packages/prompt_toolkit/input/__init__.py)
<class 'prompt_toolkit.key_binding.key_processor.KeyPress'>
step 14
$ python - <<'PY'
output · 1 lines
paste patch OK
step 15
$ python - <<'PY'
output · 1 lines
ok
step 16
$ cat > /tmp/repro_modal.py <<'PY'
output · 1 lines
modal OK
step 17
$ python -m pytest 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_update_command.py tests/cli/test_cli_bracketed_paste_sanitizer.py -q 2>&1 | tail -15
output · 15 lines
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"
E       AssertionError: daemon thread hung — modal deadlocked
E       assert not True
E        +  where True = is_alive()
E        +    where is_alive = <Thread(Thread-1 (run_on_daemon), started daemon 127041939089088)>.is_alive

tests/cli/test_slash_confirm_windows.py:88: AssertionError
3 more lines
=========================== short test summary info ============================
FAILED tests/cli/test_slash_confirm_windows.py::TestModalWindowsFallback::test_non_main_thread_falls_back_to_stdin
1 failed, 58 passed in 6.66s
step 18
$ sed -n 50,110p tests/cli/test_slash_confirm_windows.py
output · 31 lines
class TestModalWindowsFallback:
    """Windows dead-lock regression tests for _prompt_text_input_modal."""

    def test_windows_falls_back_to_stdin(self):
        """On Windows, _prompt_text_input_modal should use _prompt_text_input."""
        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.",
19 more lines
                choices=_SAMPLE_CHOICES,
            )

        # The stdin-based fallback was used, not the modal queue path.
        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_
…[1531 chars omitted]…
ve a response
            # immediately via the queue.
            original_queue = queue.Queue
            original_time = time.monotonic

            def _fake_modal_flow(*args, **kwargs):
                """Simulate the modal flow: set state, put response, return."""
                # We'll directly test that the modal path is entered by
                # checking that _slash_confirm_state was set.
step 19
$ sed -n 1,50p tests/cli/test_slash_confirm_windows.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
…[373 chars omitted]…
-------------------------------------------
# Sample choices used across tests
# ---------------------------------------------------------------------------
_SAMPLE_CHOICES = [
    ("once", "Approve Once", "proceed this time only"),
    ("always", "Always Approve", "proceed and silence this prompt permanently"),
    ("cancel", "Cancel", "keep current conversation"),
]


class TestModalWindowsFallback:
step 20

The one failing test (`test_non_main_thread_falls_back_to_stdin`) encodes the exact old behavior the issue reverses — it expects the raw-stdin fallback off-main-thread on Linux, while the new contract forbids calling `_prompt_text_input` there. That's an expected, irreconcilable conflict with the new spec. Let me run the broader suite to check nothing else broke.

$ python -m pytest tests/cli -q -x --ignore=tests/cli/test_slash_confirm_windows.py 2>&1 | tail -5
output · 5 lines
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
=========================== short test summary info ============================
FAILED tests/cli/test_cli_preloaded_skills.py::test_show_banner_does_not_print_skills
!!!!!!!!!!!!!!!!!!!!!!!!!! stopping after 1 failures !!!!!!!!!!!!!!!!!!!!!!!!!!!
1 failed, 264 passed, 11 warnings in 211.43s (0:03:31)
step 21
$ git stash -q && python -m pytest tests/cli/test_cli_preloaded_skills.py::test_show_banner_does_not_print_skills -q 2>&1 | tail -3; git stash pop
output · 11 lines
=========================== short test summary info ============================
FAILED tests/cli/test_cli_preloaded_skills.py::test_show_banner_does_not_print_skills
1 failed in 31.47s
On branch master
Changes not staged for commit:
  (use "git add <file>..." to update what will be committed)
  (use "git restore <file>..." to discard changes in working directory)
	modified:   cli.py

no changes added to commit (use "git add" and/or "git commit -a")
Dropped refs/stash@{0} (7dfd336828015cc9d5656751fd39696064f1337b)
step 22

Pre-existing failure, unrelated. Running the rest of the suite without `-x`:

$ python -m pytest tests/cli -q --deselect tests/cli/test_cli_preloaded_skills.py::test_show_banner_does_not_print_skills 2>&1 | tail -6
output · 6 lines
FAILED tests/cli/test_quick_commands.py::TestGatewayQuickCommands::test_unsupported_type_returns_error
FAILED tests/cli/test_quick_commands.py::TestGatewayQuickCommands::test_timeout_returns_error
FAILED tests/cli/test_quick_commands.py::TestGatewayQuickCommands::test_gateway_config_object_supports_quick_commands
FAILED tests/cli/test_resume_quiet_stderr.py::TestResumeQuietStderr::test_session_not_found_goes_to_stdout_in_full_mode
FAILED tests/cli/test_slash_confirm_windows.py::TestModalWindowsFallback::test_non_main_thread_falls_back_to_stdin
13 failed, 784 passed, 1 deselected, 11 warnings in 212.12s (0:03:32)
step 23
$ python -m pytest tests/cli -q --deselect tests/cli/test_cli_preloaded_skills.py::test_show_banner_does_not_print_skills 2>&1 | grep FAILED > /tmp/after.txt; git stash -q; python -m pytest tests/cli -q --deselect tests/cli/test_cli_preloaded_skills.py::test_show_banner_does_not_print_skills 2>&1 | grep FAILED > /tmp/before.txt; git stash pop -q; diff /tmp/before.txt /tmp/after.txt; echo "---"; cat /tmp/after.txt
output · 16 lines
12a13
> FAILED tests/cli/test_slash_confirm_windows.py::TestModalWindowsFallback::test_non_main_thread_falls_back_to_stdin
---
FAILED tests/cli/test_personality_none.py::TestGatewayPersonalityNone::test_none_clears_ephemeral_prompt
FAILED tests/cli/test_personality_none.py::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
FAILED tests/cli/test_personality_none.py::TestGatewayPersonalityNone::test_empty_personality_list_uses_profile_display_path
FAILED tests/cli/test_quick_commands.py::TestGatewayQuickCommands::test_exec_command_returns_output
FAILED tests/cli/test_quick_commands.py::TestGatewayQuickCommands::test_exec_command_does_not_leak_credentials
FAILED tests/cli/t
…[231 chars omitted]…
4 more lines
estGatewayQuickCommands::test_timeout_returns_error
FAILED tests/cli/test_quick_commands.py::TestGatewayQuickCommands::test_gateway_config_object_supports_quick_commands
FAILED tests/cli/test_resume_quiet_stderr.py::TestResumeQuietStderr::test_session_not_found_goes_to_stdout_in_full_mode
FAILED tests/cli/test_slash_confirm_windows.py::TestModalWindowsFallback::test_non_main_thread_falls_back_to_stdin
step 24

Only the expected conflict is new; the other 12 failures are pre-existing. Reviewing the diff:

$ git diff --stat; git diff | head -200
output · 30 lines
cli.py | 125 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
 1 file changed, 124 insertions(+), 1 deletion(-)
diff --git a/cli.py b/cli.py
index ce4f8eb..d968049 100644
--- a/cli.py
+++ b/cli.py
@@ -2448,6 +2448,88 @@ def _disable_prompt_toolkit_cpr_warning(app) -> None:
         pass
 
 
+def _apply_bracketed_paste_timeout_patch() -> bool:
+    """Recover from incomplete bracketed paste sequences.
18 more lines
+
+    When a terminal starts a bracketed paste (``ESC[200~``) but the closing
+    ``ESC[201~`` marker is lost (SSH interruption, sleep/wake, torn write),
+    prompt_toolkit's ``Vt100Parser`` buffers every subsequent keystroke as
+    paste content forever, freezing the prompt.  This patch wraps
+    ``Vt100Parser.feed`` so that if the end marker has not arrived within a
+    short delay, the buffered text is delivered as a bracketed-paste key
+    press and the parser
…[5399 chars omitted]…
apture_modal_input_snapshot()
@@ -14084,6 +14206,7 @@ class HermesCLI:
             **({'cursor': _STEADY_CURSOR} if _STEADY_CURSOR is not None else {}),
         )
         _disable_prompt_toolkit_cpr_warning(app)
+        _apply_bracketed_paste_timeout_patch()
         self._app = app  # Store reference for clarify_callback
 
         # ── Fix ghost status-bar lines on terminal resize ──────────────
step 25
$ python -c "import ast; ast.parse(open('cli.py').read()); print('syntax ok')" && python /tmp/repro_paste.py && python /tmp/repro_modal.py
output · 3 lines
syntax ok
paste patch OK
modal OK
step 26
$ done: {"notes": "Added `_apply_bracketed_paste_timeout_patch()` in cli.py: a self-contained, idempotent wrapper around prompt_toolkit's Vt100Parser.feed that records `_hermes_bp_start` on entering bracketed-paste mode and, if the ESC[201~ end marker hasn't arrived within 2s, delivers the buffered text as a BracketedPaste key press, exits
output · 3 lines
…[467 chars omitted]…
ntracts pass; full tests/cli run shows only pre-existing failures plus the one outdated test that pins the old stdin-fallback behavior the issue explicitly reverses."}
submitted.