The first CLI launch after a PM install starts tirith's download in the background, so ensure_installed() returns None and the CLI printed "tirith security scanner enabled but not available". The scanner was on its way, not missing; the same warning also fired when lazy installs are disabled by the operator's own policy. missing_is_expected() tells those by-design states apart. The CLI logs them and keeps the visible warning for a missing explicit tirith_path or a finished download that failed.
1865 lines
81 KiB
Python
1865 lines
81 KiB
Python
#!/usr/bin/env python3
|
|
"""Hermes Agent CLI — interactive terminal interface (``python cli.py --help`` for usage)."""
|
|
|
|
# Must be the very first import (UTF-8 stdio on Windows). Missing only mid-``hermes update``.
|
|
try:
|
|
import hermes_bootstrap # noqa: F401
|
|
except ModuleNotFoundError as exc:
|
|
if exc.name != "hermes_bootstrap":
|
|
raise # the bootstrap exists but cannot load: skipping it would skip PM activation
|
|
|
|
import logging
|
|
import os
|
|
import functools
|
|
import shutil # noqa: F401 — tests patch shutil/time through the cli facade
|
|
import sys
|
|
import re
|
|
import atexit
|
|
import errno
|
|
import time # noqa: F401 — see shutil
|
|
from collections import deque
|
|
from dataclasses import dataclass
|
|
from contextlib import contextmanager, suppress
|
|
from pathlib import Path
|
|
from datetime import datetime # noqa: F401 — siblings import it lazily through cli
|
|
from typing import List, Dict, Any, Optional
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
os.environ["HERMES_QUIET"] = "1" # suppress our modules' startup chatter
|
|
|
|
|
|
from hermes_cli.cli_agent_setup_mixin import CLIAgentSetupMixin
|
|
from hermes_cli.cli_commands_mixin import CLICommandsMixin
|
|
from hermes_cli.cli_billing_mixin import CLIBillingMixin
|
|
from hermes_cli.cli_loops_mixin import CLILoopsMixin
|
|
from hermes_cli.cli_info_mixin import CLIInfoMixin
|
|
from hermes_cli.cli_terminal_mixin import CLITerminalMixin
|
|
from hermes_cli.cli_modal_mixin import CLIModalMixin
|
|
from hermes_cli.cli_stream_mixin import CLIStreamMixin
|
|
from hermes_cli.cli_session_mixin import CLISessionMixin
|
|
from hermes_cli.cli_model_switch_mixin import CLIModelSwitchMixin
|
|
from hermes_cli.cli_voice_mixin import CLIVoiceMixin
|
|
from hermes_cli.cli_status_bar_mixin import CLIStatusBarMixin
|
|
from hermes_cli.cli_tui_mixin import CLITuiMixin
|
|
from hermes_cli.cli_process_notifications import CLIProcessNotificationsMixin
|
|
from hermes_cli.cli_init_mixin import CLIInitMixin
|
|
from hermes_cli.cli_tui_runtime_mixin import CLITuiRuntimeMixin
|
|
# Extracted clusters (mechanical split, #116911); re-exported here so `cli.<name>` stays the seam.
|
|
from hermes_cli.cli_shutdown import ( # noqa: F401,E402
|
|
_CLEANUP_STEPS,
|
|
_arm_exit_watchdog,
|
|
_emit_interrupted_session_end,
|
|
_exit_watchdog_timeout,
|
|
_finalize_single_query,
|
|
_float_env,
|
|
_flush_logging_and_stdio,
|
|
_flush_one_shot_session_store,
|
|
_interrupt_async_delegations,
|
|
_invoke_interrupted_session_end,
|
|
_notify_session_finalize,
|
|
_notify_single_query_session_finalize,
|
|
_oneshot_agent_and_session,
|
|
_should_emit_cleanup_session_finalize,
|
|
_shutdown_agent_memory_provider,
|
|
_shutdown_cached_aux_clients,
|
|
_shutdown_mcp_servers,
|
|
_stop_cli_wake_word,
|
|
_sync_process_session_id,
|
|
_wait_for_oneshot_background_completions,
|
|
)
|
|
from hermes_cli.cli_auto_maintenance import ( # noqa: F401,E402
|
|
_run_checkpoint_auto_maintenance,
|
|
_run_state_db_auto_maintenance,
|
|
)
|
|
from hermes_cli.cli_render import ( # noqa: F401,E402
|
|
ChatConsole,
|
|
_ACCENT,
|
|
_ACCENT_ANSI_DEFAULT,
|
|
_BOLD,
|
|
_DA1_REPLY_RE,
|
|
_DIM,
|
|
_FALSE_RE,
|
|
_LIGHT_DEFAULT_TERM_PROGRAMS,
|
|
_LIGHT_MODE_REMAP,
|
|
_LIGHT_MODE_REMAP_UPPER,
|
|
_REASONING_TAGS,
|
|
_RST,
|
|
_STREAM_PAD,
|
|
_STREAM_PARTIAL_PREVIEW_LEN,
|
|
_SkinAwareAnsi,
|
|
_TOOL_CALL_TAGS,
|
|
_TRUE_RE,
|
|
_WINDOWS_PATH_WITH_DOT_SEGMENT_RE,
|
|
_accent_hex,
|
|
_add_suspect_rows,
|
|
_append_blank_panel_line,
|
|
_append_panel_line,
|
|
_assistant_content_as_text,
|
|
_assistant_copy_text,
|
|
_b,
|
|
_build_compact_banner,
|
|
_clear_output_history,
|
|
_cli_visible_print,
|
|
_coerce_output_history_limit,
|
|
_cprint,
|
|
_d,
|
|
_detect_light_mode_uncached,
|
|
_heal_cooked_mode_drift,
|
|
_hex_to_ansi,
|
|
_install_skin_light_mode_hook,
|
|
_line_rows,
|
|
_luminance_from_hex,
|
|
_maybe_remap_for_light_mode,
|
|
_output_history_lines,
|
|
_output_history_recording,
|
|
_output_history_rows,
|
|
_output_tail_fitting,
|
|
_painted_columns,
|
|
_PaintedLine,
|
|
_panel_box_width,
|
|
_post_stream_transform_output,
|
|
_prepend_note_to_message,
|
|
_preserve_windows_dot_segments_for_markdown,
|
|
_pt_app_is_running,
|
|
_pt_print_ansi,
|
|
_query_osc11_background,
|
|
_record_output_history,
|
|
_record_output_history_entry,
|
|
_release_paints,
|
|
_render_final_assistant_content,
|
|
_rich_text_from_ansi,
|
|
_set_chrome_floor,
|
|
_strip_markdown_syntax,
|
|
_strip_reasoning_tags,
|
|
_terminal_columns,
|
|
_terminal_reflows,
|
|
_terminal_width_for_streaming,
|
|
_tty_wrap,
|
|
_wrap_panel_text,
|
|
_wrap_panel_text_keep_ws,
|
|
)
|
|
from hermes_cli.cli_config_load import ( # noqa: F401,E402
|
|
_AUXILIARY_TASK_ENV,
|
|
_CWD_PLACEHOLDERS,
|
|
_TERMINAL_ENV_MAPPINGS,
|
|
_cli_config_defaults,
|
|
_init_logging_and_display_from_config,
|
|
_load_prefill_messages,
|
|
_merge_file_config,
|
|
_mirror_config_to_env,
|
|
_parse_reasoning_config,
|
|
_parse_service_tier_config,
|
|
_resolve_prefill_messages_file,
|
|
load_cli_config,
|
|
)
|
|
from hermes_cli.cli_terminal_input import ( # noqa: F401,E402
|
|
_BACKSLASH_LINE_CONTINUATION_RE,
|
|
_DSR_CPR_ESC_RE,
|
|
_DSR_CPR_VISIBLE_RE,
|
|
_EXTENDED_ENTER_KEYS_SEQ,
|
|
_IMAGE_EXTENSIONS,
|
|
_KITTY_KEYBOARD_PUSH_SEQ,
|
|
_MODIFY_OTHER_KEYS_SEQ,
|
|
_SGR_MOUSE_BARE_RE,
|
|
_SGR_MOUSE_ESC_RE,
|
|
_SGR_MOUSE_VISIBLE_RE,
|
|
_TERMINAL_INPUT_MODE_RESET_SEQ,
|
|
_apply_backslash_line_continuation,
|
|
_apply_bracketed_paste_timeout_patch,
|
|
_bind_prompt_submit_keys,
|
|
_build_cpr_disabled_output,
|
|
_cli_multiline_shortcuts_enabled,
|
|
_collect_query_images,
|
|
_detect_file_drop,
|
|
_disable_prompt_toolkit_cpr_warning,
|
|
_enable_extended_enter_keys,
|
|
_estimate_tui_input_height,
|
|
_file_drop_result,
|
|
_format_image_attachment_badges,
|
|
_hermes_call_output_screen_diff,
|
|
_is_backslash_line_continuation,
|
|
_is_ghostty_terminal,
|
|
_preserve_ctrl_enter_newline,
|
|
_resolve_attachment_path,
|
|
_select_classic_cli_pt_output,
|
|
_should_auto_attach_clipboard_image_on_paste,
|
|
_split_path_input,
|
|
_status_bar_visible_from_display_config,
|
|
_strip_leaked_terminal_responses_with_meta,
|
|
_terminal_may_leak_cpr,
|
|
_terminal_supports_extended_enter_keys,
|
|
_termux_example_image_path,
|
|
)
|
|
from hermes_cli.cli_single_query import ( # noqa: F401,E402
|
|
_TERMINAL_PROVIDER_REASONS,
|
|
_TRANSIENT_PROVIDER_REASONS,
|
|
_collect_kanban_task_images,
|
|
_configure_quiet_agent,
|
|
_install_single_query_signal_handlers,
|
|
_int_or,
|
|
_interrupt_agent_for_signal,
|
|
_route_single_query_images,
|
|
_run_kanban_goal_loop_chat,
|
|
_run_kanban_goal_loop_q,
|
|
_run_quiet_single_query,
|
|
_run_single_query_mode,
|
|
_single_query_exit_code,
|
|
_sync_cli_session_id_from_agent,
|
|
)
|
|
|
|
from prompt_toolkit.patch_stdout import patch_stdout
|
|
try:
|
|
from prompt_toolkit.enums import EditingMode
|
|
except ImportError: # partial prompt_toolkit stubs in tests
|
|
EditingMode = None
|
|
from prompt_toolkit import print_formatted_text as _pt_print
|
|
from prompt_toolkit.formatted_text import ANSI as _PT_ANSI
|
|
try:
|
|
from prompt_toolkit.cursor_shapes import CursorShape
|
|
_STEADY_CURSOR = CursorShape.BLOCK
|
|
except (ImportError, AttributeError):
|
|
_STEADY_CURSOR = None
|
|
|
|
try:
|
|
from hermes_cli import pt_input_extras as _pt_extras
|
|
|
|
_pt_extras.install_shift_enter_alias()
|
|
_pt_extras.install_ctrl_enter_alias()
|
|
_pt_extras.install_cmd_backspace_alias()
|
|
_pt_extras.install_modify_other_keys_aliases()
|
|
_pt_extras.install_keypress_data_normalization()
|
|
_pt_extras.install_ignored_terminal_sequences()
|
|
del _pt_extras
|
|
except Exception:
|
|
pass
|
|
import threading
|
|
import queue
|
|
|
|
|
|
def _lazy_shim(module: str, name: str, alias: str | None = None):
|
|
"""Import ``module.name`` on first call; keeps heavy imports off startup while ``cli.<name>`` stays patchable."""
|
|
import importlib
|
|
|
|
def shim(*args, **kwargs):
|
|
return getattr(importlib.import_module(module), name)(*args, **kwargs)
|
|
|
|
shim.__name__ = shim.__qualname__ = alias or name
|
|
return shim
|
|
|
|
|
|
def format_duration_compact(*args, **kwargs):
|
|
seconds = float(args[0] if args else kwargs.get("seconds", 0.0))
|
|
if seconds < 60:
|
|
return f"{seconds:.0f}s"
|
|
minutes = seconds / 60
|
|
if minutes < 60:
|
|
return f"{minutes:.0f}m"
|
|
hours = minutes / 60
|
|
if hours < 24:
|
|
remaining_min = int(minutes % 60)
|
|
return f"{int(hours)}h {remaining_min}m" if remaining_min else f"{int(hours)}h"
|
|
days = hours / 24
|
|
return f"{days:.1f}d"
|
|
|
|
|
|
# model id -> shortest configured alias (process-lifetime cache; config is read once).
|
|
_REVERSE_ALIAS_CACHE: dict[str, str] | None = None
|
|
|
|
|
|
def _reverse_alias_for_display(model_name: str) -> str:
|
|
"""Shortest alias for ``model_name`` from ``model_aliases:`` or ``model.aliases:``, else ``model_name``."""
|
|
global _REVERSE_ALIAS_CACHE
|
|
if not model_name:
|
|
return model_name
|
|
if _REVERSE_ALIAS_CACHE is None:
|
|
rmap: dict[str, str] = {}
|
|
|
|
def _put(m: str, alias: str) -> None:
|
|
if m and (m not in rmap or len(alias) < len(rmap[m])):
|
|
rmap[m] = alias
|
|
|
|
try:
|
|
from hermes_cli.config import load_config
|
|
cfg = load_config() or {}
|
|
ma = cfg.get("model_aliases")
|
|
if isinstance(ma, dict):
|
|
for alias, entry in ma.items():
|
|
if isinstance(entry, dict):
|
|
_put(str(entry.get("model", "") or "").strip(), alias)
|
|
mdl = cfg.get("model", {}) or {}
|
|
if isinstance(mdl, dict):
|
|
simple = mdl.get("aliases")
|
|
if isinstance(simple, dict):
|
|
for alias, val in simple.items():
|
|
if isinstance(val, str) and val.strip():
|
|
v = val.strip()
|
|
_put(v.split("/", 1)[1] if "/" in v else v, alias)
|
|
except Exception:
|
|
pass
|
|
_REVERSE_ALIAS_CACHE = rmap
|
|
return _REVERSE_ALIAS_CACHE.get(model_name, model_name)
|
|
|
|
|
|
def format_token_count_compact(*args, **kwargs):
|
|
value = int(args[0] if args else kwargs.get("value", 0))
|
|
abs_value = abs(value)
|
|
if abs_value < 1_000:
|
|
return str(value)
|
|
|
|
sign = "-" if value < 0 else ""
|
|
units = ((1_000_000_000, "B"), (1_000_000, "M"), (1_000, "K"))
|
|
for threshold, suffix in units:
|
|
if abs_value >= threshold:
|
|
scaled = abs_value / threshold
|
|
text = f"{scaled:.{2 if scaled < 10 else 1 if scaled < 100 else 0}f}"
|
|
if "." in text:
|
|
text = text.rstrip("0").rstrip(".")
|
|
return f"{sign}{text}{suffix}"
|
|
|
|
return f"{value:,}"
|
|
|
|
|
|
realign_markdown_tables = _lazy_shim("agent.markdown_tables", "realign_markdown_tables")
|
|
|
|
_COMMAND_SPINNER_FRAMES = ("⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏")
|
|
|
|
|
|
# ~/.hermes/.env first, project .env as dev fallback; user env files override stale shell exports.
|
|
from hermes_constants import get_hermes_home
|
|
from hermes_cli.env_loader import load_hermes_dotenv
|
|
|
|
_hermes_home = get_hermes_home()
|
|
_project_env = Path(__file__).parent / '.env'
|
|
load_hermes_dotenv(hermes_home=_hermes_home, project_env=_project_env)
|
|
|
|
|
|
CLI_CONFIG = load_cli_config()
|
|
|
|
|
|
_init_logging_and_display_from_config()
|
|
|
|
# Neuter AsyncHttpxClientWrapper.__del__ before any AsyncOpenAI client exists: it
|
|
# schedules aclose() on the running loop (prompt_toolkit's, during idle), closing
|
|
# transports bound to dead worker loops ("Event loop is closed" / "Press ENTER to
|
|
# continue..."). A meta_path finder patches ``openai._base_client`` at first import —
|
|
# eager import costs ~166ms/30MB cold, and the patch is guaranteed to land before
|
|
# instantiation. See ``agent.auxiliary_client.neuter_async_httpx_del``.
|
|
try:
|
|
import sys as _httpx_neuter_sys
|
|
import importlib.util as _httpx_neuter_imp_util
|
|
|
|
class _AsyncHttpxDelNeuter:
|
|
"""Patch ``AsyncHttpxClientWrapper.__del__`` to a no-op when ``openai._base_client`` loads."""
|
|
|
|
_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 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
|
|
|
|
|
|
# Agent/tool systems load lazily: bare startup only needs the prompt.
|
|
def get_tool_definitions(*args, **kwargs):
|
|
from hermes_cli.mcp_startup import wait_for_mcp_discovery
|
|
from model_tools import get_tool_definitions as _get_tool_definitions
|
|
|
|
wait_for_mcp_discovery()
|
|
return _get_tool_definitions(*args, **kwargs)
|
|
|
|
|
|
validate_toolset = _lazy_shim("toolsets", "validate_toolset")
|
|
|
|
|
|
_cleanup_all_terminals = _lazy_shim("tools.terminal_tool", "cleanup_all_environments", "_cleanup_all_terminals")
|
|
set_sudo_password_callback = _lazy_shim("tools.terminal_tool", "set_sudo_password_callback")
|
|
set_approval_callback = _lazy_shim("tools.terminal_tool", "set_approval_callback")
|
|
set_secret_capture_callback = _lazy_shim("tools.skills_tool", "set_secret_capture_callback")
|
|
_cleanup_all_browsers = _lazy_shim("tools.browser_tool_lifecycle", "_emergency_cleanup_all_sessions", "_cleanup_all_browsers")
|
|
|
|
_cleanup_done = False # _run_cleanup runs exactly once
|
|
_cleanup_in_progress = False
|
|
_cli_wake_owner = None
|
|
# One-shot finalization runs before process cleanup (plugins see the boundary while the
|
|
# agent is attached); atexit cleanup must not finalize those sessions again.
|
|
_single_query_finalize_attempted_session_ids: set[str | None] = set()
|
|
# /handoff sessions belong to the gateway: finalizing them here would stamp end_reason on
|
|
# a row the gateway just reopened, making the handoff leg vanish from history.
|
|
# Session IDs that were handed off to the gateway via /handoff. The CLI process exits after a successful
|
|
# handoff, but the gateway now owns the session lifecycle — _run_cleanup must NOT call finalize_session on
|
|
# these, because doing so sets end_reason on a row the gateway just reopened and is actively writing to
|
|
# (#88234). The race made the handoff leg vanish from session history and broke session_search recall for
|
|
# the handed-off session.
|
|
_handed_off_session_ids: set[str | None] = set()
|
|
_active_agent_ref = None # active AIAgent, for memory-provider shutdown at exit
|
|
_deferred_agent_startup_done = False
|
|
# Set once the TUI app starts (focus reporting + mouse tracking on); gates the on-exit
|
|
# terminal reset so non-TUI one-shot runs never emit codes for modes they never enabled.
|
|
_tui_input_modes_active = False
|
|
|
|
|
|
# Set True once the TUI's prompt_toolkit app starts (which enables focus reporting + mouse tracking). Gates
|
|
# the on-exit terminal reset so non-TUI one-shot CLI runs — which also register _run_cleanup via atexit —
|
|
# don't emit escape codes for modes they never enabled (#36823).
|
|
def _mark_tui_input_modes_active() -> None:
|
|
"""Record that the TUI app started, so _run_cleanup resets input modes."""
|
|
global _tui_input_modes_active
|
|
_tui_input_modes_active = True
|
|
|
|
|
|
def _prepare_deferred_agent_startup() -> None:
|
|
"""Run Termux-deferred agent discovery before the first real agent turn."""
|
|
global _deferred_agent_startup_done
|
|
if _deferred_agent_startup_done:
|
|
return
|
|
if os.environ.get("HERMES_DEFER_AGENT_STARTUP") != "1":
|
|
return
|
|
_deferred_agent_startup_done = True
|
|
_accept_hooks = os.environ.get("HERMES_ACCEPT_HOOKS", "").lower() in {"1", "true", "yes", "on"}
|
|
try:
|
|
from hermes_cli.plugins import discover_plugins
|
|
|
|
discover_plugins()
|
|
except Exception:
|
|
logger.warning("plugin discovery failed at deferred CLI startup", exc_info=True)
|
|
try:
|
|
from hermes_cli.mcp_startup import start_background_mcp_discovery
|
|
|
|
start_background_mcp_discovery(logger=logger, thread_name="termux-cli-mcp-discovery")
|
|
except Exception:
|
|
logger.debug("MCP tool discovery failed at deferred CLI startup", exc_info=True)
|
|
try:
|
|
from agent.shell_hooks import register_from_config
|
|
from agent.outbound_webhooks import register_from_config as register_outbound_webhooks
|
|
from hermes_cli.config import load_config
|
|
|
|
_hooks_cfg = load_config()
|
|
register_from_config(_hooks_cfg, accept_hooks=_accept_hooks)
|
|
register_outbound_webhooks(_hooks_cfg)
|
|
except Exception:
|
|
logger.debug("shell-hook registration failed at deferred CLI startup", exc_info=True)
|
|
|
|
|
|
_signal_watchdog_armed = False
|
|
|
|
|
|
def _arm_exit_watchdog_on_shutdown_signal() -> None:
|
|
"""Arm the exit backstop the moment a termination signal arrives (idempotent; never raises).
|
|
|
|
The graceful unwind has wedge points BEFORE ``_run_cleanup`` arms its own watchdog
|
|
(main thread in a syscall, prompt_toolkit teardown never returning). Leash is 2x
|
|
the cleanup timeout so a progressing cleanup is never cut short. Never arm at
|
|
startup: the timer exits unconditionally.
|
|
|
|
SIGTERM/SIGHUP establish unambiguous shutdown intent, but the graceful path from signal →
|
|
``agent.interrupt()`` → ``app.exit()`` / ``KeyboardInterrupt`` → ``finally`` → ``_run_cleanup`` has
|
|
several wedge points BEFORE ``_run_cleanup`` arms the normal watchdog: a main thread parked in a syscall
|
|
that never observes the unwind, a prompt_toolkit teardown that never returns, or an agent worker
|
|
blocking the ``finally``. When that happens the process has NO backstop and a "dead" CLI lingers
|
|
(observed: ``hermes --tui`` alive ~47 min at 4% CPU after terminal close — the #65998 class).
|
|
"""
|
|
global _signal_watchdog_armed
|
|
if _signal_watchdog_armed:
|
|
return
|
|
_signal_watchdog_armed = True
|
|
base = _exit_watchdog_timeout()
|
|
if base <= 0:
|
|
return # explicitly disabled
|
|
with suppress(Exception): # never let the backstop break signal handling
|
|
_arm_exit_watchdog(timeout_s=base * 2, from_signal=True)
|
|
|
|
|
|
def _run_cleanup(*, notify_session_finalize: bool = True):
|
|
"""Run resource cleanup exactly once."""
|
|
global _cleanup_done, _cleanup_in_progress
|
|
if _cleanup_done:
|
|
return
|
|
_cleanup_done = True
|
|
_cleanup_in_progress = True
|
|
|
|
try:
|
|
_arm_exit_watchdog()
|
|
# Reset terminal input modes FIRST: teardown below can take seconds and a later
|
|
# step raising must not skip the reset. No-op unless the TUI ran.
|
|
# See #36823.
|
|
_reset_terminal_input_modes_on_exit()
|
|
|
|
for step, swallow in _CLEANUP_STEPS:
|
|
with suppress(swallow):
|
|
globals()[step]()
|
|
if notify_session_finalize:
|
|
cleanup_session_id = _active_agent_ref.session_id if _active_agent_ref else None
|
|
if _should_emit_cleanup_session_finalize(cleanup_session_id):
|
|
_notify_session_finalize(session_id=cleanup_session_id, platform="cli", reason="shutdown")
|
|
try:
|
|
_shutdown_agent_memory_provider(_active_agent_ref)
|
|
except Exception as e:
|
|
logger.warning("CLI cleanup memory shutdown failed: %s", e, exc_info=True)
|
|
finally:
|
|
_cleanup_in_progress = False
|
|
|
|
|
|
def _reset_terminal_input_modes_on_exit() -> None:
|
|
"""Disable focus reporting + mouse tracking on TUI exit (best-effort).
|
|
|
|
Ctrl+C / SIGTERM / crashes bypass prompt_toolkit's unwind, leaving focus events and
|
|
mouse reports as visible text in the next shell. Writes to stdout when it is the
|
|
terminal, else /dev/tty (the TUI may have run with stdout redirected).
|
|
|
|
Called from ``_run_cleanup`` (atexit-registered + invoked on the normal / EOF / interrupt exit paths)
|
|
this covers normal quit, Ctrl+C and SIGTERM/SIGHUP. ``kill -9`` is uncatchable, and the kanban worker's
|
|
``os._exit(0)`` path bypasses ``atexit``; neither runs this — but both are non-TTY / non-TUI, so there
|
|
is nothing to reset there. See #36823.
|
|
"""
|
|
global _tui_input_modes_active
|
|
if not _tui_input_modes_active:
|
|
return
|
|
# Clear first so a re-armed _run_cleanup doesn't re-emit.
|
|
_tui_input_modes_active = False
|
|
try:
|
|
stream = sys.stdout
|
|
if stream is not None and stream.isatty():
|
|
stream.write(_TERMINAL_INPUT_MODE_RESET_SEQ)
|
|
stream.flush()
|
|
return
|
|
except Exception:
|
|
pass
|
|
with suppress(Exception), open("/dev/tty", "w", encoding="ascii") as tty:
|
|
tty.write(_TERMINAL_INPUT_MODE_RESET_SEQ)
|
|
tty.flush()
|
|
|
|
|
|
from hermes_cli.worktree_ops import (
|
|
_git_quiet,
|
|
_git_repo_root,
|
|
_maintain_pack_health,
|
|
_prune_stale_worktrees,
|
|
_repo_is_shallow,
|
|
_setup_worktree,
|
|
_worktree_has_unpushed_commits,
|
|
release_lsp_clients,
|
|
)
|
|
|
|
# ============================================================================= Git Worktree Isolation
|
|
# (#652) =============================================================================
|
|
_active_worktree: Optional[Dict[str, str]] = None
|
|
|
|
|
|
def _cleanup_worktree(info: Dict[str, str] = None) -> None:
|
|
"""Remove a worktree and its branch on exit; kept only when it has unpushed commits."""
|
|
global _active_worktree
|
|
info = info or _active_worktree
|
|
if not info:
|
|
return
|
|
|
|
wt_path, branch, repo_root = info["path"], info["branch"], info["repo_root"]
|
|
if not Path(wt_path).exists():
|
|
return
|
|
|
|
if _worktree_has_unpushed_commits(wt_path, timeout=10):
|
|
if _repo_is_shallow(repo_root):
|
|
# Shallow boundary makes the unpushed verdict unreliable; the startup pruner reaps later.
|
|
_cprint(f"\n\033[33m⚠ Shallow clone — cannot verify push state, keeping: {wt_path}\033[0m")
|
|
print(" The next `hermes -w` session deepens the clone and prunes merged worktrees automatically.")
|
|
else:
|
|
_cprint(f"\n\033[33m⚠ Worktree has unpushed commits, keeping: {wt_path}\033[0m")
|
|
print(f" To clean up manually: git worktree remove --force {wt_path}")
|
|
_active_worktree = None
|
|
return
|
|
|
|
# Release the tree's language servers while the path still exists, then unlock so `remove`
|
|
# isn't blocked by the lock placed at creation. Fail-soft.
|
|
release_lsp_clients(wt_path)
|
|
_git_quiet(["worktree", "unlock", wt_path], repo_root, log="git worktree unlock failed (non-fatal)")
|
|
_git_quiet(["worktree", "remove", wt_path, "--force"], repo_root, timeout=15, log="Failed to remove worktree")
|
|
_git_quiet(["branch", "-D", branch], repo_root, log=f"Failed to delete branch {branch}")
|
|
|
|
_active_worktree = None
|
|
_cprint(f"\033[32m✓ Worktree cleaned up: {wt_path}\033[0m")
|
|
|
|
|
|
# Light/dark terminal detection (mirrors ui-tui/src/theme.ts detectLightMode()). Priority:
|
|
# HERMES_LIGHT/HERMES_TUI_LIGHT env, HERMES_TUI_THEME, HERMES_TUI_BACKGROUND, COLORFGBG
|
|
# (bg slot 7/15 = light), OSC 11 query, default dark. Cached so the terminal is queried once.
|
|
_LIGHT_MODE_CACHE: bool | None = None
|
|
|
|
|
|
def _detect_light_mode() -> bool:
|
|
global _LIGHT_MODE_CACHE
|
|
if _LIGHT_MODE_CACHE is not None:
|
|
return _LIGHT_MODE_CACHE
|
|
try:
|
|
result = _detect_light_mode_uncached()
|
|
except Exception:
|
|
result = False
|
|
_LIGHT_MODE_CACHE = result
|
|
return result
|
|
|
|
|
|
_install_skin_light_mode_hook()
|
|
|
|
|
|
# Prime the light-mode cache when interactive so OSC 11 happens before prompt_toolkit owns the tty.
|
|
with suppress(Exception):
|
|
if sys.stdin.isatty() and sys.stdout.isatty():
|
|
_detect_light_mode()
|
|
|
|
|
|
_OUTPUT_HISTORY_ENABLED = True
|
|
_OUTPUT_HISTORY_REPLAYING = False
|
|
_OUTPUT_HISTORY_SUPPRESSED = False
|
|
_OUTPUT_HISTORY_MAX_LINES = 200
|
|
_OUTPUT_HISTORY = deque(maxlen=_OUTPUT_HISTORY_MAX_LINES)
|
|
|
|
|
|
def _configure_output_history(enabled: bool, max_lines=200) -> None:
|
|
"""Configure recent CLI output replayed after terminal redraws."""
|
|
global _OUTPUT_HISTORY_ENABLED, _OUTPUT_HISTORY_MAX_LINES, _OUTPUT_HISTORY
|
|
_OUTPUT_HISTORY_ENABLED = bool(enabled)
|
|
_OUTPUT_HISTORY_MAX_LINES = _coerce_output_history_limit(max_lines)
|
|
_OUTPUT_HISTORY = deque(maxlen=_OUTPUT_HISTORY_MAX_LINES)
|
|
|
|
|
|
@contextmanager
|
|
def _suspend_output_history():
|
|
global _OUTPUT_HISTORY_SUPPRESSED
|
|
old_value = _OUTPUT_HISTORY_SUPPRESSED
|
|
_OUTPUT_HISTORY_SUPPRESSED = True
|
|
try:
|
|
yield
|
|
finally:
|
|
_OUTPUT_HISTORY_SUPPRESSED = old_value
|
|
|
|
|
|
def _replay_output_history(fit=None, output=None) -> None:
|
|
"""Repaint recent output above the prompt after a full screen clear.
|
|
|
|
``fit=(rows, columns, painted, top)`` replays only the newest lines whose wrapped height
|
|
fits ``rows`` (see ``_output_tail_fitting``) — the older ones are still in scrollback
|
|
(#95375) — from screen row ``top`` when known (``_set_chrome_floor``). ``output``: paint
|
|
now, straight to this prompt_toolkit output, where the caller just erased the viewport and
|
|
reset the renderer — ``run_in_terminal`` would first erase below the top row, which
|
|
scroll-on-clear terminals (tmux) take as a clear and copy the blank screen into scrollback.
|
|
"""
|
|
global _OUTPUT_HISTORY_REPLAYING
|
|
if not _OUTPUT_HISTORY_ENABLED or not _OUTPUT_HISTORY:
|
|
return
|
|
_OUTPUT_HISTORY_REPLAYING = True
|
|
try:
|
|
rendered_lines = _output_history_lines()
|
|
top = None
|
|
if fit is not None:
|
|
rows, columns, painted, top = fit
|
|
rendered_lines = _output_tail_fitting(rendered_lines, rows, columns, painted)
|
|
if rendered_lines:
|
|
# One payload: per-line pt prints each force a sync redraw (a waterfall of old output).
|
|
if output is None:
|
|
_pt_print(_PT_ANSI("\n".join(rendered_lines)))
|
|
else:
|
|
from prompt_toolkit.renderer import print_formatted_text as _paint_formatted_text
|
|
from prompt_toolkit.styles import Style
|
|
_paint_formatted_text(output, _PT_ANSI("\n".join(rendered_lines) + "\n"), Style([]))
|
|
size = output.get_size()
|
|
if top is not None: # the chrome's top is now this many rows down
|
|
top += sum(_line_rows(line, columns) for line in rendered_lines)
|
|
_set_chrome_floor(max(0, size.rows - top))
|
|
if size.columns != columns:
|
|
_add_suspect_rows(top + 1 - size.rows)
|
|
width = _painted_columns() if fit is None else columns
|
|
for line in rendered_lines: # repainted: they wrap at today's width from now on
|
|
if isinstance(line, _PaintedLine):
|
|
line.width = width
|
|
except Exception:
|
|
pass
|
|
finally:
|
|
_OUTPUT_HISTORY_REPLAYING = False
|
|
|
|
|
|
_strip_leaked_bracketed_paste_wrappers = _lazy_shim(
|
|
"hermes_cli.input_sanitize", "strip_leaked_bracketed_paste_wrappers", "_strip_leaked_bracketed_paste_wrappers"
|
|
)
|
|
|
|
|
|
# OSC sequences (e.g. OSC-8 links): pt's ANSI parser strips the ESC but leaks the payload as text.
|
|
_OSC_ESCAPE_RE = re.compile(r"\x1b\][\s\S]*?(?:\x07|\x1b\\)")
|
|
|
|
|
|
def _looks_like_slash_command(text: str) -> bool:
|
|
"""``/help`` yes, ``/Users/x/file.md`` no: a command's first word has no further ``/``."""
|
|
if not text or not text.startswith("/"):
|
|
return False
|
|
return "/" not in text.split()[0][1:]
|
|
|
|
|
|
_skill_commands = None
|
|
_skill_bundles = None
|
|
|
|
|
|
def _slash_args(cmd: str) -> str:
|
|
"""Text after the slash-command word, stripped ("" when absent)."""
|
|
parts = cmd.split(None, 1)
|
|
return parts[1].strip() if len(parts) > 1 else ""
|
|
|
|
|
|
def _ensure_skill_commands() -> dict:
|
|
global _skill_commands
|
|
if _skill_commands is None:
|
|
from agent.skill_commands import scan_skill_commands
|
|
|
|
_skill_commands = scan_skill_commands()
|
|
return _skill_commands
|
|
|
|
|
|
def get_skill_commands() -> dict:
|
|
return _ensure_skill_commands()
|
|
|
|
|
|
build_skill_invocation_message = _lazy_shim("agent.skill_commands", "build_skill_invocation_message")
|
|
build_preloaded_skills_prompt = _lazy_shim("agent.skill_commands", "build_preloaded_skills_prompt")
|
|
|
|
|
|
def get_skill_bundles() -> dict:
|
|
global _skill_bundles
|
|
if _skill_bundles is None:
|
|
from agent.skill_bundles import get_skill_bundles as _impl
|
|
|
|
_skill_bundles = _impl()
|
|
return _skill_bundles
|
|
|
|
|
|
build_bundle_invocation_message = _lazy_shim("agent.skill_bundles", "build_bundle_invocation_message")
|
|
|
|
|
|
def _get_plugin_cmd_handler_names() -> set:
|
|
"""Return plugin command names (without slash prefix) for dispatch matching."""
|
|
try:
|
|
from hermes_cli.plugins import get_plugin_commands
|
|
return set(get_plugin_commands().keys())
|
|
except Exception:
|
|
return set()
|
|
|
|
|
|
def _parse_skills_argument(skills: str | list[str] | tuple[str, ...] | None) -> list[str]:
|
|
"""Normalize a CLI skills flag into a deduplicated list of skill identifiers."""
|
|
if not skills:
|
|
return []
|
|
raw_values = [str(item) for item in skills if item is not None] if isinstance(skills, (list, tuple)) else [str(skills)]
|
|
parts = (p.strip() for raw in raw_values for p in raw.split(","))
|
|
return list(dict.fromkeys(p for p in parts if p))
|
|
|
|
|
|
def save_config_value(key_path: str, value: any) -> bool:
|
|
"""Persist dot-separated ``key_path`` = value into HERMES_HOME/config.yaml; True on success.
|
|
|
|
Never the repo's cli-config.yaml: no config reader loads it, so the value would vanish.
|
|
"""
|
|
config_path = get_hermes_home() / 'config.yaml'
|
|
|
|
try:
|
|
from hermes_constants import mkdir_under_hermes_home
|
|
mkdir_under_hermes_home(config_path.parent)
|
|
from utils import atomic_roundtrip_yaml_update
|
|
atomic_roundtrip_yaml_update(config_path, key_path, value)
|
|
try: # owner-only: config files contain API keys
|
|
os.chmod(config_path, 0o600)
|
|
except (OSError, NotImplementedError):
|
|
pass
|
|
return True
|
|
except Exception as e:
|
|
logger.error("Failed to save config: %s", e)
|
|
return False
|
|
|
|
|
|
def _normalize_moa_model(model: Optional[str]) -> tuple[Optional[str], Optional[str]]:
|
|
"""``moa:<preset>`` -> ``("moa", preset)`` (same routing as ``/moa``); anything else -> ``(None, model)``.
|
|
|
|
Returns ``("moa", "<preset>")`` when *model* selects the MoA virtual provider, otherwise ``(None,
|
|
model)`` unchanged. This gives non-interactive ``hermes chat -Q -m moa:<preset>`` the same routing the
|
|
interactive ``/moa`` command and the model picker already use: ``resolve_runtime_provider`` handles
|
|
``requested_provider == "moa"`` and ``agent_init`` builds the MoAClient off ``provider == "moa"``.
|
|
Without this the raw ``moa:<preset>`` string is sent to the real provider and rejected with a 401/400
|
|
"model not supported" (#56828).
|
|
"""
|
|
if isinstance(model, str) and model.strip().lower().startswith("moa:"):
|
|
preset = model.strip().split(":", 1)[1].strip()
|
|
if preset:
|
|
return "moa", preset
|
|
return None, model
|
|
|
|
_split_model_config_default = _lazy_shim("hermes_cli.config", "split_model_config_default", "_split_model_config_default")
|
|
|
|
|
|
class _VoiceInputMessage:
|
|
"""Sentinel for voice-transcribed input so the concise voice prefix never applies to typed text.
|
|
|
|
Distinguishes STT output from manually typed text while voice mode is active, so the
|
|
concise-voice-response prefix is applied only to messages that actually came from the microphone
|
|
(#65827).
|
|
"""
|
|
|
|
__slots__ = ("text",)
|
|
|
|
def __init__(self, text: str):
|
|
self.text = text
|
|
|
|
def __str__(self) -> str:
|
|
return self.text
|
|
|
|
|
|
class _SeededQueryMessage:
|
|
"""Sentinel for a ``-q`` prompt seeded into an interactive session; treated LITERALLY (no slash/!/file-drop)."""
|
|
|
|
__slots__ = ("text", "images")
|
|
|
|
def __init__(self, text: str, images=None):
|
|
self.text = text or ""
|
|
self.images = list(images or [])
|
|
|
|
def __str__(self) -> str:
|
|
return self.text
|
|
|
|
|
|
def _should_seed_interactive(query, image, quiet: bool, oneshot: bool) -> bool:
|
|
"""``-q`` seeds an interactive session only on a real TTY without ``--oneshot``/``-Q`` (automation answers and exits)."""
|
|
if not (query or image) or oneshot or quiet:
|
|
return False
|
|
try:
|
|
return bool(sys.stdin.isatty() and sys.stdout.isatty())
|
|
except Exception:
|
|
return False
|
|
|
|
|
|
@dataclass
|
|
class _ChatTurn:
|
|
"""Per-turn state shared by the ``chat()`` phases and the agent worker thread.
|
|
|
|
``result`` is written by the worker and read after the join; ``tts_normal_exit`` is
|
|
set only when the TTS worker drained on its own so the last sentence is never cut.
|
|
"""
|
|
|
|
result: Optional[dict] = None
|
|
mute_notification_reply: bool = False
|
|
use_streaming_tts: bool = False
|
|
box_opened: bool = False
|
|
thinking_started: bool = False
|
|
text_queue: Optional[queue.Queue] = None
|
|
tts_thread: Optional[threading.Thread] = None
|
|
stream_callback: Optional[Any] = None
|
|
stop_event: Optional[threading.Event] = None
|
|
tts_normal_exit: bool = False
|
|
voice_prefix: str = ""
|
|
from hermes_cli.cli_chat_turn_mixin import CLIChatTurnMixin
|
|
|
|
|
|
_PASTE_REF_RE = re.compile(r'\[Pasted text #\d+: \d+ lines \u2192 (.+?)\]')
|
|
|
|
|
|
class HermesCLI(CLIInitMixin, CLITuiRuntimeMixin, CLIProcessNotificationsMixin, CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin, CLITuiMixin, CLIStatusBarMixin, CLIVoiceMixin, CLIModelSwitchMixin, CLISessionMixin, CLIStreamMixin, CLIModalMixin, CLITerminalMixin, CLIInfoMixin, CLILoopsMixin, CLIChatTurnMixin):
|
|
"""Interactive REPL for the Hermes Agent."""
|
|
|
|
# Seeded -q first message (see _should_seed_interactive); run() re-creates
|
|
# _pending_input, so it is enqueued only after the fresh queue exists.
|
|
_seeded_first_message: Optional["_SeededQueryMessage"] = None
|
|
# Inspection surfaces (banner, /tools, status line) read this on partially built instances too.
|
|
disabled_toolsets: Optional[List[str]] = None
|
|
|
|
def __init__(
|
|
self,
|
|
model: str = None,
|
|
toolsets: List[str] = None,
|
|
provider: str = None,
|
|
reasoning: str = None,
|
|
api_key: str = None,
|
|
base_url: str = None,
|
|
max_turns: int = None,
|
|
run_budget: float = None,
|
|
verbose: Optional[bool] = None,
|
|
compact: bool = False,
|
|
resume: str = None,
|
|
checkpoints: bool = False,
|
|
pass_session_id: bool = False,
|
|
ignore_rules: bool = False,
|
|
):
|
|
"""CLI args win over config; ``reasoning`` is per-run only; ``resume`` restores history from SQLite."""
|
|
self._init_display_options(verbose, compact)
|
|
self._init_model_routing(model, toolsets, provider, reasoning, api_key, base_url, max_turns, run_budget,
|
|
checkpoints, pass_session_id, ignore_rules)
|
|
self._init_runtime_state(resume)
|
|
|
|
|
|
def _claim_active_session(self, surface: str = "cli", *, stderr: bool = False) -> bool:
|
|
"""Claim a global active-session slot for this CLI process."""
|
|
if self._active_session_lease is not None:
|
|
return True
|
|
try:
|
|
from hermes_cli.active_sessions import format_refusal_stderr, try_acquire_active_session
|
|
|
|
lease, message = try_acquire_active_session(
|
|
session_id=self.session_id,
|
|
surface=surface,
|
|
config=self.config,
|
|
# Writer identity: a re-claim by this process replaces its own entry.
|
|
# See #94595.
|
|
metadata={"live_session_id": str(self.session_id)},
|
|
)
|
|
except Exception as exc:
|
|
logger.warning("Failed to claim active session slot: %s", exc)
|
|
return True
|
|
if message:
|
|
print(format_refusal_stderr(message), file=sys.stderr) if stderr else self._console_print(f"[bold red]{message}[/]")
|
|
return False
|
|
self._active_session_lease = lease
|
|
with suppress(Exception):
|
|
atexit.register(self._release_active_session)
|
|
return True
|
|
|
|
def _release_active_session(self) -> None:
|
|
lease = getattr(self, "_active_session_lease", None)
|
|
if lease is None:
|
|
return
|
|
try:
|
|
lease.release()
|
|
except Exception:
|
|
logger.debug("Failed to release active session slot", exc_info=True)
|
|
finally:
|
|
self._active_session_lease = None
|
|
|
|
_PET_FRAME_INTERVAL = 0.16
|
|
_PET_CFG_INTERVAL = 2.5
|
|
|
|
def _install_tool_callbacks(self) -> None:
|
|
"""Install tool callbacks that need the live prompt UI."""
|
|
if self._tool_callbacks_installed:
|
|
return
|
|
set_sudo_password_callback(self._sudo_password_callback)
|
|
set_approval_callback(self._approval_callback)
|
|
set_secret_capture_callback(self._secret_capture_callback)
|
|
from agent.vault_backends.unlock import set_code_prompt_callback, set_save_login_prompt_callback, set_unlock_prompt_callback
|
|
set_unlock_prompt_callback(self._vault_unlock_callback)
|
|
set_save_login_prompt_callback(self._vault_save_login_callback)
|
|
set_code_prompt_callback(self._vault_code_callback)
|
|
self._tool_callbacks_installed = True
|
|
|
|
def _ensure_tirith_security(self) -> None:
|
|
"""Check tirith availability once before tools can run terminal commands."""
|
|
if self._tirith_security_checked:
|
|
return
|
|
self._tirith_security_checked = True
|
|
try:
|
|
from tools.tirith_security import ensure_installed, is_platform_supported, missing_is_expected
|
|
|
|
if (
|
|
ensure_installed(log_failures=False) is None and is_platform_supported()
|
|
and (self.config.get("security", {}) or {}).get("tirith_enabled", True)
|
|
):
|
|
# First launch after install downloads tirith in the background;
|
|
# warning then would report a fault that resolves itself.
|
|
if missing_is_expected():
|
|
logger.info("tirith not ready (downloading or lazy installs off); pattern matching only")
|
|
else:
|
|
_cprint(
|
|
f" {_DIM}⚠ tirith security scanner enabled but not available "
|
|
f"— command scanning will use pattern matching only{_RST}"
|
|
)
|
|
except Exception as exc:
|
|
logger.debug("tirith availability check failed: %s", exc)
|
|
|
|
def _show_security_advisories(self):
|
|
"""Startup banner for unacked security advisories, on stderr (piped stdout stays clean); 24h rate-limited."""
|
|
try:
|
|
from hermes_cli.security_advisories import detect_compromised, startup_banner
|
|
|
|
banner = startup_banner(detect_compromised())
|
|
if banner:
|
|
print(banner, file=sys.stderr, flush=True)
|
|
except Exception:
|
|
pass # never block startup
|
|
|
|
def _show_browser_backend_notice(self):
|
|
"""Once-per-24h hint when the default Browser Use backend silently fell back to built-in tools."""
|
|
try:
|
|
from tools.browser_use_cli import default_downgrade_notice
|
|
|
|
notice = default_downgrade_notice()
|
|
if notice:
|
|
from gateway.warning_notifications import render_notification
|
|
render_notification(lambda: self._console_print(f"[yellow]⚠ {notice}[/yellow]"), platform="cli")
|
|
except Exception:
|
|
logger.debug("browser backend notice failed", exc_info=True)
|
|
|
|
def finalize_preloaded_skills(self) -> None:
|
|
"""Join the background --skills preload and fold it into the prompt (idempotent).
|
|
|
|
Raises ``ValueError`` only when EVERY requested skill was unknown.
|
|
"""
|
|
if getattr(self, "_preload_skills_finalized", False):
|
|
return
|
|
thread = getattr(self, "_preload_skills_thread", None)
|
|
if thread is None:
|
|
self._preload_skills_finalized = True
|
|
return
|
|
thread.join(timeout=120)
|
|
self._preload_skills_finalized = True
|
|
err = getattr(self, "_preload_skills_error", None)
|
|
if err is not None:
|
|
raise err
|
|
auto_result = getattr(self, "_auto_load_skills_result", None)
|
|
if auto_result and auto_result[2]:
|
|
logger.warning("skills.auto_load: skill(s) not found or disabled, skipped: %s", ", ".join(auto_result[2]))
|
|
# auto_load names first, then explicit -s names that were not already pinned.
|
|
self.preloaded_skills = list(auto_result[1]) if auto_result else []
|
|
result = getattr(self, "_preload_skills_result", None)
|
|
if not result:
|
|
return
|
|
skills_prompt, loaded_skills, missing_skills = result
|
|
if missing_skills:
|
|
missing_display = ", ".join(missing_skills)
|
|
# A typo'd name must not crash a kanban worker; only a fully-missing set fails loudly.
|
|
if loaded_skills:
|
|
logger.warning(
|
|
"Unknown skill(s) requested, skipping: %s. "
|
|
"Continuing with: %s. "
|
|
"List available skills with `hermes skills list`.",
|
|
missing_display,
|
|
", ".join(loaded_skills),
|
|
)
|
|
else:
|
|
raise ValueError(f"Unknown skill(s): {missing_display}")
|
|
if skills_prompt:
|
|
self.system_prompt = "\n\n".join(p for p in (self.system_prompt, skills_prompt) if p).strip()
|
|
self.preloaded_skills += [name for name in loaded_skills if name not in self.preloaded_skills]
|
|
|
|
def _show_tool_availability_warnings(self):
|
|
"""Warn about toolsets switched off at startup (missing API keys, unusable terminal backend)."""
|
|
try:
|
|
# Runs on a daemon thread on the snapshot fast path: keep the imports to modules the
|
|
# registry walk already loaded plus the pure notices module (a heavy import here races
|
|
# importlib's module locks against the main thread).
|
|
from model_tools import check_tool_availability
|
|
from hermes_cli.tool_availability_notices import (
|
|
current_terminal_backend, filter_to_enabled_toolsets, tool_availability_warning_lines,
|
|
)
|
|
from tools.terminal_tool import terminal_backend_unavailable_reason
|
|
from toolsets import resolve_toolset
|
|
|
|
_, unavailable = check_tool_availability()
|
|
# Only toolsets this CLI session actually has. The selection is usually a composite bundle
|
|
# (``hermes-cli``), so expand it to tool names before matching — a raw name comparison
|
|
# matched nothing on a default install and silently dropped the terminal notice.
|
|
unavailable = filter_to_enabled_toolsets(unavailable, self.enabled_toolsets or [], resolve_toolset)
|
|
lines = tool_availability_warning_lines(
|
|
unavailable, terminal_reason=terminal_backend_unavailable_reason(),
|
|
terminal_backend=current_terminal_backend())
|
|
if lines:
|
|
self._console_print()
|
|
for line in lines:
|
|
self._console_print(line)
|
|
except Exception:
|
|
pass
|
|
|
|
def show_config(self):
|
|
"""Display current configuration with kawaii ASCII art."""
|
|
terminal_env = os.getenv("TERMINAL_ENV", "local")
|
|
terminal_cwd = os.getenv("TERMINAL_CWD", os.getcwd())
|
|
terminal_timeout = os.getenv("TERMINAL_TIMEOUT", "60")
|
|
|
|
config_path = _hermes_home / 'config.yaml'
|
|
if not config_path.exists():
|
|
config_path = Path(__file__).parent / 'cli-config.yaml'
|
|
config_status = "(loaded)" if config_path.exists() else "(not found)"
|
|
|
|
# ``api_key`` may be a callable (Entra ID bearer provider): never invoke it. Prefer the
|
|
# LIVE agent's key: the constructor seeds self.api_key from env before provider
|
|
# resolution, so on non-OpenAI providers it can be another vendor's key.
|
|
from agent.azure_identity_adapter import is_token_provider
|
|
|
|
display_key = self.api_key
|
|
if self.agent is not None and getattr(self.agent, "api_key", None):
|
|
display_key = self.agent.api_key
|
|
if is_token_provider(display_key):
|
|
api_key_display = "Microsoft Entra ID"
|
|
elif isinstance(display_key, str) and len(display_key) > 12:
|
|
api_key_display = f"{display_key[:8]}...{display_key[-4:]}"
|
|
else:
|
|
api_key_display = "Not set!"
|
|
|
|
title = "(^_^) Configuration"
|
|
width = 50
|
|
pad = width - len(title)
|
|
ssh_target = (
|
|
f"{os.getenv('TERMINAL_SSH_USER', 'not set')}@{os.getenv('TERMINAL_SSH_HOST', 'not set')}"
|
|
f":{os.getenv('TERMINAL_SSH_PORT', '22')}"
|
|
) if terminal_env == "ssh" else None
|
|
sections = (
|
|
("Model", (("Model: ", self.model), ("Base URL: ", self.base_url), ("API Key: ", api_key_display))),
|
|
("Terminal", (
|
|
("Environment: ", terminal_env),
|
|
*((("SSH Target: ", ssh_target),) if ssh_target else ()),
|
|
("Working Dir: ", terminal_cwd),
|
|
("Timeout: ", f"{terminal_timeout}s"),
|
|
)),
|
|
("Agent", (
|
|
("Max Turns: ", self.max_turns),
|
|
("Toolsets: ", ", ".join(self.enabled_toolsets) if self.enabled_toolsets else "all"),
|
|
("Verbose: ", self.verbose),
|
|
)),
|
|
("Session", (
|
|
("Started: ", self.session_start.strftime("%Y-%m-%d %H:%M:%S")),
|
|
("Config File:", f"{config_path} {config_status}"),
|
|
)),
|
|
)
|
|
print()
|
|
print("+" + "-" * width + "+")
|
|
print("|" + " " * (pad // 2) + title + " " * (pad - pad // 2) + "|")
|
|
print("+" + "-" * width + "+")
|
|
for name, rows in sections:
|
|
print()
|
|
print(f" -- {name} --")
|
|
for label, value in rows:
|
|
print(f" {label} {value}")
|
|
print()
|
|
|
|
# canonical command -> (method name, pass cmd_original?). Absent commands resolve to
|
|
# ``_handle_<name>_command(cmd)``. Looked up via getattr at dispatch time so
|
|
# monkeypatching works. A handler returning False exits the REPL.
|
|
_SLASH_DISPATCH: dict[str, tuple[str, bool]] = {
|
|
"exit": ("_cmd_exit", True), "quit": ("_cmd_exit", True), "help": ("_cmd_help", True),
|
|
"palette": ("_open_command_palette", False), "whoami": ("_handle_whoami_command", False),
|
|
"profile": ("_handle_profile_command", False), "toolsets": ("show_toolsets", False),
|
|
"config": ("show_config", False), "redraw": ("_cmd_redraw", True), "clear": ("_cmd_clear", True),
|
|
"history": ("show_history", False), "title": ("_cmd_title", True), "new": ("_cmd_new", True),
|
|
"model": ("_handle_model_switch", True), "codex-runtime": ("_handle_codex_runtime", True),
|
|
"retry": ("_cmd_retry", True), "prompt": ("_handle_prompt_compose_command", True),
|
|
"undo": ("_cmd_undo", True), "save": ("save_conversation", True), "skills": ("_cmd_skills", True),
|
|
"platforms": ("_show_gateway_status", False), "status": ("_show_session_status", False),
|
|
"context": ("_show_context_breakdown", True), "egress": ("_cmd_egress", True),
|
|
"statusbar": ("_cmd_statusbar", True), "verbose": ("_toggle_verbose", False), "yolo": ("_toggle_yolo", False),
|
|
"compress": ("_manual_compress", True), "subscription": ("_show_subscription", False),
|
|
"topup": ("_show_billing", True), "insights": ("_show_insights", True), "update": ("_cmd_update", True),
|
|
"version": ("_cmd_version", True), "paste": ("_handle_paste_command", False), "reload": ("_cmd_reload", True),
|
|
"reload-mcp": ("_confirm_and_reload_mcp", True), "reload-skills": ("_cmd_reload_skills", True),
|
|
"plugins": ("_cmd_plugins", True), "stop": ("_handle_stop_command", False),
|
|
"agents": ("_handle_agents_command", False), "bg": ("_handle_background_command", True),
|
|
"queue": ("_cmd_queue", True), "steer": ("_cmd_steer", True), "moa": ("_cmd_moa", True),
|
|
}
|
|
|
|
@classmethod
|
|
def _slash_handler(cls, canonical: str) -> tuple[str, bool] | None:
|
|
"""(method name, pass cmd_original?) for a registered command, else None."""
|
|
entry = cls._SLASH_DISPATCH.get(canonical)
|
|
if entry is None:
|
|
name = f"_handle_{canonical.replace('-', '_')}_command"
|
|
if callable(getattr(cls, name, None)):
|
|
entry = (name, True)
|
|
return entry
|
|
|
|
def process_command(self, command: str) -> bool:
|
|
"""Dispatch a slash command; returns False to exit the REPL."""
|
|
cmd_lower = command.lower().strip() # lowercase only for matching; args keep their case
|
|
cmd_original = command.strip()
|
|
|
|
# Aliases resolve via the central registry (hermes_cli/commands.py).
|
|
from hermes_cli.commands import resolve_command as _resolve_cmd
|
|
_base_word = cmd_lower.split()[0].lstrip("/")
|
|
_cmd_def = _resolve_cmd(_base_word)
|
|
canonical = _cmd_def.name if _cmd_def else _base_word
|
|
|
|
# Observer-only pre_command plugin hook (return values ignored; never raises).
|
|
if _cmd_def is not None:
|
|
from hermes_cli.plugins import fire_pre_command_hook
|
|
fire_pre_command_hook(
|
|
surface="cli", command=canonical, alias_used=_base_word, args_raw=_slash_args(cmd_original),
|
|
session_key=getattr(self, "session_id", None), platform="cli",
|
|
)
|
|
|
|
# A bare `/resume` prompt is one-shot: any other command disarms it so a later
|
|
# number isn't swallowed as a stale selection.
|
|
# See #34584.
|
|
if canonical not in {"resume", "sessions"}:
|
|
# Armed when a bare `/resume` prints the recent-sessions list so the very next bare numeric
|
|
# input (e.g. `3`) resolves to that session. Holds the exact list used for index resolution;
|
|
# one-shot (cleared on the next submitted input, whether it's the selection or anything else).
|
|
# See #34584.
|
|
self._pending_resume_sessions = None
|
|
|
|
entry = self._slash_handler(canonical)
|
|
if entry is None:
|
|
return self._process_unregistered_slash(cmd_original, cmd_lower)
|
|
method_name, pass_arg = entry
|
|
handler = getattr(self, method_name)
|
|
result = handler(cmd_original) if pass_arg else handler()
|
|
return result is not False
|
|
|
|
def _process_unregistered_slash(self, cmd_original: str, cmd_lower: str) -> bool:
|
|
"""Slash input with no built-in handler; precedence: quick_commands -> plugins -> bundles -> skills -> prefix expansion."""
|
|
base_cmd = cmd_lower.split()[0]
|
|
bare = base_cmd.lstrip("/")
|
|
skill_commands = _ensure_skill_commands()
|
|
skill_bundles = get_skill_bundles()
|
|
quick_commands = self.config.get("quick_commands", {})
|
|
user_args = cmd_original[len(base_cmd):].strip()
|
|
if bare in quick_commands:
|
|
return self._run_quick_command(base_cmd, quick_commands[bare], user_args)
|
|
if bare in _get_plugin_cmd_handler_names():
|
|
self._run_plugin_slash_command(base_cmd, user_args)
|
|
elif base_cmd in skill_bundles:
|
|
self._run_skill_bundle_command(base_cmd, skill_bundles[base_cmd], user_args)
|
|
elif base_cmd in skill_commands:
|
|
self._run_skill_slash_command(base_cmd, skill_commands[base_cmd], user_args)
|
|
else:
|
|
return self._expand_slash_prefix(cmd_original, cmd_lower, skill_commands, skill_bundles)
|
|
return True
|
|
|
|
def _run_quick_command(self, base_cmd: str, qcmd: dict, user_args: str) -> bool:
|
|
"""User-defined quick command (config.yaml): ``exec`` runs a shell snippet, ``alias`` re-dispatches."""
|
|
qtype = qcmd.get("type")
|
|
if qtype == "alias":
|
|
target = qcmd.get("target", "").strip()
|
|
if target:
|
|
target = target if target.startswith("/") else f"/{target}"
|
|
return self.process_command(f"{target} {user_args}".strip())
|
|
self._console_print(f"[bold red]Quick command '{base_cmd}' has no target defined[/]")
|
|
return True
|
|
if qtype != "exec":
|
|
self._console_print(f"[bold red]Quick command '{base_cmd}' has unsupported type (supported: 'exec', 'alias')[/]")
|
|
return True
|
|
import subprocess
|
|
exec_cmd = qcmd.get("command", "")
|
|
if not exec_cmd:
|
|
self._console_print(f"[bold red]Quick command '{base_cmd}' has no command defined[/]")
|
|
return True
|
|
try:
|
|
# shell=True is intentional (user-authored config snippets, never LLM controlled);
|
|
# the env is sanitized because this process holds every API key.
|
|
from tools.environments.local import build_subprocess_env
|
|
from hermes_cli._subprocess_compat import windows_hide_flags
|
|
result = subprocess.run(
|
|
exec_cmd, shell=True, capture_output=True, text=True, encoding="utf-8", errors="replace",
|
|
timeout=30, env=build_subprocess_env(),
|
|
creationflags=windows_hide_flags(), # no console flash on Windows (#56747)
|
|
)
|
|
# See #56747.
|
|
output = result.stdout.strip() or result.stderr.strip()
|
|
if output:
|
|
from agent.redact import redact_sensitive_text
|
|
self._console_print(_rich_text_from_ansi(redact_sensitive_text(output)))
|
|
else:
|
|
self._console_print("[dim]Command returned no output[/]")
|
|
except subprocess.TimeoutExpired:
|
|
self._console_print("[bold red]Quick command timed out (30s)[/]")
|
|
except Exception as e:
|
|
self._console_print(f"[bold red]Quick command error: {e}[/]")
|
|
return True
|
|
|
|
def _run_plugin_slash_command(self, base_cmd: str, user_args: str) -> None:
|
|
from hermes_cli.plugins import get_plugin_command_handler, resolve_plugin_command_result
|
|
|
|
plugin_handler = get_plugin_command_handler(base_cmd.lstrip("/"))
|
|
if not plugin_handler:
|
|
return
|
|
try:
|
|
result = resolve_plugin_command_result(plugin_handler(user_args))
|
|
if result:
|
|
_cprint(str(result))
|
|
except Exception as e:
|
|
_cprint(f"\033[1;31mPlugin command error: {e}{_RST}")
|
|
|
|
def _queue_skill_message(self, msg) -> None:
|
|
if hasattr(self, '_pending_input'):
|
|
self._pending_input.put(msg)
|
|
|
|
def _run_skill_bundle_command(self, base_cmd: str, bundle_info: dict, user_instruction: str) -> None:
|
|
"""``/<bundle>`` loads several skills at once (bundles win over same-named skills)."""
|
|
bundle_result = build_bundle_invocation_message(base_cmd, user_instruction, task_id=self.session_id)
|
|
if not bundle_result:
|
|
ChatConsole().print(f"[bold red]Failed to load bundle for {base_cmd}[/]")
|
|
return
|
|
msg, loaded_names, missing = bundle_result
|
|
self._queue_loaded_skills(msg, f"Loading bundle: {bundle_info['name']} ({len(loaded_names)} skills)", missing)
|
|
|
|
def _queue_loaded_skills(self, msg, label: str, missing) -> None:
|
|
print(f"\n⚡ {label}")
|
|
if missing:
|
|
ChatConsole().print(f"[yellow]Skipped missing skills: {', '.join(missing)}[/]")
|
|
self._queue_skill_message(msg)
|
|
|
|
def _run_skill_slash_command(self, base_cmd: str, skill_info: dict, rest: str) -> None:
|
|
"""``/<skill> ...``; stacked ``/skill-a /skill-b do XYZ`` loads every leading skill (up to 5)."""
|
|
from agent.skill_commands import build_stacked_skill_invocation_message, split_stacked_skill_commands
|
|
|
|
extra_keys, user_instruction = split_stacked_skill_commands(rest)
|
|
if extra_keys:
|
|
stacked_result = build_stacked_skill_invocation_message(
|
|
[base_cmd, *extra_keys], user_instruction, task_id=self.session_id,
|
|
)
|
|
if not stacked_result:
|
|
ChatConsole().print(f"[bold red]Failed to load stacked skills for {base_cmd}[/]")
|
|
return
|
|
msg, loaded_names, missing = stacked_result
|
|
self._queue_loaded_skills(
|
|
msg, f"Loading {len(loaded_names)} stacked skills: {', '.join(loaded_names)}", missing
|
|
)
|
|
return
|
|
msg = build_skill_invocation_message(base_cmd, rest, task_id=self.session_id)
|
|
if msg:
|
|
self._queue_loaded_skills(msg, f"Loading skill: {skill_info['name']}", None)
|
|
else:
|
|
ChatConsole().print(f"[bold red]Failed to load skill for {base_cmd}[/]")
|
|
|
|
def _expand_slash_prefix(self, cmd_original: str, cmd_lower: str, skill_commands, skill_bundles) -> bool:
|
|
"""Unique-prefix expansion against built-in COMMANDS + skill commands/bundles (agrees with tab-completion)."""
|
|
from hermes_cli.commands import COMMANDS
|
|
typed_base = cmd_lower.split()[0]
|
|
all_known = set(COMMANDS) | set(skill_commands) | set(skill_bundles)
|
|
matches = [c for c in all_known if c.startswith(typed_base)]
|
|
if len(matches) > 1:
|
|
if typed_base in matches:
|
|
matches = [typed_base]
|
|
else:
|
|
# Unique shortest match wins: /qui -> /quit (5) over /quint-pipeline (15)
|
|
min_len = min(len(c) for c in matches)
|
|
shortest = [c for c in matches if len(c) == min_len]
|
|
if len(shortest) == 1:
|
|
matches = shortest
|
|
if len(matches) == 1 and matches[0] != typed_base:
|
|
# Expand to the full name, preserving arguments.
|
|
return self.process_command(matches[0] + cmd_original.strip()[len(typed_base):])
|
|
if len(matches) > 1:
|
|
_cprint(f"{_ACCENT}Ambiguous command: {cmd_lower}{_RST}")
|
|
_cprint(f"{_DIM}Did you mean: {', '.join(sorted(matches))}?{_RST}")
|
|
else:
|
|
# Exact token with no handler (never re-dispatch the same token: recursion), or no match.
|
|
from hermes_cli.cli_unknown_command import unknown_command_lines
|
|
lead, pointer = unknown_command_lines(cmd_lower, all_known)
|
|
_cprint(f"\033[1;31m{lead}{_RST}")
|
|
_cprint(f"{_DIM}{_ACCENT}{pointer}{_RST}")
|
|
return True
|
|
|
|
def _drain_interrupt_queue_to_pending_input(self) -> None:
|
|
"""Move stray ``_interrupt_queue`` messages into ``_pending_input`` after every turn.
|
|
|
|
Busy-time input lands in ``_interrupt_queue`` and is only drained by the explicit
|
|
interrupt path; a turn that finishes naturally would otherwise strand it and the
|
|
CLI appears to hang. Never raises.
|
|
|
|
Called once at the end of every turn from ``process_loop``'s ``finally`` block. Catches and swallows
|
|
``Exception`` because the drain must never break the main loop. (#20271)
|
|
"""
|
|
try:
|
|
while not self._interrupt_queue.empty():
|
|
stray = self._interrupt_queue.get_nowait()
|
|
if stray:
|
|
self._pending_input.put(stray)
|
|
except Exception:
|
|
pass
|
|
|
|
def _on_reasoning(self, reasoning_text: str):
|
|
"""Callback for intermediate reasoning display during tool-call loops."""
|
|
if not reasoning_text:
|
|
return
|
|
self._reasoning_preview_buf = getattr(self, "_reasoning_preview_buf", "") + reasoning_text
|
|
self._flush_reasoning_preview(force=False)
|
|
|
|
# Inline tokens that bypass the destructive-slash confirmation modal (scripting, or
|
|
# when the modal can't be marshaled onto the app loop).
|
|
# A general escape hatch for non-interactive use (scripting/automation) and for the degraded path where
|
|
# the modal can't be marshaled onto the app loop — lets users self-serve without flipping
|
|
# approvals.destructive_slash_confirm in config. (Native Windows now drives the modal normally — see
|
|
# #33961.)
|
|
_DESTRUCTIVE_SKIP_TOKENS = frozenset({"now", "--yes", "-y"})
|
|
|
|
|
|
def run(self):
|
|
"""Run the interactive CLI loop with persistent input at bottom."""
|
|
if not self._claim_active_session("cli"):
|
|
return
|
|
|
|
self._tui_print_startup()
|
|
self._tui_init_run_state()
|
|
kb = self._tui_build_key_bindings()
|
|
layout, style = self._tui_build_layout(kb)
|
|
|
|
app = self._tui_build_application(layout, kb, style)
|
|
_disable_prompt_toolkit_cpr_warning(app)
|
|
app.after_render += self._pet_flush_kitty_frame
|
|
self._app = app
|
|
|
|
# Ghost status-bar lines on resize: pt's renderer scrolls the terminal after each
|
|
# paint, pushing chrome into scrollback where a column-shrink reflows it into
|
|
# duplicates. Wrapping _output_screen_diff keeps its reserve-space branch from firing.
|
|
try:
|
|
# Background: prompt_toolkit's renderer (renderer.py L232-242) explicitly moves the cursor to
|
|
# the bottom of the canvas after painting "to make sure the terminal scrolls up, even when the
|
|
# lower lines of the canvas just contain whitespace". In non-fullscreen mode this scrolls chrome
|
|
# content (status bar, input rules) into terminal scrollback on every render. When the terminal
|
|
# column-shrinks, the emulator reflows the previously rendered full-width rows into multiple
|
|
# narrower rows that get pushed up — leaving ghost duplicates AND polluting scrollback. Same
|
|
# issue as pt #29 (open since 2014), #1675, #1933. Surgical fix: wrap _output_screen_diff so
|
|
# that when its internal `if current_height > previous_screen.height` branch fires (the one that
|
|
# does the bottom-cursor-move), we make it fall through by inflating previous_screen.height
|
|
# first.
|
|
import prompt_toolkit.renderer as _pt_renderer
|
|
from prompt_toolkit.renderer import _output_screen_diff as _orig_osd
|
|
|
|
if not getattr(_pt_renderer, "_hermes_osd_patched", False):
|
|
_pt_renderer._output_screen_diff = functools.partial(
|
|
_hermes_call_output_screen_diff, _orig_osd
|
|
)
|
|
_pt_renderer._hermes_osd_patched = True
|
|
except Exception:
|
|
pass
|
|
|
|
_apply_bracketed_paste_timeout_patch()
|
|
|
|
self._install_resize_recovery(app)
|
|
|
|
threading.Thread(target=self._tui_spinner_loop, daemon=True).start()
|
|
threading.Thread(target=self._tui_process_loop, daemon=True).start()
|
|
# Wake word listener off-thread so a first-run engine install never blocks the prompt.
|
|
threading.Thread(target=self._tui_wake_startup, daemon=True, name="wake-startup").start()
|
|
|
|
atexit.register(_run_cleanup)
|
|
self._tui_install_signal_handlers()
|
|
|
|
if not self._tui_stdin_usable():
|
|
_run_cleanup()
|
|
self._print_exit_summary()
|
|
return
|
|
|
|
try:
|
|
with patch_stdout():
|
|
try:
|
|
# 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.
|
|
import asyncio as _aio
|
|
_aio.get_running_loop().set_exception_handler(self._tui_suppress_closed_loop_errors)
|
|
except Exception:
|
|
pass # no running loop -- nothing to patch
|
|
# Record that the app enables focus reporting + mouse tracking so _run_cleanup
|
|
# resets them; extended key modes are popped by the same reset.
|
|
# When multiline shortcuts are on, also ask supported terminals (e.g. iTerm2) to report
|
|
# modified keys distinctly (kitty protocol + modifyOtherKeys); the cleanup reset pops both
|
|
# modes. See #36823.
|
|
_mark_tui_input_modes_active()
|
|
if self._tui_multiline_shortcuts:
|
|
_enable_extended_enter_keys(app.output)
|
|
self._pet_start_anim()
|
|
app.run()
|
|
except (EOFError, KeyboardInterrupt, BrokenPipeError):
|
|
pass
|
|
except (KeyError, OSError) as _stdin_err:
|
|
# Selector registration failures from broken stdin and I/O errors from a
|
|
# broken stdout during interrupt (EIO is suppressed).
|
|
_errno = getattr(_stdin_err, "errno", None) if isinstance(_stdin_err, OSError) else None
|
|
_msg = str(_stdin_err)
|
|
if _errno == errno.EIO:
|
|
pass
|
|
elif _errno in {errno.EINVAL, errno.EBADF} or any(
|
|
s in _msg for s in ("is not registered", "Bad file descriptor", "Invalid argument")
|
|
):
|
|
print(
|
|
f"\nError: stdin is not usable ({_stdin_err}).\n"
|
|
"This can happen with certain Python installations (e.g. uv-managed cPython on macOS)\n"
|
|
"where kqueue cannot register fd 0.\n"
|
|
"Try reinstalling Python via pyenv or Homebrew, then re-run: hermes setup"
|
|
)
|
|
else:
|
|
raise
|
|
finally:
|
|
# A resize right before exit leaves its recovery (and the paints it held) unrun.
|
|
_release_paints()
|
|
self._tui_shutdown()
|
|
|
|
# /update relaunch happens here, after prompt_toolkit restored terminal modes, on the
|
|
# main thread (the process_loop thread would skip cleanup / only exit itself on Windows).
|
|
if self._pending_relaunch:
|
|
from hermes_cli.relaunch import relaunch
|
|
relaunch(self._pending_relaunch, preserve_inherited=False)
|
|
|
|
|
|
def _build_cli_from_args(model, toolsets, provider, reasoning, api_key, base_url, max_turns, run_budget, verbose, compact, resume, checkpoints, pass_session_id, ignore_rules, skills):
|
|
"""Resolve the toolset list (explicit / coding posture / platform default), construct HermesCLI, and start the background skills preload."""
|
|
toolsets_list = None
|
|
if isinstance(toolsets, str) and toolsets:
|
|
toolsets_list = [t.strip() for t in toolsets.split(",")]
|
|
elif isinstance(toolsets, (list, tuple)) and toolsets:
|
|
# Fire may pass multiple --toolsets as a tuple
|
|
toolsets_list = []
|
|
for t in toolsets:
|
|
toolsets_list.extend([x.strip() for x in t.split(",")] if isinstance(t, str) else [str(t)])
|
|
elif not toolsets:
|
|
# Coding posture inside a code workspace, else the shared platform resolver.
|
|
try:
|
|
from agent.coding_context import coding_selection
|
|
toolsets_list = coding_selection(platform="cli", config=CLI_CONFIG)
|
|
except Exception:
|
|
toolsets_list = None
|
|
if toolsets_list is None:
|
|
from hermes_cli.tools_config import _get_platform_tools
|
|
toolsets_list = sorted(_get_platform_tools(CLI_CONFIG, "cli"))
|
|
|
|
parsed_skills = _parse_skills_argument(skills)
|
|
|
|
try:
|
|
cli = HermesCLI(
|
|
model=model,
|
|
toolsets=toolsets_list,
|
|
provider=provider,
|
|
reasoning=reasoning,
|
|
api_key=api_key,
|
|
base_url=base_url,
|
|
max_turns=max_turns,
|
|
run_budget=run_budget,
|
|
verbose=verbose,
|
|
compact=compact,
|
|
resume=resume,
|
|
checkpoints=checkpoints,
|
|
pass_session_id=pass_session_id,
|
|
ignore_rules=ignore_rules,
|
|
)
|
|
except ImportError as e:
|
|
# Direct `python cli.py` bypasses cmd_chat's partial-update ImportError handler.
|
|
from hermes_constants import emit_partial_update_hint
|
|
|
|
if emit_partial_update_hint(e):
|
|
sys.exit(1)
|
|
raise
|
|
|
|
# skills.auto_load rides the same background preload as -s; --ignore-rules skips it with
|
|
# the rest of the auto-injected context. Resolved here (not lazily in the agent) so the
|
|
# session id is real for ${HERMES_SESSION_ID} and -s can dedupe against it.
|
|
from agent.skill_commands import build_auto_load_prompt, resolve_auto_load_skills
|
|
auto_load_names = [] if getattr(cli, "ignore_rules", ignore_rules) else resolve_auto_load_skills(CLI_CONFIG)
|
|
if not auto_load_names:
|
|
cli._auto_load_skills_result = ("", [], [])
|
|
if parsed_skills or auto_load_names:
|
|
# Load the skill payloads in the background: skill_view walks the full skills
|
|
# tree per skill (~0.5s for a large library) and the result is only consumed
|
|
# at agent init, not by the banner. finalize_preloaded_skills() joins the
|
|
# thread before any consumer reads cli.system_prompt.
|
|
def _load_preloaded_skills() -> None:
|
|
try:
|
|
if auto_load_names:
|
|
cli._auto_load_skills_result = build_auto_load_prompt(task_id=cli.session_id, user_config=CLI_CONFIG)
|
|
if parsed_skills:
|
|
cli._preload_skills_result = build_preloaded_skills_prompt(
|
|
parsed_skills, task_id=cli.session_id, excluded_loaded_names=set(cli._auto_load_skills_result[1]))
|
|
except Exception as exc: # surfaced by finalize
|
|
cli._preload_skills_error = exc
|
|
|
|
cli._preload_skills_requested = [*auto_load_names, *(s for s in parsed_skills if s not in auto_load_names)]
|
|
cli._preload_skills_thread = threading.Thread(target=_load_preloaded_skills, name="skills-preload", daemon=True)
|
|
cli._preload_skills_thread.start()
|
|
return cli
|
|
|
|
|
|
def _run_legacy_gateway():
|
|
"""Legacy `cli.py --gateway` entry: arm the startup watchdog (before importing the gateway graph), then run it."""
|
|
import asyncio
|
|
with suppress(Exception):
|
|
from hermes_startup_watchdog import arm_startup_watchdog
|
|
arm_startup_watchdog()
|
|
from gateway.run import start_gateway
|
|
print("Starting Hermes Gateway (messaging platforms)...")
|
|
asyncio.run(start_gateway())
|
|
|
|
|
|
def _start_worktree_setup(list_tools, list_toolsets, worktree, w):
|
|
"""Start isolated-worktree creation (+ tool prewarm) in the background.
|
|
|
|
Returns a join callable that publishes ``_active_worktree``/TERMINAL_CWD and
|
|
schedules stale-worktree GC, or None when no worktree is wanted.
|
|
"""
|
|
if list_tools or list_toolsets or not (worktree or w or CLI_CONFIG.get("worktree", False)):
|
|
return None
|
|
# Overlap tool discovery with the I/O-bound worktree setup so show_banner() hits a warm
|
|
# cache (~0.4s). Only on the -w path: plain `hermes` has no I/O wait to hide.
|
|
def _prewarm_tools() -> None:
|
|
try:
|
|
import model_tools as _mt
|
|
_mt.get_tool_definitions(quiet_mode=True)
|
|
except Exception:
|
|
logger.debug("tool prewarm failed", exc_info=True)
|
|
|
|
threading.Thread(target=_prewarm_tools, name="tool-prewarm", daemon=True).start()
|
|
_sync_base = CLI_CONFIG.get("worktree_sync", True)
|
|
_wt_result: dict = {}
|
|
|
|
def _create_worktree() -> None:
|
|
try:
|
|
_wt_result["info"] = _setup_worktree(sync_base=_sync_base)
|
|
except Exception:
|
|
logger.debug("worktree setup failed", exc_info=True)
|
|
_wt_result["info"] = None
|
|
|
|
_wt_thread = threading.Thread(target=_create_worktree, name="worktree-setup", daemon=True)
|
|
_wt_thread.start()
|
|
|
|
def _worktree_maintenance(repo: str) -> None:
|
|
_prune_stale_worktrees(repo)
|
|
_maintain_pack_health(repo)
|
|
|
|
def _join_worktree() -> Optional[Dict[str, str]]:
|
|
_wt_thread.join(timeout=120)
|
|
info = _wt_result.get("info")
|
|
if not info:
|
|
return info
|
|
global _active_worktree
|
|
_active_worktree = info
|
|
os.environ["TERMINAL_CWD"] = info["path"]
|
|
atexit.register(_cleanup_worktree, info)
|
|
# GC stale worktrees AFTER _setup_worktree so they never race on git's worktree
|
|
# metadata (the new tree is immune: <24h age gate + live pid lock); then repack
|
|
# once refs are final so lookups stay fast on multi-agent boxes.
|
|
_repo = _git_repo_root()
|
|
if _repo:
|
|
threading.Thread(target=_worktree_maintenance, args=(_repo,), name="worktree-prune", daemon=True).start()
|
|
return info
|
|
|
|
return _join_worktree
|
|
|
|
|
|
def main(
|
|
query: str = None,
|
|
q: str = None,
|
|
oneshot: bool = False,
|
|
image: str = None,
|
|
toolsets: str = None,
|
|
skills: str | list[str] | tuple[str, ...] = None,
|
|
model: str = None,
|
|
provider: str = None,
|
|
reasoning: str = None,
|
|
api_key: str = None,
|
|
base_url: str = None,
|
|
max_turns: int = None,
|
|
run_budget: float = None,
|
|
verbose: Optional[bool] = None,
|
|
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,
|
|
output_format: str = "text",
|
|
ignore_user_config: bool = False,
|
|
ignore_rules: bool = False,
|
|
):
|
|
"""
|
|
Hermes Agent CLI - Interactive AI Assistant
|
|
|
|
Args:
|
|
query: Query to run. On a real TTY this seeds an interactive session
|
|
(submitted literally as the first turn); with --oneshot/-Q or a
|
|
non-TTY it answers and exits. Alias: -q
|
|
q: Shorthand for --query
|
|
oneshot: With -q: force the legacy answer-and-exit single-query mode
|
|
even on a TTY.
|
|
image: Optional local image path to attach to a single query
|
|
toolsets: Comma-separated list of toolsets to enable (e.g., "web,terminal")
|
|
skills: Comma-separated or repeated list of skills to preload for the session
|
|
model: Model to use (default: anthropic/claude-opus-4-20250514)
|
|
provider: Inference provider ("auto", "openrouter", "nous", "openai-codex", "zai", "kimi-coding", "minimax", "minimax-cn")
|
|
reasoning: Reasoning effort for this run (none|minimal|low|medium|high|xhigh|max|ultra). Overrides agent.reasoning_effort.
|
|
api_key: API key for authentication
|
|
base_url: Base URL for the API
|
|
max_turns: Maximum tool-calling iterations (default: 60)
|
|
verbose: Enable verbose logging
|
|
compact: Use compact display mode
|
|
list_tools: List available tools and exit
|
|
list_toolsets: List available toolsets and exit
|
|
resume: Resume a previous session by its ID (e.g., 20260225_143052_a1b2c3)
|
|
worktree: Run in an isolated git worktree (for parallel agents). Alias: -w
|
|
w: Shorthand for --worktree
|
|
|
|
Examples:
|
|
python cli.py # Start interactive mode
|
|
python cli.py --toolsets web,terminal # Use specific toolsets
|
|
python cli.py --skills hermes-agent-dev,github-auth
|
|
python cli.py -q "What is Python?" # Single query mode
|
|
python cli.py -q "Describe this" --image ~/storage/shared/Pictures/cat.png
|
|
python cli.py --list-tools # List tools and exit
|
|
python cli.py --resume 20260225_143052_a1b2c3 # Resume session
|
|
python cli.py -w # Start in isolated git worktree
|
|
python cli.py -w -q "Fix issue #123" # Single query in worktree
|
|
"""
|
|
# UTF-8 stdio on Windows before any print (Rich box-drawing would UnicodeEncodeError on cp1252).
|
|
with suppress(Exception):
|
|
from hermes_cli.stdio import configure_windows_stdio
|
|
configure_windows_stdio()
|
|
|
|
os.environ["HERMES_INTERACTIVE"] = "1" # terminal_tool: interactive sudo prompts with timeout
|
|
# The banner names affected plugins; the raw per-name compat warnings would only duplicate it on stderr.
|
|
with suppress(Exception):
|
|
from hermes_cli.plugin_compat import quiet_for_interactive
|
|
quiet_for_interactive()
|
|
|
|
if gateway:
|
|
_run_legacy_gateway()
|
|
return
|
|
|
|
if not (list_tools or list_toolsets):
|
|
from hermes_cli.process_identity import register_self
|
|
from hermes_cli.shared_profile_warning import shared_profile_warning
|
|
|
|
register_self("cli")
|
|
warning = shared_profile_warning()
|
|
if warning:
|
|
print(f"Warning: {warning}", file=sys.stderr)
|
|
|
|
_join_worktree = _start_worktree_setup(list_tools, list_toolsets, worktree, w)
|
|
query = query or q
|
|
# ``hermes chat`` already validated this; the direct Fire entry point gets the same contract.
|
|
if output_format == "stream-json":
|
|
if not query:
|
|
raise ValueError("--format stream-json requires -q/--query")
|
|
quiet = True
|
|
cli = _build_cli_from_args(model, toolsets, provider, reasoning, api_key, base_url, max_turns, run_budget,
|
|
verbose, compact, resume, checkpoints, pass_session_id, ignore_rules, skills)
|
|
|
|
# Join the background worktree creation before anything consumes TERMINAL_CWD.
|
|
# A requested worktree whose setup failed aborts: never silently run without isolation.
|
|
wt_info = _join_worktree() if _join_worktree is not None else None
|
|
if _join_worktree is not None and not wt_info:
|
|
return
|
|
|
|
# Inject worktree context into agent's system prompt
|
|
if wt_info:
|
|
wt_note = (
|
|
f"\n\n[System note: You are working in an isolated git worktree at "
|
|
f"{wt_info['path']}. Your branch is `{wt_info['branch']}`. "
|
|
f"Changes here do not affect the main working tree or other agents. "
|
|
f"Remember to commit and push your changes, and create a PR if appropriate. "
|
|
f"The original repo is at {wt_info['repo_root']}.]"
|
|
)
|
|
cli.system_prompt = (cli.system_prompt or "") + wt_note
|
|
|
|
if list_tools or list_toolsets:
|
|
cli.show_banner()
|
|
(cli.show_tools if list_tools else cli.show_toolsets)()
|
|
sys.exit(0)
|
|
|
|
atexit.register(_run_cleanup) # interactive mode registers again in run() (idempotent)
|
|
_install_single_query_signal_handlers(cli)
|
|
|
|
if query or image:
|
|
_run_single_query_mode(cli, query, image, quiet, oneshot, stream_json=output_format == "stream-json")
|
|
return
|
|
cli.run()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
import fire
|
|
|
|
fire.Fire(main)
|
|
|
|
|
|
# ---- BEGIN PLUGIN-COMPAT (revert-scheduled; see COMPAT_MANIFEST.md) ----
|
|
# Names external plugins imported from this module before the Sep 2026 decomposition.
|
|
# Internal code MUST NOT use these (scripts/check_compat_pointers.py fails CI if it does).
|
|
# The whole block is removed by reverting the commit that added it.
|
|
from prompt_toolkit.layout.menus import CompletionsMenu # noqa: F401,E402
|
|
from prompt_toolkit.filters import Condition # noqa: F401,E402
|
|
from prompt_toolkit.layout import ConditionalContainer # noqa: F401,E402
|
|
from prompt_toolkit.layout.processors import ConditionalProcessor # noqa: F401,E402
|
|
from prompt_toolkit.layout.dimension import Dimension # noqa: F401,E402
|
|
from prompt_toolkit.history import FileHistory # noqa: F401,E402
|
|
from prompt_toolkit.layout import FormattedTextControl # noqa: F401,E402
|
|
from prompt_toolkit.layout import HSplit # noqa: F401,E402
|
|
from prompt_toolkit.key_binding import KeyBindings # noqa: F401,E402
|
|
from prompt_toolkit.layout import Layout # noqa: F401,E402
|
|
from prompt_toolkit.styles import Style as PTStyle # noqa: F401,E402
|
|
from rich.panel import Panel # noqa: F401,E402
|
|
from prompt_toolkit.layout.processors import PasswordProcessor # noqa: F401,E402
|
|
from prompt_toolkit.layout.processors import Processor # noqa: F401,E402
|
|
from prompt_toolkit.widgets import TextArea # noqa: F401,E402
|
|
from prompt_toolkit.layout.processors import Transformation # noqa: F401,E402
|
|
from prompt_toolkit.layout import Window # noqa: F401,E402
|
|
from prompt_toolkit.layout import WindowAlign # noqa: F401,E402
|
|
import base64 # noqa: F401,E402
|
|
import concurrent.futures # noqa: F401,E402
|
|
import copy # noqa: F401,E402
|
|
from rich import box as rich_box # noqa: F401,E402
|
|
import tempfile # noqa: F401,E402
|
|
|
|
def AIAgent(*args, **kwargs):
|
|
from run_agent import AIAgent as _AIAgent
|
|
|
|
return _AIAgent(*args, **kwargs)
|
|
|
|
def CanonicalUsage(*args, **kwargs):
|
|
from agent.usage_pricing import CanonicalUsage as _CanonicalUsage
|
|
|
|
return _CanonicalUsage(*args, **kwargs)
|
|
|
|
|
|
_PLUGIN_COMPAT_LAZY = {
|
|
'DEFAULT_BROWSER_CDP_URL': ('hermes_cli.browser_connect', 'DEFAULT_BROWSER_CDP_URL'),
|
|
'HERMES_AGENT_LOGO': ('hermes_cli.banner', 'HERMES_AGENT_LOGO'),
|
|
'HERMES_CADUCEUS': ('hermes_cli.banner', 'HERMES_CADUCEUS'),
|
|
'SlashCommandAutoSuggest': ('hermes_cli.commands_completion', 'SlashCommandAutoSuggest'),
|
|
'SlashCommandCompleter': ('hermes_cli.commands_completion', 'SlashCommandCompleter'),
|
|
'build_welcome_banner': ('hermes_cli.banner', 'build_welcome_banner'),
|
|
'display_hermes_home': ('hermes_constants', 'display_hermes_home'),
|
|
'estimate_usage_cost': ('agent.usage_pricing', 'estimate_usage_cost'),
|
|
'get_all_toolsets': ('toolsets', 'get_all_toolsets'),
|
|
'get_job': ('cron.jobs', 'get_job'),
|
|
'get_toolset_for_tool': ('model_tools', 'get_toolset_for_tool'),
|
|
'get_toolset_info': ('toolsets', 'get_toolset_info'),
|
|
'init_skin_from_config': ('hermes_cli.skin_engine', 'init_skin_from_config'),
|
|
'is_browser_debug_ready': ('hermes_cli.browser_connect', 'is_browser_debug_ready'),
|
|
'is_table_divider': ('agent.markdown_tables', 'is_table_divider'),
|
|
'looks_like_table_row': ('agent.markdown_tables', 'looks_like_table_row'),
|
|
'manual_chrome_debug_command': ('hermes_cli.browser_connect', 'manual_chrome_debug_command'),
|
|
'print_config_warnings': ('hermes_cli.config', 'print_config_warnings'),
|
|
'prompt_for_secret': ('hermes_cli.callbacks', 'prompt_for_secret'),
|
|
'set_friendly_tool_labels': ('agent.display', 'set_friendly_tool_labels'),
|
|
'set_tool_preview_max_len': ('agent.display', 'set_tool_preview_max_len'),
|
|
'setup_logging': ('hermes_logging', 'setup_logging'),
|
|
}
|
|
|
|
|
|
def __getattr__(name): # PEP 562 — lazy so no import cycles
|
|
target = _PLUGIN_COMPAT_LAZY.get(name)
|
|
if target is None:
|
|
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
|
import importlib
|
|
from hermes_cli.plugin_compat import warn_once
|
|
warn_once(__name__, name, *target)
|
|
return getattr(importlib.import_module(target[0]), target[1])
|
|
# ---- END PLUGIN-COMPAT ----
|