Merge origin/main into bb/gui

This commit is contained in:
Brooklyn Nicholson
2026-05-27 21:22:14 -05:00
918 changed files with 153852 additions and 7510 deletions
+313 -44
View File
@@ -562,13 +562,12 @@ def load_cli_config() -> Dict[str, Any]:
"singularity_image": "TERMINAL_SINGULARITY_IMAGE",
"modal_image": "TERMINAL_MODAL_IMAGE",
"daytona_image": "TERMINAL_DAYTONA_IMAGE",
"vercel_runtime": "TERMINAL_VERCEL_RUNTIME",
# SSH config
"ssh_host": "TERMINAL_SSH_HOST",
"ssh_user": "TERMINAL_SSH_USER",
"ssh_port": "TERMINAL_SSH_PORT",
"ssh_key": "TERMINAL_SSH_KEY",
# Container resource config (docker, singularity, modal, daytona, vercel_sandbox -- ignored for local/ssh)
# Container resource config (docker, singularity, modal, daytona -- ignored for local/ssh)
"container_cpu": "TERMINAL_CONTAINER_CPU",
"container_memory": "TERMINAL_CONTAINER_MEMORY",
"container_disk": "TERMINAL_CONTAINER_DISK",
@@ -2360,6 +2359,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
@@ -3419,8 +3501,29 @@ class HermesCLI:
"session_total_tokens": 0,
"session_api_calls": 0,
"compressions": 0,
"active_background_tasks": 0,
"active_background_processes": 0,
}
# Count live /background tasks. The dict entry is removed in the
# task thread's finally block, so len() reflects truly-running tasks.
# len() on a CPython dict is atomic; safe to read without a lock.
try:
bg_tasks = getattr(self, "_background_tasks", None)
if bg_tasks:
snapshot["active_background_tasks"] = len(bg_tasks)
except Exception:
pass
# Count live background terminal processes (terminal tool background
# sessions tracked by tools.process_registry). Cheap O(1) read.
try:
from tools.process_registry import process_registry
snapshot["active_background_processes"] = process_registry.count_running()
except Exception:
pass
if not agent:
return snapshot
@@ -3656,6 +3759,12 @@ class HermesCLI:
compressions = snapshot.get("compressions", 0)
if compressions:
parts.append(f"🗜️ {compressions}")
bg_count = snapshot.get("active_background_tasks", 0)
if bg_count:
parts.append(f"{bg_count}")
bg_proc_count = snapshot.get("active_background_processes", 0)
if bg_proc_count:
parts.append(f"{bg_proc_count}")
parts.append(duration_label)
if yolo_active:
parts.append("⚠ YOLO")
@@ -3672,6 +3781,12 @@ class HermesCLI:
parts = [f"{snapshot['model_short']}", context_label, percent_label]
if compressions:
parts.append(f"🗜️ {compressions}")
bg_count = snapshot.get("active_background_tasks", 0)
if bg_count:
parts.append(f"{bg_count}")
bg_proc_count = snapshot.get("active_background_processes", 0)
if bg_proc_count:
parts.append(f"{bg_proc_count}")
parts.append(duration_label)
prompt_elapsed = snapshot.get("prompt_elapsed")
if prompt_elapsed:
@@ -3712,6 +3827,8 @@ class HermesCLI:
percent_label = f"{percent}%" if percent is not None else "--"
if width < 76:
compressions = snapshot.get("compressions", 0)
bg_count = snapshot.get("active_background_tasks", 0)
bg_proc_count = snapshot.get("active_background_processes", 0)
frags = [
("class:status-bar", ""),
("class:status-bar-strong", snapshot["model_short"]),
@@ -3721,6 +3838,12 @@ class HermesCLI:
if compressions:
frags.append(("class:status-bar-dim", " · "))
frags.append((self._compression_count_style(compressions), f"🗜️ {compressions}"))
if bg_count:
frags.append(("class:status-bar-dim", " · "))
frags.append(("class:status-bar-strong", f"{bg_count}"))
if bg_proc_count:
frags.append(("class:status-bar-dim", " · "))
frags.append(("class:status-bar-strong", f"{bg_proc_count}"))
frags.extend([
("class:status-bar-dim", " · "),
("class:status-bar-dim", duration_label),
@@ -3739,6 +3862,8 @@ class HermesCLI:
bar_style = self._status_bar_context_style(percent)
compressions = snapshot.get("compressions", 0)
bg_count = snapshot.get("active_background_tasks", 0)
bg_proc_count = snapshot.get("active_background_processes", 0)
frags = [
("class:status-bar", ""),
("class:status-bar-strong", snapshot["model_short"]),
@@ -3752,6 +3877,12 @@ class HermesCLI:
if compressions:
frags.append(("class:status-bar-dim", ""))
frags.append((self._compression_count_style(compressions), f"🗜️ {compressions}"))
if bg_count:
frags.append(("class:status-bar-dim", ""))
frags.append(("class:status-bar-strong", f"{bg_count}"))
if bg_proc_count:
frags.append(("class:status-bar-dim", ""))
frags.append(("class:status-bar-strong", f"{bg_proc_count}"))
frags.extend([
("class:status-bar-dim", ""),
("class:status-bar-dim", duration_label),
@@ -4731,9 +4862,22 @@ class HermesCLI:
# is non-empty and we skip the DB round-trip.
if self._resumed and self._session_db and not self.conversation_history:
session_meta = self._session_db.get_session(self.session_id)
# In quiet mode (`hermes chat -Q` / --quiet, surfaced via
# tool_progress_mode == "off"), resume status lines go to stderr
# so stdout stays machine-readable for automation wrappers that
# do `$(hermes chat -Q --resume <id> -q "...")`. Without this,
# the resume banner pollutes captured stdout. See #11793.
_quiet_mode = getattr(self, "tool_progress_mode", "full") == "off"
if not session_meta:
_cprint(f"\033[1;31mSession not found: {self.session_id}{_RST}")
_cprint(f"{_DIM}Use a session ID from a previous CLI run (hermes sessions list).{_RST}")
if _quiet_mode:
print(f"Session not found: {self.session_id}", file=sys.stderr)
print(
"Use a session ID from a previous CLI run (hermes sessions list).",
file=sys.stderr,
)
else:
_cprint(f"\033[1;31mSession not found: {self.session_id}{_RST}")
_cprint(f"{_DIM}Use a session ID from a previous CLI run (hermes sessions list).{_RST}")
return False
# If the requested session is the (empty) head of a compression
# chain, walk to the descendant that actually holds the messages.
@@ -4760,16 +4904,30 @@ class HermesCLI:
title_part = ""
if session_meta.get("title"):
title_part = f" \"{session_meta['title']}\""
ChatConsole().print(
f"[bold {_accent_hex()}]↻ Resumed session[/] "
f"[bold]{_escape(self.session_id)}[/]"
f"[bold {_accent_hex()}]{_escape(title_part)}[/] "
f"({msg_count} user message{'s' if msg_count != 1 else ''}, {len(restored)} total messages)"
)
if _quiet_mode:
print(
f"↻ Resumed session {self.session_id}{title_part} "
f"({msg_count} user message{'s' if msg_count != 1 else ''}, "
f"{len(restored)} total messages)",
file=sys.stderr,
)
else:
ChatConsole().print(
f"[bold {_accent_hex()}]↻ Resumed session[/] "
f"[bold]{_escape(self.session_id)}[/]"
f"[bold {_accent_hex()}]{_escape(title_part)}[/] "
f"({msg_count} user message{'s' if msg_count != 1 else ''}, {len(restored)} total messages)"
)
else:
ChatConsole().print(
f"[bold {_accent_hex()}]Session {_escape(self.session_id)} found but has no messages. Starting fresh.[/]"
)
if _quiet_mode:
print(
f"Session {self.session_id} found but has no messages. Starting fresh.",
file=sys.stderr,
)
else:
ChatConsole().print(
f"[bold {_accent_hex()}]Session {_escape(self.session_id)} found but has no messages. Starting fresh.[/]"
)
# Re-open the session (clear ended_at so it's active again)
try:
self._session_db._conn.execute(
@@ -4933,20 +5091,22 @@ class HermesCLI:
if os.environ.get("HERMES_DEFER_AGENT_STARTUP") != "1":
self._show_tool_availability_warnings()
# Warn about very low context lengths (common with local servers)
if ctx_len and ctx_len <= 8192:
# Warn about low context lengths (common with local servers). Keep
# this tied to the runtime guard so guidance cannot drift again.
from agent.model_metadata import MINIMUM_CONTEXT_LENGTH
if ctx_len and ctx_len < MINIMUM_CONTEXT_LENGTH:
self._console_print()
self._console_print(
f"[yellow]⚠️ Context length is only {ctx_len:,} tokens — "
f"this is likely too low for agent use with tools.[/]"
)
self._console_print(
"[dim] Hermes needs 16k32k minimum. Tool schemas + system prompt alone use ~4k8k.[/]"
f"[dim] Hermes needs at least {MINIMUM_CONTEXT_LENGTH:,} tokens. Tool schemas + system prompt use a large fixed prefix.[/]"
)
base_url = getattr(self, "base_url", "") or ""
if "11434" in base_url or "ollama" in base_url.lower():
self._console_print(
"[dim] Ollama fix: OLLAMA_CONTEXT_LENGTH=32768 ollama serve[/]"
f"[dim] Ollama fix: OLLAMA_CONTEXT_LENGTH={MINIMUM_CONTEXT_LENGTH} ollama serve[/]"
)
elif "1234" in base_url:
self._console_print(
@@ -6482,6 +6642,19 @@ class HermesCLI:
parts = cmd_original.split(None, 1)
target = parts[1].strip() if len(parts) > 1 else ""
# Strip common outer brackets/quotes users may type literally from the
# usage hint (e.g. ``/resume <abc123>`` or ``/resume [abc123]``). The
# `/resume` help text shows angle brackets as a placeholder and a few
# users copy them through verbatim. Stripping them keeps the lookup
# working without changing the help string.
if len(target) >= 2 and (
(target[0] == "<" and target[-1] == ">")
or (target[0] == "[" and target[-1] == "]")
or (target[0] == '"' and target[-1] == '"')
or (target[0] == "'" and target[-1] == "'")
):
target = target[1:-1].strip()
if not target:
_cprint(" Usage: /resume <number|session_id_or_title>")
if self._show_recent_sessions(reason="resume"):
@@ -6949,7 +7122,30 @@ class HermesCLI:
could be interpreted as EOF/exit. A first-class modal state keeps the
choices visible and lets the normal Enter key binding submit the typed
or highlighted choice.
**Platform note (Windows dead-lock issue #30768):**
The queue-based modal relies on prompt_toolkit key bindings receiving
keyboard events and calling ``_submit_slash_confirm_response``. On
Windows (PowerShell / Windows Terminal) the prompt_toolkit input
channel can become unresponsive when the modal is entered from the
``process_loop`` daemon thread, causing a dead-lock: the user sees the
confirmation panel but keystrokes never reach the key bindings and the
``response_queue.get()`` blocks until the 120-second timeout expires.
To avoid this, we fall back to ``_prompt_text_input`` (a simple
``input()``-based prompt) when any of these conditions hold:
* ``sys.platform == "win32"`` native Windows console (ConPTY /
win32_input) does not support the modal reliably.
* ``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
if not choices:
@@ -6960,27 +7156,70 @@ class HermesCLI:
if not getattr(self, "_app", None):
return self._prompt_text_input("Choice [1/2/3]: ")
# On Windows the prompt_toolkit input channel can deadlock when the
# modal is entered from the process_loop daemon thread — keystrokes
# 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]: ")
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_state = {
"title": title,
"detail": detail,
"choices": choices,
"selected": 0,
"response_queue": response_queue,
}
self._slash_confirm_deadline = _time.monotonic() + timeout
self._invalidate()
def _teardown_modal() -> None:
self._slash_confirm_state = None
self._slash_confirm_deadline = 0
self._restore_modal_input_snapshot()
self._invalidate()
def _run_on_app_loop(fn) -> bool:
if in_main_thread or app_loop is None:
fn()
return True
ready = threading.Event()
def _wrapped() -> None:
try:
fn()
finally:
ready.set()
try:
app_loop.call_soon_threadsafe(_wrapped)
except Exception:
return False
return ready.wait(timeout=5)
if not _run_on_app_loop(_setup_modal):
return self._prompt_text_input("Choice [1/2/3]: ")
_last_countdown_refresh = _time.monotonic()
try:
while True:
try:
result = response_queue.get(timeout=1)
self._slash_confirm_state = None
self._slash_confirm_deadline = 0
self._restore_modal_input_snapshot()
self._invalidate()
_run_on_app_loop(_teardown_modal)
return result
except queue.Empty:
remaining = self._slash_confirm_deadline - _time.monotonic()
@@ -6992,10 +7231,7 @@ class HermesCLI:
self._invalidate()
finally:
if self._slash_confirm_state is not None:
self._slash_confirm_state = None
self._slash_confirm_deadline = 0
self._restore_modal_input_snapshot()
self._invalidate()
_run_on_app_loop(_teardown_modal)
return None
def _submit_slash_confirm_response(self, value: str | None) -> None:
@@ -11896,9 +12132,22 @@ class HermesCLI:
pass
print("Resume this session with:")
print(f" hermes --resume {self.session_id}")
# Session IDs are profile-constrained, so the resume hint must
# include `-p <profile>` for non-default profiles. Without this,
# copying the hint from a non-default profile fails to find the
# session on the next invocation. The "default" and "custom"
# profile names use the standard HERMES_HOME, so no -p needed.
try:
from hermes_cli.profiles import get_active_profile_name
_active_profile = get_active_profile_name()
except Exception:
_active_profile = "default"
profile_flag = (
"" if _active_profile in ("default", "custom") else f" -p {_active_profile}"
)
print(f" hermes --resume {self.session_id}{profile_flag}")
if session_title:
print(f" hermes -c \"{session_title}\"")
print(f" hermes -c \"{session_title}\"{profile_flag}")
print()
print(f"Session: {self.session_id}")
if session_title:
@@ -13112,7 +13361,11 @@ class HermesCLI:
pasted_text = _sanitize_surrogates(pasted_text)
line_count = pasted_text.count('\n')
buf = event.current_buffer
if line_count >= 5 and not buf.text.strip().startswith('/'):
threshold = self.config.get("paste_collapse_threshold", 5)
char_threshold = self.config.get("paste_collapse_char_threshold", 2000)
lines_hit = threshold > 0 and line_count >= threshold
chars_hit = char_threshold > 0 and len(pasted_text) >= char_threshold
if (lines_hit or chars_hit) and not buf.text.strip().startswith('/'):
_paste_counter[0] += 1
paste_dir = _hermes_home / "pastes"
paste_dir.mkdir(parents=True, exist_ok=True)
@@ -13281,7 +13534,11 @@ class HermesCLI:
newlines_added = line_count - _prev_newline_count[0]
_prev_newline_count[0] = line_count
is_paste = chars_added > 1 or newlines_added >= 4
if line_count >= 5 and is_paste and not text.startswith('/'):
threshold = self.config.get("paste_collapse_threshold_fallback", 5)
char_threshold = self.config.get("paste_collapse_char_threshold", 2000)
lines_hit = threshold > 0 and line_count >= threshold
chars_hit = char_threshold > 0 and len(text) >= char_threshold
if (lines_hit or chars_hit) and is_paste and not text.startswith('/'):
_paste_counter[0] += 1
paste_dir = _hermes_home / "pastes"
paste_dir.mkdir(parents=True, exist_ok=True)
@@ -14018,6 +14275,10 @@ class HermesCLI:
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():
@@ -14102,11 +14363,19 @@ class HermesCLI:
if not _file_drop and isinstance(user_input, str) and _looks_like_slash_command(user_input):
_cprint(f"\n⚙️ {user_input}")
if not self.process_command(user_input):
self._should_exit = True
# Schedule app exit
if app.is_running:
app.exit()
try:
if not self.process_command(user_input):
self._should_exit = True
# Schedule app exit
if app.is_running:
app.exit()
except KeyboardInterrupt:
# Ctrl+C during a slow slash command (e.g. /skills browse,
# /sessions list with a large DB) should interrupt the
# command and return to the prompt, NOT exit the entire
# session. Without this guard a KeyboardInterrupt unwinds
# to the outer prompt_toolkit loop and the session dies.
_cprint("\n[dim]Command interrupted.[/dim]")
continue
# Expand paste references back to full content