nousresearch-hermes-agent-32018-33241
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 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]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]$ 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),
$ 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 = []$ 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()$ grep -n "^logger\|^import time\|^import sys\|_patch\|def main(" /repo/cli.py | head -30; grep -rn "bracketed" /repo/cli.py | headoutput · 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.
$ 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$ 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
$ python - <<'PY'
output · 1 lines
OK
$ python - <<'PY'
output · 1 lines
OK
$ 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'$ 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']
$ 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'$ 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 OKPaste patch works. Now the modal fix:
$ python - <<'PY'
output · 1 lines
OK
$ cd /tmp && cat > repro_modal.py <<'PY'
output · 3 lines
1813:class _SkinAwareAnsi: 2612:class ChatConsole: 2881:class HermesCLI:
$ cd /tmp && cat > repro_modal.py <<'PY'
output · 1 lines
MODAL OK
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
$ 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: AssertionError3 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
$ 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.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
$ 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
$ 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"$ cd /repo && python - <<'PY'
output · 2 lines
....................................... [100%] 39 passed in 2.13s
$ 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)
$ 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)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"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
$ 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]$ 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$ 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 = []$ 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
$ 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.)$ 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()$ 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 -20output · 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()
$ grep -n "def main\|if __name__\|def run(\|def _run_cli\|Application(" /repo/cli.py | head; sed -n 2320,2360p /repo/cli.pyoutput · 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$ 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:Now let me add the paste-timeout patch function to cli.py:
$ cat > /tmp/patch_fn.py <<'EOF'
output · 1 lines
OK
Now hook the patch call into `run()` and fix the modal:
$ cd /repo && python - <<'PY'
output · 1 lines
OK
$ 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')]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$ 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
Now test the modal path:
$ cd /repo && cat > /tmp/test_modal.py <<'EOF'
output · 1 lines
ALL MODAL TESTS PASS
$ 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
$ 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)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)
$ 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
$ 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
$ 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$ 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(-)
$ 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
$ 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
+
+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]$ 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"),$ 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 = False25 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:$ 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$ 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$ 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:$ grep -n "PromptSession\|create_input\|vt100\|_apply_.*patch\|def main\|Application(" /repo/cli.py | head -30output · 2 lines
14078: app = Application( 14612:def main(
$ 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).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$ cat > /tmp/bp_patch.py <<'EOF'
output · 1 lines
True
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')]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')]$ sed -i '28s/len(events) == 1/len(events) == 2/' /tmp/test_bp.py && python /tmp/test_bp.py
output · 1 lines
OK
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
$ 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.$ python - <<'PY'
output · 1 lines
ok
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:$ 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$ 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]: ")$ 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()
$ python - <<'PY'
output · 1 lines
ok
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$ cd /tmp && python - <<'EOF'
output · 1 lines
{'r': 'bad'}$ 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() + timeout27 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'}$ 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 the11 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]: ")$ python - <<'PY'
output · 1 lines
OK
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
$ 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
$ 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)$ 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
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)
$ 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)
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$ python - <<'PY'
output · 1 lines
standalone OK
$ 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 matching16 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$ 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-thread15 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: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_toutput · 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]$ 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"),$ 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 = False25 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($ 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()$ 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
$ 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()
$ 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:$ 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)
$ 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$ cat > /tmp/patch_fn.py <<'PY'
output · 1 lines
ok
$ python - <<'PY'
output · 1 lines
2502: KeyPress(Keys.BracketedPaste, buffered)
$ python - <<'PY'
output · 2 lines
2451:def _apply_bracketed_paste_timeout_patch() -> bool: 14169: _apply_bracketed_paste_timeout_patch()
$ 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: []$ 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'>
$ python - <<'PY'
output · 1 lines
paste patch OK
$ python - <<'PY'
output · 1 lines
ok
$ cat > /tmp/repro_modal.py <<'PY'
output · 1 lines
modal OK
$ 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: AssertionError3 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
$ 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.$ 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: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)
$ 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)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)
$ 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
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 ──────────────$ python -c "import ast; ast.parse(open('cli.py').read()); print('syntax ok')" && python /tmp/repro_paste.py && python /tmp/repro_modal.pyoutput · 3 lines
syntax ok paste patch OK modal OK
$ 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, exitsoutput · 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.