Merge remote-tracking branch 'origin/main' into bb/gui
# Conflicts: # apps/dashboard/package-lock.json # apps/dashboard/package.json # apps/dashboard/src/components/BottomPickSheet.tsx # apps/dashboard/src/hooks/useBelowBreakpoint.ts # gateway/platforms/telegram.py # hermes_cli/gateway.py # hermes_cli/web_server.py # nix/web.nix # scripts/install.ps1 # tests/gateway/test_telegram_thread_fallback.py # tui_gateway/server.py
This commit is contained in:
@@ -655,9 +655,58 @@ except Exception:
|
||||
# which, during CLI idle time, finds prompt_toolkit's event loop and tries to
|
||||
# close TCP transports bound to dead worker loops — producing
|
||||
# "Event loop is closed" / "Press ENTER to continue..." errors.
|
||||
#
|
||||
# We install a sys.meta_path finder that defers the actual import + patch
|
||||
# until ``openai._base_client`` is first loaded by the rest of the codebase.
|
||||
# Eagerly importing it here (the old approach) cost ~166ms / ~30MB on every
|
||||
# cold CLI start because openai's type tree (responses/*, graders/*) is huge.
|
||||
# The finder approach pays nothing until the SDK is genuinely needed and
|
||||
# still guarantees the patch is applied before any AsyncOpenAI instance can
|
||||
# be constructed (the import-then-instantiate ordering is enforced by
|
||||
# Python's import system).
|
||||
try:
|
||||
from agent.auxiliary_client import neuter_async_httpx_del
|
||||
neuter_async_httpx_del()
|
||||
import sys as _httpx_neuter_sys
|
||||
import importlib.util as _httpx_neuter_imp_util
|
||||
|
||||
class _AsyncHttpxDelNeuter:
|
||||
"""Defer ``AsyncHttpxClientWrapper.__del__`` neutering until import.
|
||||
|
||||
Saves ~166ms on cold CLI start where openai is never used (e.g.
|
||||
``hermes --help`` paths inside the chat command flow). See
|
||||
``agent.auxiliary_client.neuter_async_httpx_del`` for full rationale
|
||||
on why ``__del__`` must be a no-op.
|
||||
"""
|
||||
|
||||
_armed = True
|
||||
|
||||
def find_spec(self, fullname, path=None, target=None):
|
||||
if not self._armed or fullname != "openai._base_client":
|
||||
return None
|
||||
# Disarm before delegating so the recursive find_spec call
|
||||
# below doesn't loop through us.
|
||||
self._armed = False
|
||||
try:
|
||||
_httpx_neuter_sys.meta_path.remove(self)
|
||||
except ValueError:
|
||||
pass
|
||||
spec = _httpx_neuter_imp_util.find_spec(fullname)
|
||||
if spec is None or spec.loader is None:
|
||||
return None
|
||||
_orig_exec = spec.loader.exec_module
|
||||
|
||||
def _patched_exec(module):
|
||||
_orig_exec(module)
|
||||
try:
|
||||
cls = getattr(module, "AsyncHttpxClientWrapper", None)
|
||||
if cls is not None:
|
||||
cls.__del__ = lambda self: None # type: ignore[assignment]
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
spec.loader.exec_module = _patched_exec # type: ignore[method-assign]
|
||||
return spec
|
||||
|
||||
_httpx_neuter_sys.meta_path.insert(0, _AsyncHttpxDelNeuter())
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@@ -940,6 +989,37 @@ def _setup_worktree(repo_root: str = None) -> Optional[Dict[str, str]]:
|
||||
return info
|
||||
|
||||
|
||||
def _worktree_has_unpushed_commits(worktree_path: str, timeout: int = 10) -> bool:
|
||||
"""Return whether a worktree has commits not reachable from any remote branch.
|
||||
|
||||
``git log HEAD --not --remotes`` compares against remote-tracking refs under
|
||||
``refs/remotes/*``. If a repo has no remote-tracking refs yet, there is no
|
||||
usable remote baseline to compare against, so treat it as having no
|
||||
"unpushed" commits.
|
||||
"""
|
||||
import subprocess
|
||||
|
||||
try:
|
||||
remote_refs = subprocess.run(
|
||||
["git", "for-each-ref", "--format=%(refname)", "refs/remotes"],
|
||||
capture_output=True, text=True, timeout=timeout, cwd=worktree_path,
|
||||
)
|
||||
if remote_refs.returncode != 0:
|
||||
return True
|
||||
if not remote_refs.stdout.strip():
|
||||
return False
|
||||
|
||||
result = subprocess.run(
|
||||
["git", "log", "--oneline", "HEAD", "--not", "--remotes"],
|
||||
capture_output=True, text=True, timeout=timeout, cwd=worktree_path,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
return True
|
||||
return bool(result.stdout.strip())
|
||||
except Exception:
|
||||
return True
|
||||
|
||||
|
||||
def _cleanup_worktree(info: Dict[str, str] = None) -> None:
|
||||
"""Remove a worktree and its branch on exit.
|
||||
|
||||
@@ -962,18 +1042,7 @@ def _cleanup_worktree(info: Dict[str, str] = None) -> None:
|
||||
if not Path(wt_path).exists():
|
||||
return
|
||||
|
||||
# Check for unpushed commits — commits reachable from HEAD but not
|
||||
# from any remote branch. These represent real work the agent did
|
||||
# but didn't push.
|
||||
has_unpushed = False
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["git", "log", "--oneline", "HEAD", "--not", "--remotes"],
|
||||
capture_output=True, text=True, timeout=10, cwd=wt_path,
|
||||
)
|
||||
has_unpushed = bool(result.stdout.strip())
|
||||
except Exception:
|
||||
has_unpushed = True # Assume unpushed on error — don't delete
|
||||
has_unpushed = _worktree_has_unpushed_commits(wt_path, timeout=10)
|
||||
|
||||
if has_unpushed:
|
||||
print(f"\n\033[33m⚠ Worktree has unpushed commits, keeping: {wt_path}\033[0m")
|
||||
@@ -1121,15 +1190,8 @@ def _prune_stale_worktrees(repo_root: str, max_age_hours: int = 24) -> None:
|
||||
|
||||
if not force:
|
||||
# 24h–72h tier: only remove if no unpushed commits
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["git", "log", "--oneline", "HEAD", "--not", "--remotes"],
|
||||
capture_output=True, text=True, timeout=5, cwd=str(entry),
|
||||
)
|
||||
if result.stdout.strip():
|
||||
continue # Has unpushed commits — skip
|
||||
except Exception:
|
||||
continue # Can't check — skip
|
||||
if _worktree_has_unpushed_commits(str(entry), timeout=5):
|
||||
continue # Has unpushed commits or can't check — skip
|
||||
|
||||
# Safe to remove
|
||||
try:
|
||||
@@ -1569,7 +1631,14 @@ def _rich_text_from_ansi(text: str) -> _RichText:
|
||||
def _strip_markdown_syntax(text: str) -> str:
|
||||
"""Best-effort markdown marker removal for plain-text display."""
|
||||
plain = _rich_text_from_ansi(text or "").plain
|
||||
plain = re.sub(r"^\s{0,3}(?:[-*_]\s*){3,}$", "", plain, flags=re.MULTILINE)
|
||||
# Avoid stripping cron-style expressions like "* * * * *" as if they were
|
||||
# Markdown horizontal rules. CommonMark treats three or more "*" as an HR,
|
||||
# but in Hermes output it's common to display cron schedules verbatim.
|
||||
#
|
||||
# Keep the behavior for "-" / "_" HR markers, and only strip "*" HR lines
|
||||
# when there are exactly 3 asterisks (with optional whitespace).
|
||||
plain = re.sub(r"^\s{0,3}(?:[-_]\s*){3,}$", "", plain, flags=re.MULTILINE)
|
||||
plain = re.sub(r"^\s{0,3}(?:\*\s*){3}\s*$", "", plain, flags=re.MULTILINE)
|
||||
plain = re.sub(r"^\s{0,3}#{1,6}\s+", "", plain, flags=re.MULTILINE)
|
||||
# Preserve blockquotes, lists, and checkboxes because they carry structure.
|
||||
plain = re.sub(r"(```+|~~~+)", "", plain)
|
||||
@@ -1580,7 +1649,9 @@ def _strip_markdown_syntax(text: str) -> str:
|
||||
plain = re.sub(r"(?<!\w)___([^_]+)___(?!\w)", r"\1", plain)
|
||||
plain = re.sub(r"\*\*([^*]+)\*\*", r"\1", plain)
|
||||
plain = re.sub(r"(?<!\w)__([^_]+)__(?!\w)", r"\1", plain)
|
||||
plain = re.sub(r"\*([^*]+)\*", r"\1", plain)
|
||||
# Only strip `*emphasis*` markers when the inner text is non-whitespace.
|
||||
# This avoids corrupting cron expressions like "* * * * *".
|
||||
plain = re.sub(r"\*([^\s*][^*]*?[^\s*])\*", r"\1", plain)
|
||||
plain = re.sub(r"(?<!\w)_([^_]+)_(?!\w)", r"\1", plain)
|
||||
plain = re.sub(r"~~([^~]+)~~", r"\1", plain)
|
||||
plain = re.sub(r"\n{3,}", "\n\n", plain)
|
||||
@@ -1785,7 +1856,16 @@ def _cprint(text: str):
|
||||
# direct prompt_toolkit print is safe and matches existing behavior
|
||||
# (spinner frames, streamed tokens, tool activity prefixes, …).
|
||||
if app is None or not getattr(app, "_is_running", False):
|
||||
_pt_print(_PT_ANSI(text))
|
||||
try:
|
||||
_pt_print(_PT_ANSI(text))
|
||||
except Exception:
|
||||
# Fallback when stdout is not a real console (e.g. subprocess
|
||||
# worker logging to a file). prompt_toolkit raises
|
||||
# NoConsoleScreenBufferError (Windows) or OSError (other).
|
||||
try:
|
||||
print(text)
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
try:
|
||||
@@ -1817,13 +1897,26 @@ def _cprint(text: str):
|
||||
# prompt, prints, and redraws. Fire-and-forget — if scheduling
|
||||
# fails we fall back to a direct print so the line isn't lost.
|
||||
def _schedule():
|
||||
# run_in_terminal() may return either:
|
||||
# • a coroutine / Future (prompt_toolkit ≥ 3.0) — must be scheduled
|
||||
# via ensure_future so the coroutine is actually awaited; calling
|
||||
# it bare would leave it unawaited and silently drop the output
|
||||
# (fixes #23185 Bug A).
|
||||
# • None (some mocks / older PT builds) — just call the inner
|
||||
# function directly since PT already executed it synchronously.
|
||||
# Do NOT fall back to a bare _pt_print when ensure_future raises,
|
||||
# because run_in_terminal already invoked the lambda in that case
|
||||
# (the mock path), which would double-print the line.
|
||||
try:
|
||||
run_in_terminal(lambda: _pt_print(_PT_ANSI(text)))
|
||||
import asyncio as _aio
|
||||
import inspect as _inspect
|
||||
coro = run_in_terminal(lambda: _pt_print(_PT_ANSI(text)))
|
||||
if coro is not None and (_inspect.isawaitable(coro) or _inspect.iscoroutine(coro)):
|
||||
_aio.ensure_future(coro)
|
||||
# else: run_in_terminal ran the lambda synchronously; nothing more
|
||||
# to do (double-scheduling would print twice).
|
||||
except Exception:
|
||||
try:
|
||||
_pt_print(_PT_ANSI(text))
|
||||
except Exception:
|
||||
pass
|
||||
pass # best-effort; the line may already have been printed
|
||||
|
||||
try:
|
||||
loop.call_soon_threadsafe(_schedule)
|
||||
@@ -2416,8 +2509,13 @@ from agent.skill_commands import (
|
||||
build_skill_invocation_message,
|
||||
build_preloaded_skills_prompt,
|
||||
)
|
||||
from agent.skill_bundles import (
|
||||
get_skill_bundles,
|
||||
build_bundle_invocation_message,
|
||||
)
|
||||
|
||||
_skill_commands = scan_skill_commands()
|
||||
_skill_bundles = get_skill_bundles()
|
||||
|
||||
|
||||
def _get_plugin_cmd_handler_names() -> set:
|
||||
@@ -2830,6 +2928,11 @@ class HermesCLI:
|
||||
# process_command() when the user runs /exit --delete or /quit --delete.
|
||||
# Ported from google-gemini/gemini-cli#19332.
|
||||
self._delete_session_on_exit = False
|
||||
# /update: when set, run() executes relaunch() after prompt_toolkit
|
||||
# has fully exited and cleaned up terminal modes. Set by
|
||||
# _handle_update_command() so the relaunch happens on the main thread,
|
||||
# not the background process_loop thread.
|
||||
self._pending_relaunch: list[str] | None = None
|
||||
self._last_ctrl_c_time = 0
|
||||
self._clarify_state = None
|
||||
self._clarify_freetext = False
|
||||
@@ -4226,7 +4329,13 @@ class HermesCLI:
|
||||
resolved_acp_command = runtime.get("command")
|
||||
resolved_acp_args = list(runtime.get("args") or [])
|
||||
resolved_credential_pool = runtime.get("credential_pool")
|
||||
if not isinstance(api_key, str) or not api_key:
|
||||
# A callable api_key is a bearer-token provider (Azure Foundry
|
||||
# Entra ID — ``azure_identity_adapter.build_token_provider``).
|
||||
# The OpenAI SDK accepts ``Callable[[], str]`` for ``api_key`` and
|
||||
# invokes it before every request. Skip the string-only validation
|
||||
# and placeholder substitution for callables.
|
||||
_is_callable_provider = callable(api_key) and not isinstance(api_key, str)
|
||||
if not _is_callable_provider and (not isinstance(api_key, str) or not api_key):
|
||||
# Custom / local endpoints (llama.cpp, ollama, vLLM, etc.) often
|
||||
# don't require authentication. When a base_url IS configured but
|
||||
# no API key was found, use a placeholder so the OpenAI SDK
|
||||
@@ -5492,6 +5601,17 @@ class HermesCLI:
|
||||
f" [bold {_accent_hex()}]{cmd:<22}[/] [dim]-[/] {_escape(info['description'])}"
|
||||
)
|
||||
|
||||
_bundles_now = get_skill_bundles()
|
||||
if _bundles_now:
|
||||
_cprint(f"\n ▣ {_BOLD}Skill Bundles{_RST} ({len(_bundles_now)} installed):")
|
||||
for cmd, info in sorted(_bundles_now.items()):
|
||||
skill_count = len(info.get("skills", []))
|
||||
desc = info.get("description") or f"Load {skill_count} skills"
|
||||
ChatConsole().print(
|
||||
f" [bold {_accent_hex()}]{cmd:<22}[/] [dim]-[/] "
|
||||
f"{_escape(desc)} [dim]({skill_count} skills)[/]"
|
||||
)
|
||||
|
||||
_cprint(f"\n {_DIM}Tip: Just type your message to chat with Hermes!{_RST}")
|
||||
_cprint(f" {_DIM}Multi-line: Alt+Enter for a new line{_RST}")
|
||||
_cprint(f" {_DIM}Draft editor: Ctrl+G (Alt+G in VSCode/Cursor){_RST}")
|
||||
@@ -5680,7 +5800,15 @@ class HermesCLI:
|
||||
config_path = project_config_path
|
||||
config_status = "(loaded)" if config_path.exists() else "(not found)"
|
||||
|
||||
api_key_display = '********' + self.api_key[-4:] if self.api_key and len(self.api_key) > 4 else 'Not set!'
|
||||
# ``self.api_key`` may be a callable (Azure Foundry Entra ID bearer
|
||||
# provider). Never invoke it; just identify the auth surface.
|
||||
from agent.azure_identity_adapter import is_token_provider
|
||||
if is_token_provider(self.api_key):
|
||||
api_key_display = "Microsoft Entra ID"
|
||||
elif isinstance(self.api_key, str) and len(self.api_key) > 12:
|
||||
api_key_display = f"{self.api_key[:8]}...{self.api_key[-4:]}"
|
||||
else:
|
||||
api_key_display = "Not set!"
|
||||
|
||||
print()
|
||||
title = "(^_^) Configuration"
|
||||
@@ -7891,6 +8019,9 @@ class HermesCLI:
|
||||
self._handle_copy_command(cmd_original)
|
||||
elif canonical == "debug":
|
||||
self._handle_debug_command()
|
||||
elif canonical == "update":
|
||||
if self._handle_update_command():
|
||||
return False
|
||||
elif canonical == "paste":
|
||||
self._handle_paste_command()
|
||||
elif canonical == "image":
|
||||
@@ -7907,6 +8038,8 @@ class HermesCLI:
|
||||
elif canonical == "reload-skills":
|
||||
with self._busy_command(self._slow_command_status(cmd_original)):
|
||||
self._reload_skills()
|
||||
elif canonical == "bundles":
|
||||
self._handle_bundles_command(cmd_original)
|
||||
elif canonical == "browser":
|
||||
self._handle_browser_command(cmd_original)
|
||||
elif canonical == "plugins":
|
||||
@@ -8043,6 +8176,30 @@ class HermesCLI:
|
||||
_cprint(str(result))
|
||||
except Exception as e:
|
||||
_cprint(f"\033[1;31mPlugin command error: {e}{_RST}")
|
||||
# Skill bundles take precedence over individual skills — /<bundle>
|
||||
# loads multiple skills at once. Rescans cheaply when files change.
|
||||
elif base_cmd in get_skill_bundles():
|
||||
user_instruction = cmd_original[len(base_cmd):].strip()
|
||||
bundle_result = build_bundle_invocation_message(
|
||||
base_cmd, user_instruction, task_id=self.session_id
|
||||
)
|
||||
if bundle_result:
|
||||
msg, loaded_names, missing = bundle_result
|
||||
bundle_info = get_skill_bundles()[base_cmd]
|
||||
print(
|
||||
f"\n⚡ Loading bundle: {bundle_info['name']} "
|
||||
f"({len(loaded_names)} skills)"
|
||||
)
|
||||
if missing:
|
||||
ChatConsole().print(
|
||||
f"[yellow]Skipped missing skills: {', '.join(missing)}[/]"
|
||||
)
|
||||
if hasattr(self, '_pending_input'):
|
||||
self._pending_input.put(msg)
|
||||
else:
|
||||
ChatConsole().print(
|
||||
f"[bold red]Failed to load bundle for {base_cmd}[/]"
|
||||
)
|
||||
# Check for skill slash commands (/gif-search, /axolotl, etc.)
|
||||
elif base_cmd in _skill_commands:
|
||||
user_instruction = cmd_original[len(base_cmd):].strip()
|
||||
@@ -8062,7 +8219,7 @@ class HermesCLI:
|
||||
# that execution-time resolution agrees with tab-completion.
|
||||
from hermes_cli.commands import COMMANDS
|
||||
typed_base = cmd_lower.split()[0]
|
||||
all_known = set(COMMANDS) | set(_skill_commands)
|
||||
all_known = set(COMMANDS) | set(_skill_commands) | set(get_skill_bundles())
|
||||
matches = [c for c in all_known if c.startswith(typed_base)]
|
||||
if len(matches) > 1:
|
||||
# Prefer an exact match (typed the full command name)
|
||||
@@ -8263,6 +8420,44 @@ class HermesCLI:
|
||||
"""
|
||||
return try_launch_chrome_debug(port, system)
|
||||
|
||||
def _handle_bundles_command(self, cmd: str) -> None:
|
||||
"""In-session ``/bundles`` — show installed skill bundles.
|
||||
|
||||
Mirrors ``hermes bundles list`` but renders inside the running
|
||||
CLI so users can discover what's available without dropping out
|
||||
of their session. Bundles are loaded via ``/<bundle-name>``.
|
||||
"""
|
||||
try:
|
||||
from agent.skill_bundles import list_bundles, _bundles_dir
|
||||
except Exception as exc:
|
||||
_cprint(f"\033[1;31mBundle subsystem unavailable: {exc}{_RST}")
|
||||
return
|
||||
|
||||
bundles = list_bundles()
|
||||
if not bundles:
|
||||
_cprint(" No skill bundles installed.")
|
||||
_cprint(
|
||||
f" {_DIM}Create one with: hermes bundles create "
|
||||
f"<name> --skill <s1> --skill <s2>{_RST}"
|
||||
)
|
||||
_cprint(f" {_DIM}Directory: {_bundles_dir()}{_RST}")
|
||||
return
|
||||
|
||||
_cprint(f"\n ▣ {_BOLD}Skill Bundles{_RST} ({len(bundles)} installed):")
|
||||
for info in bundles:
|
||||
skill_count = len(info.get("skills", []))
|
||||
desc = info.get("description") or f"Load {skill_count} skills"
|
||||
ChatConsole().print(
|
||||
f" [bold {_accent_hex()}]/{info['slug']:<20}[/] "
|
||||
f"[dim]-[/] {_escape(desc)} [dim]({skill_count} skills)[/]"
|
||||
)
|
||||
for s in info.get("skills", []):
|
||||
ChatConsole().print(f" [dim]· {_escape(s)}[/]")
|
||||
_cprint(
|
||||
f"\n {_DIM}Invoke a bundle with /<slug>. "
|
||||
f"Manage with `hermes bundles`.{_RST}"
|
||||
)
|
||||
|
||||
def _handle_browser_command(self, cmd: str):
|
||||
"""Handle /browser connect|disconnect|status — manage live Chrome CDP connection."""
|
||||
import platform as _plat
|
||||
@@ -9126,6 +9321,7 @@ class HermesCLI:
|
||||
None,
|
||||
approx_tokens=approx_tokens,
|
||||
focus_topic=focus_topic or None,
|
||||
force=True,
|
||||
)
|
||||
self.conversation_history = compressed
|
||||
# _compress_context ends the old session and creates a new child
|
||||
@@ -9172,6 +9368,58 @@ class HermesCLI:
|
||||
args = SimpleNamespace(lines=200, expire=7, local=False)
|
||||
run_debug_share(args)
|
||||
|
||||
def _handle_update_command(self) -> bool:
|
||||
"""Handle /update — update Hermes Agent to the latest version.
|
||||
|
||||
In the classic CLI this exits the session and relaunches as
|
||||
``hermes update`` so the user sees update output directly and gets
|
||||
the new version on next launch.
|
||||
|
||||
Returns ``True`` when the update was confirmed (caller should trigger
|
||||
app exit so the relaunch is deferred to the main thread after
|
||||
prompt_toolkit cleans up terminal modes). Returns ``False`` / falsy
|
||||
when cancelled.
|
||||
"""
|
||||
from hermes_cli.config import is_managed, format_managed_message
|
||||
|
||||
if is_managed():
|
||||
print(f" ✗ {format_managed_message('update Hermes Agent')}")
|
||||
return False
|
||||
|
||||
# Use the prompt_toolkit-native modal so the confirmation panel
|
||||
# renders properly above the composer and avoids raw input() races
|
||||
# with the prompt_toolkit event loop (same pattern as
|
||||
# _confirm_destructive_slash).
|
||||
choices = [
|
||||
("once", "Update Now", "exit the current session and update Hermes Agent"),
|
||||
("cancel", "Cancel", "keep the current session"),
|
||||
]
|
||||
raw = self._prompt_text_input_modal(
|
||||
title="⚕ Update Hermes Agent",
|
||||
detail="This will exit the current session and run `hermes update`.",
|
||||
choices=choices,
|
||||
)
|
||||
if raw is None:
|
||||
print(" 🟡 /update cancelled.")
|
||||
return False
|
||||
choice = self._normalize_slash_confirm_choice(raw, choices)
|
||||
if choice != "once":
|
||||
print(" 🟡 /update cancelled.")
|
||||
return False
|
||||
|
||||
print()
|
||||
print(" ⚕ Launching update...")
|
||||
print()
|
||||
|
||||
# Store the relaunch args so run() can exec them from the main thread
|
||||
# after prompt_toolkit exits and restores terminal modes. Calling
|
||||
# relaunch() directly here (from the process_loop daemon thread) would
|
||||
# skip terminal cleanup on POSIX (execvp replaces the process mid-TUI)
|
||||
# and only exit the worker thread on Windows (subprocess.run +
|
||||
# sys.exit inside a non-main thread does not exit the process).
|
||||
self._pending_relaunch = ["update"]
|
||||
return True
|
||||
|
||||
def _show_usage(self):
|
||||
"""Show rate limits (if available) and session token usage."""
|
||||
if not self.agent:
|
||||
@@ -12633,6 +12881,7 @@ class HermesCLI:
|
||||
_completer = SlashCommandCompleter(
|
||||
skill_commands_provider=lambda: get_skill_commands(),
|
||||
command_filter=cli_ref._command_available,
|
||||
skill_bundles_provider=lambda: get_skill_bundles(),
|
||||
)
|
||||
input_area = TextArea(
|
||||
height=Dimension(min=1, max=8, preferred=1),
|
||||
@@ -13677,7 +13926,31 @@ class HermesCLI:
|
||||
time.sleep(_grace)
|
||||
except Exception:
|
||||
pass # never block signal handling
|
||||
raise KeyboardInterrupt()
|
||||
# Prefer a clean prompt_toolkit exit over `raise KeyboardInterrupt()`.
|
||||
# Raising KBI from a signal handler unwinds into whatever Python
|
||||
# frame the interpreter happens to be running — typically an
|
||||
# `await asyncio.sleep()` inside prompt_toolkit's
|
||||
# `_poll_output_size` coroutine. The KBI becomes a Task
|
||||
# exception, prompt_toolkit's `_handle_exception` prints
|
||||
# "Unhandled exception in event loop" + the full traceback, and
|
||||
# parks the terminal on "Press ENTER to continue..." (#13710
|
||||
# variant — same root cause, different surface).
|
||||
#
|
||||
# `app.exit()` scheduled via `call_soon_threadsafe` lets the
|
||||
# event loop unwind normally; `app.run()` returns and our
|
||||
# existing `except (EOFError, KeyboardInterrupt, BrokenPipeError)`
|
||||
# block at the bottom of the input loop handles the rest.
|
||||
try:
|
||||
from prompt_toolkit.application.current import get_app_or_none
|
||||
_app = get_app_or_none()
|
||||
if _app is not None:
|
||||
_loop = getattr(_app, "loop", None)
|
||||
if _loop is not None:
|
||||
_loop.call_soon_threadsafe(_app.exit)
|
||||
return # clean unwind — no traceback, no ENTER pause
|
||||
except Exception:
|
||||
pass
|
||||
raise KeyboardInterrupt() # fallback for non-prompt_toolkit contexts
|
||||
|
||||
try:
|
||||
import signal as _signal
|
||||
@@ -13879,6 +14152,15 @@ class HermesCLI:
|
||||
_run_cleanup()
|
||||
self._print_exit_summary()
|
||||
|
||||
# Deferred relaunch: /update sets _pending_relaunch so the exec
|
||||
# happens here — after prompt_toolkit has exited and fully restored
|
||||
# terminal modes — rather than from the background process_loop
|
||||
# thread (which would skip terminal cleanup on POSIX and only exit
|
||||
# the worker thread on Windows).
|
||||
if getattr(self, '_pending_relaunch', None):
|
||||
from hermes_cli.relaunch import relaunch
|
||||
relaunch(self._pending_relaunch, preserve_inherited=False)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Main Entry Point
|
||||
|
||||
Reference in New Issue
Block a user