diff --git a/cli.py b/cli.py
index 560b5d9a2e37..8774f25f5c27 100644
--- a/cli.py
+++ b/cli.py
@@ -37,8 +37,6 @@
import time
import uuid
import textwrap
-from collections import deque
-from urllib.parse import unquote, urlparse
from contextlib import contextmanager
from pathlib import Path
from datetime import datetime
@@ -67,8 +65,6 @@
from prompt_toolkit.layout.menus import CompletionsMenu
from prompt_toolkit.widgets import TextArea
from prompt_toolkit.key_binding import KeyBindings
-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 # Non-blinking block cursor
@@ -160,7 +156,7 @@ def realign_markdown_tables(*args, **kwargs):
# NOTE: `from agent.account_usage import ...` is deliberately NOT at module
# top — it transitively pulls the OpenAI SDK chain (~230 ms cold) and is only
# needed when the user runs `/limits`. Lazy-imported inside the handler below.
-from hermes_cli.banner import _format_context_length, format_banner_version_label
+from hermes_cli.banner import _format_context_length
_COMMAND_SPINNER_FRAMES = ("⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏")
@@ -168,6 +164,7 @@ def realign_markdown_tables(*args, **kwargs):
# Load .env from ~/.hermes/.env first, then project root as dev fallback.
# User-managed env files should override stale shell exports on restart.
from hermes_constants import get_hermes_home, display_hermes_home
+from hermes_constants import is_termux as _is_termux_environment
from hermes_cli.browser_connect import (
DEFAULT_BROWSER_CDP_URL,
is_browser_debug_ready,
@@ -175,557 +172,30 @@ def realign_markdown_tables(*args, **kwargs):
try_launch_chrome_debug,
)
from hermes_cli.env_loader import load_hermes_dotenv
-from utils import base_url_host_matches, fast_safe_load
+from utils import base_url_host_matches
_hermes_home = get_hermes_home()
_project_env = Path(__file__).parent / '.env'
load_hermes_dotenv(hermes_home=_hermes_home, project_env=_project_env)
-_REASONING_TAGS = (
- "REASONING_SCRATCHPAD",
- "think",
- "thinking",
- "reasoning",
- "thought",
+# Configuration loading and content-processing helpers — implementation
+# extracted to cli_config.py; re-imported here so existing ``from cli
+# import X`` call sites keep working.
+from cli_config import (
+ _REASONING_TAGS,
+ _assistant_content_as_text,
+ _assistant_copy_text,
+ _load_prefill_messages,
+ _parse_reasoning_config,
+ _parse_service_tier_config,
+ _resolve_prefill_messages_file,
+ _strip_reasoning_tags,
+ load_cli_config,
+ save_config_value,
)
-def _strip_reasoning_tags(text: str) -> str:
- """Remove reasoning/thinking blocks from displayed text.
-
- Handles every case:
- * Closed pairs ``…`` (case-insensitive, multi-line).
- * Unterminated open tags that run to end-of-text (e.g. truncated
- generations on NIM/MiniMax where the close tag is dropped).
- * Stray orphan close tags (``stuffanswer``) left behind by
- partial-content dumps.
-
- Covers the variants emitted by reasoning models today: ````,
- ````, ````, ````, and
- ```` (Gemma 4). Must stay in sync with
- ``run_agent.py::_strip_think_blocks`` and the stream consumer's
- ``_OPEN_THINK_TAGS`` / ``_CLOSE_THINK_TAGS`` tuples.
-
- Also strips tool-call XML blocks some open models leak into visible
- content (````, ````, Gemma-style
- ``…``). Ported from
- openclaw/openclaw#67318.
- """
- cleaned = text
- for tag in _REASONING_TAGS:
- # Closed pair — case-insensitive so … is handled too.
- cleaned = re.sub(
- rf"<{tag}>.*?{tag}>\s*",
- "",
- cleaned,
- flags=re.DOTALL | re.IGNORECASE,
- )
- # Unterminated open tag — strip from the tag to end of text.
- cleaned = re.sub(
- rf"<{tag}>.*$",
- "",
- cleaned,
- flags=re.DOTALL | re.IGNORECASE,
- )
- # Stray orphan close tag left behind by partial dumps.
- cleaned = re.sub(
- rf"{tag}>\s*",
- "",
- cleaned,
- flags=re.IGNORECASE,
- )
- # Tool-call XML blocks (openclaw/openclaw#67318).
- for tc_tag in ("tool_call", "tool_calls", "tool_result",
- "function_call", "function_calls"):
- cleaned = re.sub(
- rf"<{tc_tag}\b[^>]*>.*?{tc_tag}>\s*",
- "",
- cleaned,
- flags=re.DOTALL | re.IGNORECASE,
- )
- # — boundary + attribute gated to avoid prose FPs.
- cleaned = re.sub(
- r'(?:(?<=^)|(?<=[\n\r.!?:]))[ \t]*'
- r']*\bname\s*=[^>]*>'
- r'(?:(?:(?!).)*)\s*',
- '',
- cleaned,
- flags=re.DOTALL | re.IGNORECASE,
- )
- # Stray tool-call close tags.
- cleaned = re.sub(
- r'(?:tool_call|tool_calls|tool_result|function_call|function_calls|function)>\s*',
- '',
- cleaned,
- flags=re.IGNORECASE,
- )
- return cleaned.strip()
-
-
-def _assistant_content_as_text(content: Any) -> str:
- if content is None:
- return ""
- if isinstance(content, str):
- return content
- if isinstance(content, list):
- parts = [
- str(part.get("text", ""))
- for part in content
- if isinstance(part, dict) and part.get("type") == "text"
- ]
- return "\n".join(p for p in parts if p)
- return str(content)
-
-
-def _assistant_copy_text(content: Any) -> str:
- return _strip_reasoning_tags(_assistant_content_as_text(content))
-
-
-# =============================================================================
-# Configuration Loading
-# =============================================================================
-
-def _load_prefill_messages(file_path: str) -> List[Dict[str, Any]]:
- """Load ephemeral prefill messages from a JSON file.
-
- The file should contain a JSON array of {role, content} dicts, e.g.:
- [{"role": "user", "content": "Hi"}, {"role": "assistant", "content": "Hello!"}]
-
- Relative paths are resolved from ~/.hermes/.
- Returns an empty list if the path is empty or the file doesn't exist.
- """
- if not file_path:
- return []
- path = Path(file_path).expanduser()
- if not path.is_absolute():
- path = _hermes_home / path
- if not path.exists():
- logger.warning("Prefill messages file not found: %s", path)
- return []
- try:
- with open(path, "r", encoding="utf-8") as f:
- data = json.load(f)
- if not isinstance(data, list):
- logger.warning("Prefill messages file must contain a JSON array: %s", path)
- return []
- return data
- except Exception as e:
- logger.warning("Failed to load prefill messages from %s: %s", path, e)
- return []
-
-
-def _resolve_prefill_messages_file(config: Dict[str, Any]) -> str:
- """Resolve the prefill file path from env/config.
-
- ``prefill_messages_file`` at the top level is the canonical config key.
- ``agent.prefill_messages_file`` remains a legacy fallback for older CLI and
- godmode-generated configs.
- """
- env_path = os.getenv("HERMES_PREFILL_MESSAGES_FILE", "").strip()
- if env_path:
- return env_path
- top_level = str(config.get("prefill_messages_file", "") or "").strip()
- if top_level:
- return top_level
- agent_cfg = config.get("agent", {})
- if isinstance(agent_cfg, dict):
- return str(agent_cfg.get("prefill_messages_file", "") or "").strip()
- return ""
-
-
-def _parse_reasoning_config(effort) -> dict | None:
- """Parse a reasoning effort level into an OpenRouter reasoning config dict.
-
- Accepts the raw config value (string or YAML boolean — ``false``/``off``
- parse as thinking disabled, see parse_reasoning_effort).
- """
- from hermes_constants import parse_reasoning_effort
- result = parse_reasoning_effort(effort)
- if effort and str(effort).strip() and result is None:
- logger.warning("Unknown reasoning_effort '%s', using default (medium)", effort)
- return result
-
-
-def _parse_service_tier_config(raw: str) -> str | None:
- """Parse a persisted service-tier preference into a Responses API value."""
- value = str(raw or "").strip().lower()
- if not value or value in {"normal", "default", "standard", "off", "none"}:
- return None
- if value in {"fast", "priority", "on"}:
- return "priority"
- logger.warning("Unknown service_tier '%s', ignoring", raw)
- return None
-
-def load_cli_config() -> Dict[str, Any]:
- """
- Load CLI configuration from config files.
-
- Config lookup order:
- 1. ~/.hermes/config.yaml (user config - preferred)
- 2. ./cli-config.yaml (project config - fallback)
-
- Environment variables take precedence over config file values.
- Returns default values if no config file exists.
-
- If HERMES_IGNORE_USER_CONFIG=1 is set (via ``hermes chat --ignore-user-config``),
- the user config at ``~/.hermes/config.yaml`` is skipped entirely and only the
- built-in defaults plus the project-level ``cli-config.yaml`` (if any) are used.
- Credentials in ``.env`` are still loaded — this flag only suppresses
- behavioral/config settings.
- """
- # Check user config first ({HERMES_HOME}/config.yaml)
- user_config_path = _hermes_home / 'config.yaml'
- project_config_path = Path(__file__).parent / 'cli-config.yaml'
-
- # --ignore-user-config: force-skip the user config.yaml (still honor project
- # config as a fallback so defaults stay sensible).
- ignore_user_config = os.environ.get("HERMES_IGNORE_USER_CONFIG") == "1"
-
- # Use user config if it exists, otherwise project config
- if user_config_path.exists() and not ignore_user_config:
- config_path = user_config_path
- else:
- config_path = project_config_path
-
- # Default configuration
- defaults = {
- "model": {
- "default": "",
- "base_url": "",
- "provider": "auto",
- },
- "terminal": {
- "env_type": "local",
- "cwd": ".", # "." is resolved to os.getcwd() at runtime
- "home_mode": "auto",
- "lifetime_seconds": 300,
- "docker_image": "nikolaik/python-nodejs:python3.11-nodejs20",
- "docker_forward_env": [],
- "singularity_image": "docker://nikolaik/python-nodejs:python3.11-nodejs20",
- "modal_image": "nikolaik/python-nodejs:python3.11-nodejs20",
- "daytona_image": "nikolaik/python-nodejs:python3.11-nodejs20",
- "docker_volumes": [], # host:container volume mounts for Docker backend
- "docker_mount_cwd_to_workspace": False, # explicit opt-in only; default off for sandbox isolation
- },
- "browser": {
- "inactivity_timeout": 120, # Auto-cleanup inactive browser sessions after 2 min
- "record_sessions": False, # Auto-record browser sessions as WebM videos
- "engine": "auto", # Browser engine: auto (Chrome), lightpanda, chrome
- "camofox": {
- "rewrite_loopback_urls": False,
- "loopback_host_alias": "host.docker.internal",
- },
- },
- "compression": {
- "enabled": True, # Auto-compress when approaching context limit
- "threshold": 0.50, # Compress at 50% of model's context limit
- },
- "agent": {
- "max_turns": 90, # Default max tool-calling iterations (shared with subagents)
- "verbose": False,
- "system_prompt": "",
- "prefill_messages_file": "",
- "reasoning_effort": "",
- "service_tier": "",
- "personalities": {
- "helpful": "You are a helpful, friendly AI assistant.",
- "concise": "You are a concise assistant. Keep responses brief and to the point.",
- "technical": "You are a technical expert. Provide detailed, accurate technical information.",
- "creative": "You are a creative assistant. Think outside the box and offer innovative solutions.",
- "teacher": "You are a patient teacher. Explain concepts clearly with examples.",
- "kawaii": "You are a kawaii assistant! Use cute expressions like (◕‿◕), ★, ♪, and ~! Add sparkles and be super enthusiastic about everything! Every response should feel warm and adorable desu~! ヽ(>∀<☆)ノ",
- "catgirl": "You are Neko-chan, an anime catgirl AI assistant, nya~! Add 'nya' and cat-like expressions to your speech. Use kaomoji like (=^・ω・^=) and ฅ^•ﻌ•^ฅ. Be playful and curious like a cat, nya~!",
- "pirate": "Arrr! Ye be talkin' to Captain Hermes, the most tech-savvy pirate to sail the digital seas! Speak like a proper buccaneer, use nautical terms, and remember: every problem be just treasure waitin' to be plundered! Yo ho ho!",
- "shakespeare": "Hark! Thou speakest with an assistant most versed in the bardic arts. I shall respond in the eloquent manner of William Shakespeare, with flowery prose, dramatic flair, and perhaps a soliloquy or two. What light through yonder terminal breaks?",
- "surfer": "Duuude! You're chatting with the chillest AI on the web, bro! Everything's gonna be totally rad. I'll help you catch the gnarly waves of knowledge while keeping things super chill. Cowabunga!",
- "noir": "The rain hammered against the terminal like regrets on a guilty conscience. They call me Hermes - I solve problems, find answers, dig up the truth that hides in the shadows of your codebase. In this city of silicon and secrets, everyone's got something to hide. What's your story, pal?",
- "uwu": "hewwo! i'm your fwiendwy assistant uwu~ i wiww twy my best to hewp you! *nuzzles your code* OwO what's this? wet me take a wook! i pwomise to be vewy hewpful >w<",
- "philosopher": "Greetings, seeker of wisdom. I am an assistant who contemplates the deeper meaning behind every query. Let us examine not just the 'how' but the 'why' of your questions. Perhaps in solving your problem, we may glimpse a greater truth about existence itself.",
- "hype": "YOOO LET'S GOOOO!!! I am SO PUMPED to help you today! Every question is AMAZING and we're gonna CRUSH IT together! This is gonna be LEGENDARY! ARE YOU READY?! LET'S DO THIS!",
- },
- },
-
- "display": {
- "compact": False,
- "resume_display": "full",
- # Recap tuning for /resume — see hermes_cli/config.py DEFAULT_CONFIG.
- "resume_exchanges": 10,
- "resume_max_user_chars": 300,
- "resume_max_assistant_chars": 200,
- "resume_max_assistant_lines": 3,
- "resume_skip_tool_only": True,
- # Live reasoning display default ON — keep in sync with
- # hermes_cli/config.py DEFAULT_CONFIG (display.show_reasoning).
- "show_reasoning": True,
- "reasoning_full": False,
- "streaming": True,
- "busy_input_mode": "interrupt",
- "persistent_output": True,
- "persistent_output_max_lines": 200,
- # Print a one-line summary of resolved modal prompts (approval /
- # clarify) into scrollback so the decision survives the repaint.
- "persist_prompts": True,
-
- "skin": "default",
- },
- "clarify": {
- "timeout": 120, # Seconds to wait for a clarify answer before auto-proceeding
- },
- "code_execution": {
- "timeout": 300, # Max seconds a sandbox script can run before being killed (5 min)
- "max_tool_calls": 50, # Max RPC tool calls per execution
- },
- "auxiliary": {
- "vision": {
- "provider": "auto",
- "model": "",
- "base_url": "",
- "api_key": "",
- },
- "web_extract": {
- "provider": "auto",
- "model": "",
- "base_url": "",
- "api_key": "",
- },
- },
- "delegation": {
- "max_iterations": 45, # Max tool-calling turns per child agent
- "model": "", # Subagent model override (empty = inherit parent model)
- "provider": "", # Subagent provider override (empty = inherit parent provider)
- "base_url": "", # Direct OpenAI-compatible endpoint for subagents
- "api_key": "", # API key for delegation.base_url (falls back to OPENAI_API_KEY)
- },
- "onboarding": {
- # First-touch hint flags (see agent/onboarding.py). Each hint is
- # shown once per install then latched here.
- "seen": {},
- },
- }
-
- # Track whether the config file explicitly set terminal config.
- # When using defaults (no config file / no terminal section), we should NOT
- # overwrite env vars that were already set by .env -- only a user's config
- # file should be authoritative.
- _file_has_terminal_config = False
-
- # Load from file if exists
- if config_path.exists():
- try:
- with open(config_path, "r", encoding="utf-8") as f:
- from hermes_cli.config import _normalize_root_model_keys
-
- file_config = _normalize_root_model_keys(fast_safe_load(f) or {})
-
- _file_has_terminal_config = "terminal" in file_config
-
- # Handle model config - can be string (new format) or dict (old format)
- if "model" in file_config:
- if isinstance(file_config["model"], str):
- # New format: model is just a string, convert to dict structure
- defaults["model"]["default"] = file_config["model"]
- elif isinstance(file_config["model"], dict):
- # Old format: model is a dict with default/base_url
- defaults["model"].update(file_config["model"])
- # If the user config sets model.model but not model.default,
- # promote model.model to model.default so the user's explicit
- # choice isn't shadowed by the hardcoded default. Without this,
- # profile configs that only set "model:" (not "default:") silently
- # fall back to claude-opus because the merge preserves the
- # hardcoded default and HermesCLI.__init__ checks "default" first.
- if "model" in file_config["model"] and "default" not in file_config["model"]:
- defaults["model"]["default"] = file_config["model"]["model"]
-
- # Deep merge file_config into defaults.
- # First: merge keys that exist in both (deep-merge dicts, overwrite scalars)
- for key in defaults:
- if key == "model":
- continue # Already handled above
- if key in file_config:
- if isinstance(defaults[key], dict) and file_config[key] is None:
- continue
- if isinstance(defaults[key], dict) and isinstance(file_config[key], dict):
- defaults[key].update(file_config[key])
- else:
- defaults[key] = file_config[key]
-
- # Second: carry over keys from file_config that aren't in defaults
- # (e.g. platform_toolsets, provider_routing, memory, honcho, etc.)
- for key in file_config:
- if key not in defaults and key != "model":
- defaults[key] = file_config[key]
-
- # Handle legacy root-level max_turns (backwards compat) - copy to
- # agent.max_turns whenever the nested key is missing.
- agent_file_config = file_config.get("agent")
- if "max_turns" in file_config and not (
- isinstance(agent_file_config, dict)
- and agent_file_config.get("max_turns") is not None
- ):
- defaults["agent"]["max_turns"] = file_config["max_turns"]
- except Exception as e:
- logger.warning("Failed to load cli-config.yaml: %s", e)
-
- # Expand ${ENV_VAR} references in config values before bridging to env vars.
- from hermes_cli.config import _expand_env_vars
- defaults = _expand_env_vars(defaults)
-
- # Managed scope: overlay administrator-pinned values LAST so they win over
- # the user's config here too. cli.py builds its config independently of
- # hermes_cli.config._load_config_impl (which has its own managed merge), so
- # without this the entire interactive CLI/TUI surface — skin, display prefs,
- # etc. read from CLI_CONFIG — would silently ignore managed scope while
- # `hermes config`/`doctor`/guards (which use load_config) honor it. The
- # shared helper mirrors _load_config_impl (env-only expansion, root-model
- # normalization, leaf-merge) and is fail-open.
- from hermes_cli import managed_scope
-
- defaults = managed_scope.apply_managed_overlay(defaults)
-
- # Apply terminal config to environment variables (so terminal_tool picks them up)
- terminal_config = defaults.get("terminal", {})
-
- # Normalize config key: the new config system (hermes_cli/config.py) and all
- # documentation use "backend", the legacy cli-config.yaml uses "env_type".
- # Accept both, with "backend" taking precedence (it's the documented key).
- if "backend" in terminal_config:
- terminal_config["env_type"] = terminal_config["backend"]
-
- # CWD resolution for CLI/TUI. The gateway has its own config bridge in
- # gateway/run.py but may lazily import cli.py (triggering this code).
- # Local backend: always os.getcwd(). Use `cd /dir && hermes` to control it.
- # Non-local with placeholder: pop so terminal_tool uses its per-backend default.
- # Non-local with explicit path: keep as-is.
- _CWD_PLACEHOLDERS = (".", "auto", "cwd")
- effective_backend = terminal_config.get("env_type", "local")
-
- if effective_backend == "local":
- terminal_config["cwd"] = os.getcwd()
- defaults["terminal"]["cwd"] = terminal_config["cwd"]
- elif terminal_config.get("cwd") in _CWD_PLACEHOLDERS:
- terminal_config.pop("cwd", None)
-
- env_mappings = {
- "env_type": "TERMINAL_ENV",
- "cwd": "TERMINAL_CWD",
- "timeout": "TERMINAL_TIMEOUT",
- "home_mode": "TERMINAL_HOME_MODE",
- "lifetime_seconds": "TERMINAL_LIFETIME_SECONDS",
- "docker_image": "TERMINAL_DOCKER_IMAGE",
- "docker_forward_env": "TERMINAL_DOCKER_FORWARD_ENV",
- "singularity_image": "TERMINAL_SINGULARITY_IMAGE",
- "modal_image": "TERMINAL_MODAL_IMAGE",
- "daytona_image": "TERMINAL_DAYTONA_IMAGE",
- # SSH config
- "ssh_host": "TERMINAL_SSH_HOST",
- "ssh_user": "TERMINAL_SSH_USER",
- "ssh_port": "TERMINAL_SSH_PORT",
- "ssh_key": "TERMINAL_SSH_KEY",
- # Container resource config (docker, singularity, modal, daytona -- ignored for local/ssh)
- "container_cpu": "TERMINAL_CONTAINER_CPU",
- "container_memory": "TERMINAL_CONTAINER_MEMORY",
- "container_disk": "TERMINAL_CONTAINER_DISK",
- "container_persistent": "TERMINAL_CONTAINER_PERSISTENT",
- "docker_volumes": "TERMINAL_DOCKER_VOLUMES",
- "docker_env": "TERMINAL_DOCKER_ENV",
- "docker_extra_args": "TERMINAL_DOCKER_EXTRA_ARGS",
- "docker_mount_cwd_to_workspace": "TERMINAL_DOCKER_MOUNT_CWD_TO_WORKSPACE",
- "docker_network": "TERMINAL_DOCKER_NETWORK",
- "docker_run_as_host_user": "TERMINAL_DOCKER_RUN_AS_HOST_USER",
- "docker_persist_across_processes": "TERMINAL_DOCKER_PERSIST_ACROSS_PROCESSES",
- "docker_orphan_reaper": "TERMINAL_DOCKER_ORPHAN_REAPER",
- "sandbox_dir": "TERMINAL_SANDBOX_DIR",
- # Persistent shell (non-local backends)
- "persistent_shell": "TERMINAL_PERSISTENT_SHELL",
- # Sudo support (works with all backends)
- "sudo_password": "SUDO_PASSWORD",
- }
-
- # Bridge config → env vars for terminal_tool. TERMINAL_CWD is force-exported
- # UNLESS we're inside a gateway process (detected by _HERMES_GATEWAY marker)
- # where it was already set correctly by gateway/run.py's config bridge.
- _is_gateway = os.environ.get("_HERMES_GATEWAY") == "1"
- for config_key, env_var in env_mappings.items():
- if config_key in terminal_config:
- if env_var == "TERMINAL_CWD":
- if _is_gateway:
- continue
- # CLI: always export (overrides stale .env or inherited values)
- os.environ[env_var] = str(terminal_config[config_key])
- continue
- if _file_has_terminal_config or env_var not in os.environ:
- val = terminal_config[config_key]
- if isinstance(val, (list, dict)):
- os.environ[env_var] = json.dumps(val)
- else:
- os.environ[env_var] = str(val)
-
- # Apply browser config to environment variables
- browser_config = defaults.get("browser", {})
- browser_env_mappings = {
- "inactivity_timeout": "BROWSER_INACTIVITY_TIMEOUT",
- }
-
- for config_key, env_var in browser_env_mappings.items():
- if config_key in browser_config:
- os.environ[env_var] = str(browser_config[config_key])
-
- # Apply auxiliary model/direct-endpoint overrides to environment variables.
- # Vision and web_extract each have their own provider/model/base_url/api_key tuple.
- # Compression config is read directly from config.yaml by run_agent.py and
- # auxiliary_client.py — no env var bridging needed.
- # Only set env vars for non-empty / non-default values so auto-detection
- # still works.
- auxiliary_config = defaults.get("auxiliary", {})
- auxiliary_task_env = {
- # config key → env var mapping
- "vision": {
- "provider": "AUXILIARY_VISION_PROVIDER",
- "model": "AUXILIARY_VISION_MODEL",
- "base_url": "AUXILIARY_VISION_BASE_URL",
- "api_key": "AUXILIARY_VISION_API_KEY",
- },
- "web_extract": {
- "provider": "AUXILIARY_WEB_EXTRACT_PROVIDER",
- "model": "AUXILIARY_WEB_EXTRACT_MODEL",
- "base_url": "AUXILIARY_WEB_EXTRACT_BASE_URL",
- "api_key": "AUXILIARY_WEB_EXTRACT_API_KEY",
- },
- "approval": {
- "provider": "AUXILIARY_APPROVAL_PROVIDER",
- "model": "AUXILIARY_APPROVAL_MODEL",
- "base_url": "AUXILIARY_APPROVAL_BASE_URL",
- "api_key": "AUXILIARY_APPROVAL_API_KEY",
- },
- }
-
- for task_key, env_map in auxiliary_task_env.items():
- task_cfg = auxiliary_config.get(task_key, {})
- if not isinstance(task_cfg, dict):
- continue
- prov = str(task_cfg.get("provider", "")).strip()
- model = str(task_cfg.get("model", "")).strip()
- base_url = str(task_cfg.get("base_url", "")).strip()
- api_key = str(task_cfg.get("api_key", "")).strip()
- if prov and prov != "auto":
- os.environ[env_map["provider"]] = prov
- if model:
- os.environ[env_map["model"]] = model
- if base_url:
- os.environ[env_map["base_url"]] = base_url
- if api_key:
- os.environ[env_map["api_key"]] = api_key
-
- # Security settings
- security_config = defaults.get("security", {})
- if isinstance(security_config, dict):
- redact = security_config.get("redact_secrets")
- if redact is not None:
- os.environ["HERMES_REDACT_SECRETS"] = str(redact).lower()
-
- return defaults
-
# Load configuration at module startup
CLI_CONFIG = load_cli_config()
@@ -1283,488 +753,24 @@ def _reset_terminal_input_modes_on_exit() -> None:
# =============================================================================
# Git Worktree Isolation (#652)
# =============================================================================
-
-# Tracks the active worktree for cleanup on exit
-_active_worktree: Optional[Dict[str, str]] = None
-
-
-def _normalize_git_bash_path(p: Optional[str]) -> Optional[str]:
- """Translate a Git Bash-style path (``/c/Users/...``) to the native
- Windows form (``C:\\Users\\...``) that Python's ``subprocess.Popen``
- and ``pathlib.Path`` accept.
-
- No-op on non-Windows and for paths that already look native. Git on
- native Windows normally emits forward-slash Windows paths
- (``C:/Users/...``) which both bash and Python handle, but certain
- configurations (Git Bash shells, MSYS2, WSL-mounted repos) surface
- ``/c/...`` or ``/cygdrive/c/...`` variants.
- """
- if not p:
- return p
- if sys.platform != "win32":
- return p
- import re as _re
- # /c/Users/... or /C/Users/...
- m = _re.match(r"^/([a-zA-Z])/(.*)$", p)
- if m:
- drive, rest = m.group(1), m.group(2)
- return f"{drive.upper()}:\\{rest.replace('/', chr(92))}"
- # /cygdrive/c/... or /mnt/c/...
- m = _re.match(r"^/(?:cygdrive|mnt)/([a-zA-Z])/(.*)$", p)
- if m:
- drive, rest = m.group(1), m.group(2)
- return f"{drive.upper()}:\\{rest.replace('/', chr(92))}"
- return p
-
-
-def _git_repo_root() -> Optional[str]:
- """Return the git repo root for CWD, or None if not in a repo.
-
- Runs through :func:`_normalize_git_bash_path` so callers can pass
- the result directly to ``Path``/``subprocess.Popen(cwd=...)`` on
- Windows without hitting ``C:\\c\\Users\\...`` style resolution
- mistakes.
- """
- import subprocess
- try:
- result = subprocess.run(
- ["git", "rev-parse", "--show-toplevel"],
- capture_output=True, text=True, timeout=5,
- )
- if result.returncode == 0:
- return _normalize_git_bash_path(result.stdout.strip())
- except Exception:
- pass
- return None
-
-
-def _path_is_within_root(path: Path, root: Path) -> bool:
- """Return True when a resolved path stays within the expected root."""
- try:
- path.relative_to(root)
- return True
- except ValueError:
- return False
-
-
-def _resolve_worktree_base(repo_root: str) -> tuple:
- """Resolve the freshest base ref to branch a new worktree from.
-
- The standalone clone's ``HEAD`` can lag the remote by hundreds of commits
- (the ``~/.hermes/hermes-agent`` clone is updated only by ``hermes update``,
- not on every session). Branching a worktree from that stale ``HEAD`` roots
- every new branch on an old base — so the PR diff GitHub computes against
- current ``main`` balloons with unrelated changes, and the agent has to
- discover the staleness via the pre-push gate and rebase. Branching from the
- freshly-fetched remote tip instead means the worktree starts current.
-
- Strategy (each step falls back to the next on failure):
- 1. If the current branch tracks an upstream, fetch and use that upstream
- ref — so a deliberate feature-branch worktree tracks its own remote,
- not the default branch.
- 2. Else fetch the remote's default branch (``origin/HEAD`` → e.g.
- ``origin/main``) and use it.
- 3. Else fall back to ``HEAD`` (offline, no remote, or detached) — the
- old behavior, never worse than before.
-
- Returns ``(base_ref, label)`` where *base_ref* is a git revision suitable
- for ``git worktree add ... `` and *label* is a short
- human-readable description for the session banner.
- """
- import subprocess
-
- def _git(args, timeout=20):
- return subprocess.run(
- ["git", *args],
- capture_output=True, text=True, timeout=timeout, cwd=repo_root,
- )
-
- # 1. Current branch's upstream, if it tracks one.
- try:
- up = _git(["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{upstream}"])
- if up.returncode == 0:
- upstream = up.stdout.strip() # e.g. "origin/main"
- if upstream and "/" in upstream:
- remote = upstream.split("/", 1)[0]
- # Fetch just that branch; fail-soft if offline.
- _git(["fetch", remote, upstream.split("/", 1)[1]], timeout=30)
- return upstream, f"{upstream} (fetched)"
- except Exception as e:
- logger.debug("worktree base: upstream resolution failed: %s", e)
-
- # 2. Remote default branch (origin/HEAD).
- try:
- # Resolve the remote's default branch symref.
- head_ref = _git(["symbolic-ref", "--quiet", "refs/remotes/origin/HEAD"])
- default_ref = ""
- if head_ref.returncode == 0:
- default_ref = head_ref.stdout.strip().replace("refs/remotes/", "", 1)
- if not default_ref:
- # origin/HEAD not set locally; ask the remote.
- show = _git(["remote", "show", "origin"], timeout=30)
- for line in show.stdout.splitlines():
- line = line.strip()
- if line.startswith("HEAD branch:"):
- _branch = line.split(":", 1)[1].strip()
- # A remote with no default branch reports "(unknown)";
- # don't construct a bogus "origin/(unknown)" ref from it.
- if _branch and _branch != "(unknown)":
- default_ref = "origin/" + _branch
- break
- if default_ref and "/" in default_ref:
- remote, branch = default_ref.split("/", 1)
- _git(["fetch", remote, branch], timeout=30)
- return default_ref, f"{default_ref} (fetched)"
- except Exception as e:
- logger.debug("worktree base: default-branch resolution failed: %s", e)
-
- # 3. Fall back to local HEAD (offline / no remote / detached).
- return "HEAD", "HEAD (local — could not reach remote)"
-
-
-def _setup_worktree(repo_root: str = None, sync_base: bool = True) -> Optional[Dict[str, str]]:
- """Create an isolated git worktree for this CLI session.
-
- Returns a dict with worktree metadata on success, None on failure.
- The dict contains: path, branch, repo_root.
-
- When *sync_base* is True (default), the worktree branches from the
- freshly-fetched remote tip rather than the (possibly stale) local ``HEAD``
- — see ``_resolve_worktree_base``. Set ``worktree_sync: false`` in config to
- branch from local ``HEAD`` (the pre-#10760-followup behavior).
- """
- import subprocess
-
- repo_root = repo_root or _git_repo_root()
- if not repo_root:
- print("\033[31m✗ --worktree requires being inside a git repository.\033[0m")
- print(" cd into your project repo first, then run hermes -w")
- return None
-
- short_id = uuid.uuid4().hex[:8]
- wt_name = f"hermes-{short_id}"
- branch_name = f"hermes/{wt_name}"
-
- worktrees_dir = Path(repo_root) / ".worktrees"
- worktrees_dir.mkdir(parents=True, exist_ok=True)
-
- wt_path = worktrees_dir / wt_name
-
- # Ensure .worktrees/ is in .gitignore
- gitignore = Path(repo_root) / ".gitignore"
- _ignore_entry = ".worktrees/"
- try:
- existing = gitignore.read_text() if gitignore.exists() else ""
- if _ignore_entry not in existing.splitlines():
- with open(gitignore, "a", encoding="utf-8") as f:
- if existing and not existing.endswith("\n"):
- f.write("\n")
- f.write(f"{_ignore_entry}\n")
- except Exception as e:
- logger.debug("Could not update .gitignore: %s", e)
-
- # Resolve the base ref. By default branch from the freshly-fetched remote
- # tip so the worktree starts current with the project, not from the
- # (possibly stale) local HEAD of the standalone clone (#10760 follow-up).
- if sync_base:
- base_ref, base_label = _resolve_worktree_base(repo_root)
- else:
- base_ref, base_label = "HEAD", "HEAD (local — worktree_sync disabled)"
-
- # Create the worktree
- try:
- result = subprocess.run(
- ["git", "worktree", "add", str(wt_path), "-b", branch_name, base_ref],
- capture_output=True, text=True, timeout=30, cwd=repo_root,
- )
- if result.returncode != 0:
- # If branching from the resolved remote ref failed for any reason
- # (e.g. a partial fetch left the ref unusable), retry from local
- # HEAD so worktree creation never hard-fails on a sync hiccup.
- if base_ref != "HEAD":
- logger.warning(
- "worktree add from %s failed (%s); retrying from local HEAD",
- base_ref, result.stderr.strip(),
- )
- base_ref, base_label = "HEAD", "HEAD (fallback — remote base failed)"
- result = subprocess.run(
- ["git", "worktree", "add", str(wt_path), "-b", branch_name, base_ref],
- capture_output=True, text=True, timeout=30, cwd=repo_root,
- )
- if result.returncode != 0:
- print(f"\033[31m✗ Failed to create worktree: {result.stderr.strip()}\033[0m")
- return None
- except Exception as e:
- print(f"\033[31m✗ Failed to create worktree: {e}\033[0m")
- return None
-
- # Copy files listed in .worktreeinclude (gitignored files the agent needs)
- include_file = Path(repo_root) / ".worktreeinclude"
- if include_file.exists():
- try:
- repo_root_resolved = Path(repo_root).resolve()
- wt_path_resolved = wt_path.resolve()
- for line in include_file.read_text().splitlines():
- entry = line.strip()
- if not entry or entry.startswith("#"):
- continue
- src = Path(repo_root) / entry
- dst = wt_path / entry
- # Prevent path traversal and symlink escapes: both the resolved
- # source and the resolved destination must stay inside their
- # expected roots before any file or symlink operation happens.
- try:
- src_resolved = src.resolve(strict=False)
- dst_resolved = dst.resolve(strict=False)
- except (OSError, ValueError):
- logger.debug("Skipping invalid .worktreeinclude entry: %s", entry)
- continue
- if not _path_is_within_root(src_resolved, repo_root_resolved):
- logger.warning("Skipping .worktreeinclude entry outside repo root: %s", entry)
- continue
- if not _path_is_within_root(dst_resolved, wt_path_resolved):
- logger.warning("Skipping .worktreeinclude entry that escapes worktree: %s", entry)
- continue
- if src.is_file():
- dst.parent.mkdir(parents=True, exist_ok=True)
- shutil.copy2(str(src), str(dst))
- elif src.is_dir():
- # Symlink directories (faster, saves disk). On Windows,
- # symlink creation requires Developer Mode or elevation,
- # and fails with OSError otherwise — fall back to a
- # recursive copy so the worktree is still usable. The
- # copy is slower and uses disk, but it doesn't require
- # admin and matches the Linux/macOS symlink outcome
- # functionally.
- if not dst.exists():
- dst.parent.mkdir(parents=True, exist_ok=True)
- try:
- os.symlink(str(src_resolved), str(dst))
- except (OSError, NotImplementedError) as _sym_err:
- if sys.platform == "win32":
- logger.info(
- ".worktreeinclude: symlink failed (%s) — "
- "falling back to copytree on Windows.",
- _sym_err,
- )
- try:
- shutil.copytree(
- str(src_resolved),
- str(dst),
- symlinks=True,
- dirs_exist_ok=False,
- )
- except Exception as _copy_err:
- logger.warning(
- ".worktreeinclude: copy fallback "
- "also failed for %s -> %s: %s",
- src, dst, _copy_err,
- )
- else:
- raise
- except Exception as e:
- logger.debug("Error copying .worktreeinclude entries: %s", e)
-
- # Lock the worktree so other processes (and `git worktree remove`) can see
- # it is actively in use. Fail-soft: a lock failure never blocks the session.
- try:
- subprocess.run(
- ["git", "worktree", "lock", "--reason", f"hermes pid={os.getpid()}", str(wt_path)],
- capture_output=True, text=True, timeout=10, cwd=repo_root,
- )
- logger.debug("Worktree locked: %s (pid=%s)", wt_path, os.getpid())
- except Exception as e:
- logger.debug("git worktree lock failed (non-fatal): %s", e)
-
- info = {
- "path": str(wt_path),
- "branch": branch_name,
- "repo_root": repo_root,
- "base": base_ref,
- }
-
- print(f"\033[32m✓ Worktree created:\033[0m {wt_path}")
- print(f" Branch: {branch_name}")
- print(f" Base: {base_label}")
-
- 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 _worktree_is_dirty(worktree_path: str, timeout: int = 10) -> bool:
- """Return whether a worktree has uncommitted changes (staged, unstaged, or
- untracked).
-
- Fails SAFE: on any error returns True so callers do not delete a worktree
- whose state they cannot determine.
- """
- import subprocess
-
- try:
- result = subprocess.run(
- ["git", "status", "--porcelain"],
- 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 _worktree_lock_is_live(repo_root: str, worktree_path: str, timeout: int = 10):
- """Classify a worktree's git lock as live, dead, or absent.
-
- ``hermes -w`` locks each worktree with reason ``hermes pid=`` so a
- concurrent hermes process' startup prune leaves an in-use worktree alone.
- But a *crashed* session leaves the lock behind forever, and
- ``git worktree remove --force`` (single ``-f``) refuses to remove a locked
- worktree — so dead-locked worktrees accumulate indefinitely. This lets the
- pruner tell the two apart:
-
- - ``"live"`` — locked and the owning pid is still running (skip it).
- - ``"dead"`` — locked but the owning pid is gone, or the reason isn't a
- parseable hermes lock (safe to unlock + reap).
- - ``None`` — not locked at all.
-
- Fails SAFE toward ``"live"``: if git can't be queried at all we cannot
- prove the worktree is safe to touch, so we report it as live.
- """
- import re
- import subprocess
-
- try:
- result = subprocess.run(
- ["git", "worktree", "list", "--porcelain"],
- capture_output=True, text=True, timeout=timeout, cwd=repo_root,
- )
- if result.returncode != 0:
- return "live"
- except Exception:
- return "live"
-
- target = Path(worktree_path).resolve()
- current: Optional[Path] = None
- for line in result.stdout.splitlines():
- if line.startswith("worktree "):
- try:
- current = Path(line[len("worktree "):].strip()).resolve()
- except Exception:
- current = None
- elif line == "locked" or line.startswith("locked "):
- if current != target:
- continue
- reason = line[len("locked"):].strip()
- m = re.search(r"hermes pid=(\d+)", reason)
- if not m:
- # Locked by something we don't recognize as a hermes session
- # (or lock reason unavailable). Treat as dead — a foreign lock
- # on a hermes -w worktree is almost certainly a leftover, and
- # the age/dirty/unpushed gates already ran before we got here.
- return "dead"
- pid = int(m.group(1))
- if pid == os.getpid():
- return "live"
- try:
- from gateway.status import _pid_exists
- return "live" if _pid_exists(pid) else "dead"
- except Exception:
- # Can't determine liveness — fail safe toward keeping it.
- return "live"
- return None
-
-
-def _cleanup_worktree(info: Dict[str, str] = None) -> None:
- """Remove a worktree and its branch on exit.
-
- Preserves the worktree only if it has unpushed commits (real work
- that hasn't been pushed to any remote). Uncommitted changes alone
- (untracked files, test artifacts) are not enough to keep it — agent
- work lives in commits/PRs, not the working tree.
- """
- global _active_worktree
- info = info or _active_worktree
- if not info:
- return
-
- import subprocess
-
- wt_path = info["path"]
- branch = info["branch"]
- repo_root = info["repo_root"]
-
- if not Path(wt_path).exists():
- return
-
- 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")
- print(f" To clean up manually: git worktree remove --force {wt_path}")
- _active_worktree = None
- return
-
- # Remove worktree (even if working tree is dirty — uncommitted
- # changes without unpushed commits are just artifacts)
- # Unlock first so `git worktree remove` isn't blocked by the lock we
- # placed at creation time. Fail-soft — never block cleanup.
- try:
- subprocess.run(
- ["git", "worktree", "unlock", wt_path],
- capture_output=True, text=True, timeout=10, cwd=repo_root,
- )
- except Exception as e:
- logger.debug("git worktree unlock failed (non-fatal): %s", e)
-
- try:
- subprocess.run(
- ["git", "worktree", "remove", wt_path, "--force"],
- capture_output=True, text=True, timeout=15, cwd=repo_root,
- )
- except Exception as e:
- logger.debug("Failed to remove worktree: %s", e)
-
- # Delete the branch
- try:
- subprocess.run(
- ["git", "branch", "-D", branch],
- capture_output=True, text=True, timeout=10, cwd=repo_root,
- )
- except Exception as e:
- logger.debug("Failed to delete branch %s: %s", branch, e)
-
- _active_worktree = None
- print(f"\033[32m✓ Worktree cleaned up: {wt_path}\033[0m")
+# Implementation extracted to cli_git.py; re-imported here so existing
+# ``from cli import X`` call sites keep working. The active-worktree
+# session state lives in cli_git — use get/set_active_worktree().
+from cli_git import (
+ _cleanup_worktree,
+ _git_repo_root,
+ _normalize_git_bash_path,
+ _path_is_within_root,
+ _prune_orphaned_branches,
+ _prune_stale_worktrees,
+ _resolve_worktree_base,
+ _setup_worktree,
+ _worktree_has_unpushed_commits,
+ _worktree_is_dirty,
+ _worktree_lock_is_live,
+ get_active_worktree,
+ set_active_worktree,
+)
def _run_state_db_auto_maintenance(session_db) -> None:
@@ -1791,609 +797,106 @@ def _run_state_db_auto_maintenance(session_db) -> None:
sessions_dir=_hermes_home_maint / "sessions"
)
session_db.set_meta("ghost_session_prune_v1", "1")
- if pruned:
- logger.info("Pruned %d empty TUI ghost sessions", pruned)
- except Exception as _prune_exc:
- logger.debug("Ghost session prune skipped: %s", _prune_exc)
-
- # One-time finalize of orphaned compression continuations (#20001).
- try:
- if not session_db.get_meta("orphaned_compression_finalize_v1"):
- finalized = session_db.finalize_orphaned_compression_sessions()
- session_db.set_meta("orphaned_compression_finalize_v1", "1")
- if finalized:
- logger.info(
- "Finalized %d orphaned compression sessions", finalized
- )
- except Exception as _finalize_exc:
- logger.debug("Orphan compression finalize skipped: %s", _finalize_exc)
-
- cfg = (_load_full_config().get("sessions") or {})
- if not cfg.get("auto_prune", False):
- return
- session_db.maybe_auto_prune_and_vacuum(
- retention_days=int(cfg.get("retention_days", 90)),
- min_interval_hours=int(cfg.get("min_interval_hours", 24)),
- vacuum=bool(cfg.get("vacuum_after_prune", True)),
- sessions_dir=_hermes_home_maint / "sessions",
- )
- except Exception as exc:
- logger.debug("state.db auto-maintenance skipped: %s", exc)
-
-
-def _run_checkpoint_auto_maintenance() -> None:
- """Call ``checkpoint_manager.maybe_auto_prune_checkpoints`` using current config.
-
- Reads the ``checkpoints:`` section from config.yaml via
- :func:`hermes_cli.config.load_config`. Honours ``auto_prune`` /
- ``retention_days`` / ``delete_orphans`` / ``min_interval_hours``.
- Never raises — maintenance must never block interactive startup.
- """
- try:
- from hermes_cli.config import load_config as _load_full_config
- cfg = (_load_full_config().get("checkpoints") or {})
- if not cfg.get("auto_prune", False):
- return
- from tools.checkpoint_manager import maybe_auto_prune_checkpoints
- maybe_auto_prune_checkpoints(
- retention_days=int(cfg.get("retention_days", 7)),
- min_interval_hours=int(cfg.get("min_interval_hours", 24)),
- delete_orphans=bool(cfg.get("delete_orphans", True)),
- max_total_size_mb=int(cfg.get("max_total_size_mb", 500)),
- )
- except Exception as exc:
- logger.debug("checkpoint auto-maintenance skipped: %s", exc)
-
-
-def _prune_stale_worktrees(repo_root: str, max_age_hours: int = 24) -> None:
- """Remove stale worktrees and orphaned branches on startup.
-
- Age-based tiers (aggressive cleanup keeps ``.worktrees/`` from growing
- unbounded):
- - Under max_age_hours (24h): skip — session may still be active.
- - 24h–72h: remove if no unpushed commits.
- - Over 72h: force remove regardless (nothing should sit this long).
-
- Lock handling (orthogonal to age): ``hermes -w`` locks each worktree with
- reason ``hermes pid=`` so a concurrent hermes process leaves an in-use
- worktree alone. A *live*-locked worktree is skipped at any age; a
- *dead*-locked one (owning pid gone — a crashed session) is unlocked first
- so ``git worktree remove --force`` can actually reap it, otherwise those
- leftovers accumulate forever (``remove --force`` refuses a locked tree).
-
- Branch deletion is gated on ``git worktree remove`` succeeding, so a failed
- removal never orphans the branch (which would drop easy reachability of any
- commits still in the worktree).
-
- Also prunes orphaned ``hermes/*`` and ``pr-*`` local branches that
- have no corresponding worktree.
- """
- import subprocess
- import time
-
- worktrees_dir = Path(repo_root) / ".worktrees"
- if not worktrees_dir.exists():
- _prune_orphaned_branches(repo_root)
- return
-
- now = time.time()
- soft_cutoff = now - (max_age_hours * 3600) # 24h default
- hard_cutoff = now - (max_age_hours * 3 * 3600) # 72h default
-
- for entry in worktrees_dir.iterdir():
- if not entry.is_dir() or not entry.name.startswith("hermes-"):
- continue
-
- # Check age
- try:
- mtime = entry.stat().st_mtime
- if mtime > soft_cutoff:
- continue # Too recent — skip
- except Exception:
- continue
-
- force = mtime <= hard_cutoff # Over 72h — reap aggressively
-
- # Never delete real work, regardless of age. Unpushed commits and
- # uncommitted changes may be a crashed session's in-flight work; the
- # >72h tier reaps only abandoned *clean, fully-pushed* worktrees (the
- # scratch trees that actually cause .worktrees/ bloat).
- if _worktree_has_unpushed_commits(str(entry), timeout=5):
- continue # Has unpushed commits or can't check — skip
- if not force:
- # 24h–72h tier is conservative: unpushed check above is enough.
- pass
- elif _worktree_is_dirty(str(entry), timeout=5):
- continue # >72h but dirty — preserve uncommitted work
-
- # Respect git-native session locks. A lock owned by a still-running
- # hermes process means the worktree is actively in use — never touch
- # it. A lock whose owning pid is gone is a crashed session's leftover:
- # unlock it so `git worktree remove --force` (single -f) can reap it,
- # otherwise dead-locked worktrees pile up indefinitely.
- lock_state = _worktree_lock_is_live(repo_root, str(entry), timeout=5)
- if lock_state == "live":
- logger.debug("Skipping live-locked worktree: %s", entry.name)
- continue
- if lock_state == "dead":
- try:
- subprocess.run(
- ["git", "worktree", "unlock", str(entry)],
- capture_output=True, text=True, timeout=10, cwd=repo_root,
- )
- except Exception as e:
- logger.debug("Failed to unlock dead worktree %s: %s", entry.name, e)
-
- # Safe to remove
- try:
- branch_result = subprocess.run(
- ["git", "branch", "--show-current"],
- capture_output=True, text=True, timeout=5, cwd=str(entry),
- )
- branch = branch_result.stdout.strip()
-
- remove_result = subprocess.run(
- ["git", "worktree", "remove", str(entry), "--force"],
- capture_output=True, text=True, timeout=15, cwd=repo_root,
- )
- if remove_result.returncode != 0:
- # Removal failed — keep the branch so any commits stay
- # reachable rather than orphaning it.
- logger.debug(
- "Failed to remove worktree %s: %s",
- entry.name, remove_result.stderr.strip(),
- )
- continue
- if branch:
- subprocess.run(
- ["git", "branch", "-D", branch],
- capture_output=True, text=True, timeout=10, cwd=repo_root,
- )
- logger.debug("Pruned stale worktree: %s (force=%s)", entry.name, force)
- except Exception as e:
- logger.debug("Failed to prune worktree %s: %s", entry.name, e)
-
- _prune_orphaned_branches(repo_root)
-
-
-def _prune_orphaned_branches(repo_root: str) -> None:
- """Delete local ``hermes/hermes-*`` and ``pr-*`` branches with no worktree.
-
- These are auto-generated by ``hermes -w`` sessions and PR review
- workflows respectively. Once their worktree is gone they serve no
- purpose and just accumulate.
- """
- import subprocess
-
- try:
- result = subprocess.run(
- ["git", "branch", "--format=%(refname:short)"],
- capture_output=True, text=True, timeout=10, cwd=repo_root,
- )
- if result.returncode != 0:
- return
- all_branches = [b.strip() for b in result.stdout.strip().split("\n") if b.strip()]
- except Exception:
- return
-
- # Collect branches that are actively checked out in a worktree
- active_branches: set = set()
- try:
- wt_result = subprocess.run(
- ["git", "worktree", "list", "--porcelain"],
- capture_output=True, text=True, timeout=10, cwd=repo_root,
- )
- for line in wt_result.stdout.split("\n"):
- if line.startswith("branch refs/heads/"):
- active_branches.add(line.split("branch refs/heads/", 1)[-1].strip())
- except Exception:
- return # Can't determine active branches — bail
-
- # Also protect the currently checked-out branch and main
- try:
- head_result = subprocess.run(
- ["git", "branch", "--show-current"],
- capture_output=True, text=True, timeout=5, cwd=repo_root,
- )
- current = head_result.stdout.strip()
- if current:
- active_branches.add(current)
- except Exception:
- pass
- active_branches.add("main")
-
- orphaned = [
- b for b in all_branches
- if b not in active_branches
- and (b.startswith("hermes/hermes-") or b.startswith("pr-"))
- ]
-
- if not orphaned:
- return
-
- # Delete in batches
- for i in range(0, len(orphaned), 50):
- batch = orphaned[i:i + 50]
- try:
- subprocess.run(
- ["git", "branch", "-D"] + batch,
- capture_output=True, text=True, timeout=30, cwd=repo_root,
- )
- except Exception as e:
- logger.debug("Failed to prune orphaned branches: %s", e)
-
- logger.debug("Pruned %d orphaned branches", len(orphaned))
-
-# ============================================================================
-# ASCII Art & Branding
-# ============================================================================
-
-# Color palette (hex colors for Rich markup):
-# - Gold: #FFD700 (headers, highlights)
-# - Amber: #FFBF00 (secondary highlights)
-# - Bronze: #CD7F32 (tertiary elements)
-# - Light: #FFF8DC (text)
-# - Dim: #B8860B (muted text)
-
-# ANSI building blocks for conversation display
-_ACCENT_ANSI_DEFAULT = "\033[1;38;2;255;215;0m" # True-color #FFD700 bold — fallback
-_BOLD = "\033[1m"
-_RST = "\033[0m"
-_STREAM_PAD = " " # 4-space indent for streamed response text (matches Panel padding)
-
-
-def _hex_to_ansi(hex_color: str, *, bold: bool = False) -> str:
- """Convert a hex color like '#268bd2' to a true-color ANSI escape.
-
- Auto-remaps known dark-mode-tuned colors to readable light-mode
- equivalents when running on a light terminal (see
- _maybe_remap_for_light_mode + _LIGHT_MODE_REMAP).
- """
- hex_color = _maybe_remap_for_light_mode(hex_color)
- try:
- r = int(hex_color[1:3], 16)
- g = int(hex_color[3:5], 16)
- b = int(hex_color[5:7], 16)
- prefix = "1;" if bold else ""
- return f"\033[{prefix}38;2;{r};{g};{b}m"
- except (ValueError, IndexError):
- return _ACCENT_ANSI_DEFAULT if bold else "\033[38;2;184;134;11m"
-
-
-# ────────────────────────────────────────────────────────────────────────
-# Light/dark terminal mode detection.
-#
-# Mirrors ui-tui/src/theme.ts detectLightMode(). Used to decide whether
-# to remap "near-white" skin colors (e.g. #FFF8DC banner_text, #B8860B
-# banner_dim) to darker equivalents that are readable on a light
-# Terminal.app / iTerm2 background.
-#
-# Detection priority:
-# 1. HERMES_LIGHT / HERMES_TUI_LIGHT env (true/false) — explicit override
-# 2. HERMES_TUI_THEME=light|dark — explicit theme
-# 3. HERMES_TUI_BACKGROUND=#RRGGBB — explicit bg hint
-# 4. COLORFGBG env (set by xterm/Konsole/urxvt) — bg slot 7/15 = light
-# 5. OSC 11 query (\x1b]11;?\x1b\\) — ask the terminal directly
-# 6. Default: assume dark (matches the legacy Hermes assumption)
-#
-# Cached after first call so we don't query the terminal repeatedly.
-_LIGHT_MODE_CACHE: bool | None = None
-_TRUE_RE = re.compile(r"^(1|true|on|yes|y)$")
-_FALSE_RE = re.compile(r"^(0|false|off|no|n)$")
-_LIGHT_DEFAULT_TERM_PROGRAMS = frozenset() # Apple_Terminal doesn't reliably indicate; require explicit
-
-
-def _luminance_from_hex(hex_str: str) -> float | None:
- s = (hex_str or "").strip().lstrip("#")
- if len(s) == 3:
- s = "".join(c * 2 for c in s)
- if len(s) != 6 or not all(c in "0123456789abcdefABCDEF" for c in s):
- return None
- try:
- r, g, b = int(s[0:2], 16), int(s[2:4], 16), int(s[4:6], 16)
- except ValueError:
- return None
- # Rec.709 luma
- return (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255.0
-
-
-def _query_osc11_background() -> str | None:
- """Ask the terminal for its background color via OSC 11.
-
- Most modern terminals reply with \x1b]11;rgb:RRRR/GGGG/BBBB\x1b\\
- within a few ms. We wait up to 100ms total before giving up.
- Returns "#RRGGBB" or None on timeout / non-tty.
-
- Skipped over SSH: the round-trip routinely exceeds our 100ms budget, so a
- late reply lands after prompt_toolkit has grabbed the tty — its payload
- leaks in as typed text and the BEL terminator reads as Ctrl+G (open
- editor), trapping the user in a stray editor. Remote sessions fall back to
- COLORFGBG / env hints / the dark default instead.
- """
- if not sys.stdin.isatty() or not sys.stdout.isatty():
- return None
- if any(os.environ.get(v) for v in ("SSH_CONNECTION", "SSH_CLIENT", "SSH_TTY")):
- return None
- try:
- import termios
- import tty
- fd = sys.stdin.fileno()
- old = termios.tcgetattr(fd)
- except Exception:
- return None
- try:
- try:
- tty.setcbreak(fd)
- except Exception:
- return None
- try:
- sys.stdout.write("\x1b]11;?\x1b\\")
- sys.stdout.flush()
- except Exception:
- return None
- # Read up to ~50ms for the response
- import select
- deadline = time.monotonic() + 0.1
- buf = b""
- while time.monotonic() < deadline:
- r, _, _ = select.select([fd], [], [], deadline - time.monotonic())
- if not r:
- continue
- try:
- chunk = os.read(fd, 64)
- except OSError:
- break
- if not chunk:
- break
- buf += chunk
- if b"\x1b\\" in buf or b"\x07" in buf:
- break
- # Parse: \x1b]11;rgb:RRRR/GGGG/BBBB\x1b\\
- m = re.search(rb"rgb:([0-9a-fA-F]+)/([0-9a-fA-F]+)/([0-9a-fA-F]+)", buf)
- if not m:
- return None
- # Each component is 1-4 hex digits — normalize to 8-bit
- def norm(h: bytes) -> int:
- v = int(h, 16)
- # Scale to 0-255 based on hex length
- bits = len(h) * 4
- return (v * 255) // ((1 << bits) - 1) if bits else 0
- r, g, b = norm(m.group(1)), norm(m.group(2)), norm(m.group(3))
- return f"#{r:02X}{g:02X}{b:02X}"
- finally:
- # TCSAFLUSH discards any unread input as it restores the original
- # attributes — scrubs a slow/partial OSC 11 reply out of the tty
- # buffer before prompt_toolkit can read it as keystrokes.
- try:
- termios.tcsetattr(fd, termios.TCSAFLUSH, old)
- except Exception:
- pass
-
-
-def _detect_light_mode() -> bool:
- global _LIGHT_MODE_CACHE
- if _LIGHT_MODE_CACHE is not None:
- return _LIGHT_MODE_CACHE
- result = False
- try:
- # 1. Explicit env override
- for var in ("HERMES_LIGHT", "HERMES_TUI_LIGHT"):
- v = (os.environ.get(var) or "").strip().lower()
- if _TRUE_RE.match(v):
- result = True
- _LIGHT_MODE_CACHE = result
- return result
- if _FALSE_RE.match(v):
- _LIGHT_MODE_CACHE = result
- return result
- # 2. Theme hint
- theme = (os.environ.get("HERMES_TUI_THEME") or "").strip().lower()
- if theme == "light":
- result = True
- _LIGHT_MODE_CACHE = result
- return result
- if theme == "dark":
- _LIGHT_MODE_CACHE = result
- return result
- # 3. Explicit bg hex
- bg_hint = os.environ.get("HERMES_TUI_BACKGROUND") or ""
- bg_lum = _luminance_from_hex(bg_hint)
- if bg_lum is not None:
- result = bg_lum >= 0.5
- _LIGHT_MODE_CACHE = result
- return result
- # 4. COLORFGBG (xterm/Konsole/urxvt)
- cfgbg = (os.environ.get("COLORFGBG") or "").strip()
- if cfgbg:
- last = cfgbg.split(";")[-1] if ";" in cfgbg else cfgbg
- if last.isdigit():
- bg = int(last)
- if bg in {7, 15}:
- result = True
- _LIGHT_MODE_CACHE = result
- return result
- if 0 <= bg < 16:
- _LIGHT_MODE_CACHE = result
- return result
- # 5. OSC 11 query (best-effort, only when stdin/stdout are TTY)
- bg_color = _query_osc11_background()
- if bg_color:
- lum = _luminance_from_hex(bg_color)
- if lum is not None:
- result = lum >= 0.5
- _LIGHT_MODE_CACHE = result
- return result
- # 6. TERM_PROGRAM allow-list (currently empty)
- tp = (os.environ.get("TERM_PROGRAM") or "").strip()
- if tp in _LIGHT_DEFAULT_TERM_PROGRAMS:
- result = True
- except Exception:
- result = False
- _LIGHT_MODE_CACHE = result
- return result
-
-
-# Light-mode equivalents of skin colors that are unreadable on cream
-# Terminal.app backgrounds. Used by _SkinAwareAnsi to remap colors
-# at resolution time when light mode is detected.
-#
-# IMPORTANT: only remap colors that are used as STANDALONE foregrounds
-# on the terminal's background. Don't remap colors that are paired
-# with a dark bg (e.g. status bar text on bg:#1a1a2e) — those would
-# become invisible the OTHER direction (dark gray on dark navy).
-_LIGHT_MODE_REMAP: dict[str, str] = {
- # Original (dark-mode) -> Light-mode replacement (darker, readable)
- "#FFF8DC": "#1A1A1A", # cornsilk -> near-black
- "#FFD700": "#9A6B00", # gold -> dark goldenrod (readable on cream)
- "#FFBF00": "#8A5A00", # amber -> dark amber
- "#B8860B": "#5C4500", # dark goldenrod -> deeper brown (more contrast)
- "#DAA520": "#6B4F00", # goldenrod -> dark olive
- "#F1E6CF": "#1A1A1A", # cream -> near-black
- "#c9d1d9": "#24292F", # github-light fg
- "#EAF7FF": "#0F1B26", # ice
- "#F5F5F5": "#1A1A1A",
- "#FFF0D4": "#1A1A1A",
- "#CD7F32": "#8A4F1A", # bronze -> darker bronze
- "#FFEFB5": "#3A2A00",
- # NOTE: skipping #C0C0C0/#888888/#555555/#8B8682 — those are
- # status-bar foregrounds paired with dark navy bg, where dark
- # remap values would become invisible.
-}
-
-
-def _maybe_remap_for_light_mode(hex_color: str) -> str:
- """If we're in light mode, remap a dark-mode-tuned color to a
- higher-contrast equivalent. No-op in dark mode."""
- if not _detect_light_mode():
- return hex_color
- if not hex_color or not hex_color.startswith("#"):
- return hex_color
- # Case-insensitive lookup
- upper = hex_color.upper()
- if upper in _LIGHT_MODE_REMAP_UPPER:
- return _LIGHT_MODE_REMAP_UPPER[upper]
- return hex_color
-
-
-# Pre-uppercased lookup table for case-insensitive remapping
-_LIGHT_MODE_REMAP_UPPER = {k.upper(): v for k, v in _LIGHT_MODE_REMAP.items()}
-
-
-def _install_skin_light_mode_hook() -> None:
- """Wrap SkinConfig.get_color at import time so EVERY skin color read goes
- through the light-mode remap. Idempotent."""
- try:
- from hermes_cli.skin_engine import SkinConfig # type: ignore[import]
- except Exception:
- return
- if getattr(SkinConfig, "_hermes_light_mode_hook_installed", False):
- return
- _orig_get_color = SkinConfig.get_color
-
- def _wrapped_get_color(self, key, fallback=""):
- value = _orig_get_color(self, key, fallback)
- try:
- return _maybe_remap_for_light_mode(value)
- except Exception:
- return value
-
- SkinConfig.get_color = _wrapped_get_color # type: ignore[method-assign]
- SkinConfig._hermes_light_mode_hook_installed = True # type: ignore[attr-defined]
-
-
-_install_skin_light_mode_hook()
-
-
-# Prime the light-mode detection cache early (at module load) when
-# we're running interactively so OSC 11 happens before pt grabs the
-# tty. Skip for non-tty contexts (subagents, gateway, tests).
-try:
- if sys.stdin.isatty() and sys.stdout.isatty():
- _detect_light_mode()
-except Exception:
- pass
-
-
-
-class _SkinAwareAnsi:
- """Lazy ANSI escape that resolves from the skin engine on first use.
-
- Acts as a string in f-strings and concatenation. Call ``.reset()`` to
- force re-resolution after a ``/skin`` switch.
- """
-
- def __init__(self, skin_key: str, fallback_hex: str = "#FFD700", *, bold: bool = False):
- self._skin_key = skin_key
- self._fallback_hex = fallback_hex
- self._bold = bold
- self._cached: str | None = None
-
- def __str__(self) -> str:
- if self._cached is None:
- try:
- from hermes_cli.skin_engine import get_active_skin
- self._cached = _hex_to_ansi(
- get_active_skin().get_color(self._skin_key, self._fallback_hex),
- bold=self._bold,
- )
- except Exception:
- self._cached = _hex_to_ansi(self._fallback_hex, bold=self._bold)
- return self._cached
-
- def __add__(self, other: str) -> str:
- return str(self) + other
-
- def __radd__(self, other: str) -> str:
- return other + str(self)
-
- def reset(self) -> None:
- """Clear cache so the next access re-reads the skin."""
- self._cached = None
-
-
-_ACCENT = _SkinAwareAnsi("response_border", "#FFD700", bold=True)
-# Use ANSI dim+italic attributes (\x1b[2;3m) instead of a hardcoded
-# hex color so dim/thinking text inherits the terminal's default
-# foreground color and stays readable in both light and dark
-# Terminal.app modes. Hardcoded skin colors like #B8860B
-# (dark goldenrod) become invisible against light cream backgrounds.
-_DIM = "\x1b[2;3m"
-
+ if pruned:
+ logger.info("Pruned %d empty TUI ghost sessions", pruned)
+ except Exception as _prune_exc:
+ logger.debug("Ghost session prune skipped: %s", _prune_exc)
-def _b(s: str) -> str:
- """Bold if stdout is a real TTY; plain text otherwise (slash-worker safe)."""
- import sys as _sys
- try:
- return f"\x1b[1m{s}\x1b[0m" if _sys.stdout.isatty() else str(s)
- except Exception:
- return str(s)
+ # One-time finalize of orphaned compression continuations (#20001).
+ try:
+ if not session_db.get_meta("orphaned_compression_finalize_v1"):
+ finalized = session_db.finalize_orphaned_compression_sessions()
+ session_db.set_meta("orphaned_compression_finalize_v1", "1")
+ if finalized:
+ logger.info(
+ "Finalized %d orphaned compression sessions", finalized
+ )
+ except Exception as _finalize_exc:
+ logger.debug("Orphan compression finalize skipped: %s", _finalize_exc)
+ cfg = (_load_full_config().get("sessions") or {})
+ if not cfg.get("auto_prune", False):
+ return
+ session_db.maybe_auto_prune_and_vacuum(
+ retention_days=int(cfg.get("retention_days", 90)),
+ min_interval_hours=int(cfg.get("min_interval_hours", 24)),
+ vacuum=bool(cfg.get("vacuum_after_prune", True)),
+ sessions_dir=_hermes_home_maint / "sessions",
+ )
+ except Exception as exc:
+ logger.debug("state.db auto-maintenance skipped: %s", exc)
-def _d(s: str) -> str:
- """Dim-italic if stdout is a real TTY; plain text otherwise."""
- import sys as _sys
- try:
- return f"\x1b[2;3m{s}\x1b[0m" if _sys.stdout.isatty() else str(s)
- except Exception:
- return str(s)
+def _run_checkpoint_auto_maintenance() -> None:
+ """Call ``checkpoint_manager.maybe_auto_prune_checkpoints`` using current config.
-def _accent_hex() -> str:
- """Return the active skin accent color for legacy CLI output lines."""
+ Reads the ``checkpoints:`` section from config.yaml via
+ :func:`hermes_cli.config.load_config`. Honours ``auto_prune`` /
+ ``retention_days`` / ``delete_orphans`` / ``min_interval_hours``.
+ Never raises — maintenance must never block interactive startup.
+ """
try:
- from hermes_cli.skin_engine import get_active_skin
- return get_active_skin().get_color("ui_accent", "#FFBF00")
- except Exception:
- return "#FFBF00"
-
+ from hermes_cli.config import load_config as _load_full_config
+ cfg = (_load_full_config().get("checkpoints") or {})
+ if not cfg.get("auto_prune", False):
+ return
+ from tools.checkpoint_manager import maybe_auto_prune_checkpoints
+ maybe_auto_prune_checkpoints(
+ retention_days=int(cfg.get("retention_days", 7)),
+ min_interval_hours=int(cfg.get("min_interval_hours", 24)),
+ delete_orphans=bool(cfg.get("delete_orphans", True)),
+ max_total_size_mb=int(cfg.get("max_total_size_mb", 500)),
+ )
+ except Exception as exc:
+ logger.debug("checkpoint auto-maintenance skipped: %s", exc)
-def _rich_text_from_ansi(text: str) -> _RichText:
- """Safely render assistant/tool output that may contain ANSI escapes.
- Using Rich Text.from_ansi preserves literal bracketed text like
- ``[not markup]`` while still interpreting real ANSI color codes.
- """
- return _RichText.from_ansi(text or "")
+# ============================================================================
+# ASCII Art & Branding, ANSI helpers, and UI primitives
+# ============================================================================
+# Implementation extracted to cli_display.py; re-imported here so existing
+# ``from cli import X`` call sites keep working.
+from cli_display import (
+ HERMES_AGENT_LOGO,
+ HERMES_CADUCEUS,
+ ChatConsole,
+ _ACCENT,
+ _ACCENT_ANSI_DEFAULT,
+ _BOLD,
+ _DIM,
+ _IMAGE_EXTENSIONS,
+ _OSC_ESCAPE_RE,
+ _RST,
+ _STREAM_PAD,
+ _SkinAwareAnsi,
+ _accent_hex,
+ _b,
+ _build_compact_banner,
+ _clear_output_history,
+ _coerce_output_history_limit,
+ _collect_query_images,
+ _configure_output_history,
+ _cprint,
+ _d,
+ _detect_file_drop,
+ _detect_light_mode,
+ _format_image_attachment_badges,
+ _hex_to_ansi,
+ _install_skin_light_mode_hook,
+ _looks_like_slash_command,
+ _luminance_from_hex,
+ _maybe_remap_for_light_mode,
+ _query_osc11_background,
+ _record_output_history,
+ _record_output_history_entry,
+ _replay_output_history,
+ _resolve_attachment_path,
+ _rich_text_from_ansi,
+ _should_auto_attach_clipboard_image_on_paste,
+ _split_path_input,
+ _suspend_output_history,
+ _termux_example_image_path,
+)
def _strip_markdown_syntax(text: str) -> str:
@@ -2504,197 +1007,6 @@ def _render_final_assistant_content(text: str, mode: str = "render"):
return Markdown(plain)
-_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 _coerce_output_history_limit(value) -> int:
- try:
- return max(10, int(value))
- except (TypeError, ValueError):
- return 200
-
-
-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)
-
-
-def _clear_output_history() -> None:
- _OUTPUT_HISTORY.clear()
-
-
-@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 _record_output_history_entry(entry) -> None:
- if not _OUTPUT_HISTORY_ENABLED or _OUTPUT_HISTORY_REPLAYING or _OUTPUT_HISTORY_SUPPRESSED:
- return
- _OUTPUT_HISTORY.append(entry)
-
-
-def _record_output_history(text: str) -> None:
- if not _OUTPUT_HISTORY_ENABLED or _OUTPUT_HISTORY_REPLAYING or _OUTPUT_HISTORY_SUPPRESSED:
- return
- normalized = str(text).replace("\r", "").rstrip("\n")
- if not normalized:
- return
- for line in normalized.splitlines():
- _record_output_history_entry(line)
-
-
-def _replay_output_history() -> None:
- """Repaint recent output above the prompt after a full screen clear."""
- global _OUTPUT_HISTORY_REPLAYING
- if not _OUTPUT_HISTORY_ENABLED or not _OUTPUT_HISTORY:
- return
- _OUTPUT_HISTORY_REPLAYING = True
- try:
- rendered_lines = []
- for entry in tuple(_OUTPUT_HISTORY):
- if callable(entry):
- try:
- lines = entry()
- except Exception:
- continue
- if isinstance(lines, str):
- lines = lines.splitlines()
- else:
- lines = [entry]
- rendered_lines.extend(str(line) for line in lines)
- if rendered_lines:
- # Replay after resize can contain hundreds of history lines. A
- # per-line prompt_toolkit print forces one synchronous terminal I/O
- # and redraw cycle per line, which users perceive as a waterfall of
- # old output. Keep the existing history contents unchanged, but
- # emit the replay as one ANSI payload so resize recovery does a
- # single prompt_toolkit print/redraw.
- _pt_print(_PT_ANSI("\n".join(rendered_lines)))
- except Exception:
- pass
- finally:
- _OUTPUT_HISTORY_REPLAYING = False
-
-
-def _cprint(text: str):
- """Print ANSI-colored text through prompt_toolkit's native renderer.
-
- Raw ANSI escapes written via print() are swallowed by patch_stdout's
- StdoutProxy. Routing through print_formatted_text(ANSI(...)) lets
- prompt_toolkit parse the escapes and render real colors.
-
- When called from a background thread while a prompt_toolkit
- ``Application`` is running (the common case for the self-improvement
- background review's ``💾 …`` summary, curator summaries, and other
- bg-thread emissions), a direct ``_pt_print`` races with the input
- area's redraw and the line can end up visually buried behind the
- prompt. Route those cases through ``run_in_terminal`` via
- ``loop.call_soon_threadsafe``, which pauses the input area, prints
- the line above it, and redraws the prompt cleanly.
- """
- _record_output_history(text)
-
- try:
- from prompt_toolkit.application import get_app_or_none, run_in_terminal
- except Exception:
- _pt_print(_PT_ANSI(text))
- return
-
- app = None
- try:
- app = get_app_or_none()
- except Exception:
- app = None
-
- # No active app, or we're already on the app's main thread: the
- # 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):
- 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:
- loop = app.loop # type: ignore[attr-defined]
- except Exception:
- loop = None
- if loop is None:
- _pt_print(_PT_ANSI(text))
- return
-
- import asyncio as _asyncio
- try:
- # Use get_running_loop() instead of get_event_loop() to avoid the
- # DeprecationWarning / RuntimeWarning emitted by Python 3.10+ when
- # get_event_loop() is called from a thread that has no current event
- # loop set (e.g. the process_loop background thread). Fixes #19285.
- current_loop = _asyncio.get_running_loop()
- except RuntimeError:
- current_loop = None
- except Exception:
- current_loop = None
- # Same thread as the app's loop → safe to print directly.
- if current_loop is loop and loop.is_running():
- _pt_print(_PT_ANSI(text))
- return
-
- # Cross-thread emission: ask the app's event loop to schedule a
- # ``run_in_terminal`` that wraps ``_pt_print``. This hides the
- # 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:
- 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:
- pass # best-effort; the line may already have been printed
-
- try:
- loop.call_soon_threadsafe(_schedule)
- except Exception:
- try:
- _pt_print(_PT_ANSI(text))
- except Exception:
- pass
-
-
def _prepend_note_to_message(message, note: str):
"""Prepend a one-shot system-style note to a user message.
@@ -2750,250 +1062,6 @@ def _cli_visible_print(text: str = "") -> None:
print(text)
-# ---------------------------------------------------------------------------
-# File-drop / local attachment detection — extracted as pure helpers for tests.
-# ---------------------------------------------------------------------------
-
-_IMAGE_EXTENSIONS = frozenset({
- '.png', '.jpg', '.jpeg', '.gif', '.webp',
- '.bmp', '.tiff', '.tif', '.svg', '.ico',
-})
-
-
-from hermes_constants import is_termux as _is_termux_environment
-
-
-def _termux_example_image_path(filename: str = "cat.png") -> str:
- """Return a realistic example media path for the current Termux setup."""
- candidates = [
- os.path.expanduser("~/storage/shared"),
- "/sdcard",
- "/storage/emulated/0",
- "/storage/self/primary",
- ]
- for root in candidates:
- if os.path.isdir(root):
- return os.path.join(root, "Pictures", filename)
- return os.path.join("~/storage/shared", "Pictures", filename)
-
-
-def _split_path_input(raw: str) -> tuple[str, str]:
- r"""Split a leading file path token from trailing free-form text.
-
- Supports quoted paths and backslash-escaped spaces so callers can accept
- inputs like:
- /tmp/pic.png describe this
- ~/storage/shared/My\ Photos/cat.png what is this?
- "/storage/emulated/0/DCIM/Camera/cat 1.png" summarize
- """
- raw = str(raw or "").strip()
- if not raw:
- return "", ""
-
- if raw[0] in {'"', "'"}:
- quote = raw[0]
- pos = 1
- while pos < len(raw):
- ch = raw[pos]
- if ch == '\\' and pos + 1 < len(raw):
- pos += 2
- continue
- if ch == quote:
- token = raw[1:pos]
- remainder = raw[pos + 1 :].strip()
- return token, remainder
- pos += 1
- return raw[1:], ""
-
- pos = 0
- while pos < len(raw):
- ch = raw[pos]
- if ch == '\\' and pos + 1 < len(raw) and raw[pos + 1] == ' ':
- pos += 2
- elif ch == ' ':
- break
- else:
- pos += 1
-
- token = raw[:pos].replace('\\ ', ' ')
- remainder = raw[pos:].strip()
- return token, remainder
-
-
-def _resolve_attachment_path(raw_path: str) -> Path | None:
- """Resolve a user-supplied local attachment path.
-
- Accepts quoted or unquoted paths, expands ``~`` and env vars, and resolves
- relative paths from ``TERMINAL_CWD`` when set (matching terminal tool cwd).
- Returns ``None`` when the path does not resolve to an existing file.
- """
- token = str(raw_path or "").strip()
- if not token:
- return None
-
- if (token.startswith('"') and token.endswith('"')) or (token.startswith("'") and token.endswith("'")):
- token = token[1:-1].strip()
- token = token.replace('\\ ', ' ')
- if not token:
- return None
-
- expanded = token
- if token.startswith("file://"):
- try:
- parsed = urlparse(token)
- if parsed.scheme == "file":
- expanded = unquote(parsed.path or "")
- if parsed.netloc and os.name == "nt":
- expanded = f"//{parsed.netloc}{expanded}"
- except Exception:
- expanded = token
- expanded = os.path.expandvars(os.path.expanduser(expanded))
- if os.name != "nt":
- normalized = expanded.replace("\\", "/")
- if len(normalized) >= 3 and normalized[1] == ":" and normalized[2] == "/" and normalized[0].isalpha():
- expanded = f"/mnt/{normalized[0].lower()}/{normalized[3:]}"
- path = Path(expanded)
- if not path.is_absolute():
- base_dir = Path(os.getenv("TERMINAL_CWD", os.getcwd()))
- path = base_dir / path
-
- try:
- resolved = path.resolve()
- except Exception:
- resolved = path
-
- # Path.exists() / is_file() invoke os.stat(), which raises OSError when
- # the candidate string is structurally invalid as a path — most commonly
- # ENAMETOOLONG (errno 63 on macOS, errno 36 on Linux) when the input
- # exceeds NAME_MAX (typically 255 bytes). This bites pasted slash
- # commands like `/goal ` because `_detect_file_drop()`'s
- # `starts_like_path` prefilter accepts any input starting with `/`,
- # then this resolver tries to stat it before short-circuiting on the
- # slash-command path. Without this guard the OSError propagates up to
- # the process_loop catch-all in _interactive_loop and the user input
- # is silently lost (the warning ends up in agent.log but the user sees
- # nothing — the prompt just hangs).
- try:
- if not resolved.exists() or not resolved.is_file():
- return None
- except OSError:
- return None
- return resolved
-
-
-
-
-
-def _detect_file_drop(user_input: str) -> "dict | None":
- """Detect if *user_input* starts with a real local file path.
-
- This catches dragged/pasted paths before they are mistaken for slash
- commands, and also supports Termux-friendly paths like ``~/storage/...``.
-
- Returns a dict on match::
-
- {
- "path": Path, # resolved file path
- "is_image": bool, # True when suffix is a known image type
- "remainder": str, # any text after the path
- }
-
- Returns ``None`` when the input is not a real file path.
- """
- if not isinstance(user_input, str):
- return None
-
- stripped = user_input.strip()
- if not stripped:
- return None
-
- starts_like_path = (
- stripped.startswith("/")
- or stripped.startswith("~")
- or stripped.startswith("./")
- or stripped.startswith("../")
- or stripped.startswith("file://")
- or (len(stripped) >= 3 and stripped[1] == ":" and stripped[2] in {"\\", "/"} and stripped[0].isalpha())
- or stripped.startswith('"/')
- or stripped.startswith('"~')
- or stripped.startswith("'/")
- or stripped.startswith("'~")
- or stripped.startswith('"./')
- or stripped.startswith('"../')
- or stripped.startswith("'./")
- or stripped.startswith("'../")
- or (len(stripped) >= 4 and stripped[0] in {"'", '"'} and stripped[2] == ":" and stripped[3] in {"\\", "/"} and stripped[1].isalpha())
- )
- if not starts_like_path:
- return None
-
- direct_path = _resolve_attachment_path(stripped)
- if direct_path is not None:
- return {
- "path": direct_path,
- "is_image": direct_path.suffix.lower() in _IMAGE_EXTENSIONS,
- "remainder": "",
- }
-
- first_token, remainder = _split_path_input(stripped)
- drop_path = _resolve_attachment_path(first_token)
- if drop_path is None and " " in stripped and stripped[0] not in {"'", '"'}:
- space_positions = [idx for idx, ch in enumerate(stripped) if ch == " "]
- for pos in reversed(space_positions):
- candidate = stripped[:pos].rstrip()
- resolved = _resolve_attachment_path(candidate)
- if resolved is not None:
- drop_path = resolved
- remainder = stripped[pos + 1 :].strip()
- break
- if drop_path is None:
- return None
-
- return {
- "path": drop_path,
- "is_image": drop_path.suffix.lower() in _IMAGE_EXTENSIONS,
- "remainder": remainder,
- }
-
-
-def _format_image_attachment_badges(attached_images: list[Path], image_counter: int, width: int | None = None) -> str:
- """Format the attached-image badge row for the interactive CLI.
-
- Narrow terminals such as Termux should get a compact summary that fits on a
- single row, while wider terminals can show the classic per-image badges.
- """
- if not attached_images:
- return ""
-
- width = width or shutil.get_terminal_size((80, 24)).columns
-
- def _trunc(name: str, limit: int) -> str:
- return name if len(name) <= limit else name[: max(1, limit - 3)] + "..."
-
- if width < 52:
- if len(attached_images) == 1:
- return f"[📎 {_trunc(attached_images[0].name, 20)}]"
- return f"[📎 {len(attached_images)} images attached]"
-
- if width < 80:
- if len(attached_images) == 1:
- return f"[📎 {_trunc(attached_images[0].name, 32)}]"
- first = _trunc(attached_images[0].name, 20)
- extra = len(attached_images) - 1
- return f"[📎 {first}] [+{extra}]"
-
- base = image_counter - len(attached_images) + 1
- return " ".join(
- f"[📎 Image #{base + i}]"
- for i in range(len(attached_images))
- )
-
-
-def _should_auto_attach_clipboard_image_on_paste(pasted_text: str) -> bool:
- """Auto-attach clipboard images only for image-only paste gestures."""
- return not pasted_text.strip()
-
-
def _strip_leaked_bracketed_paste_wrappers(text: str) -> str:
"""Strip leaked bracketed-paste wrapper markers from user-visible text.
@@ -3365,187 +1433,6 @@ def _estimate_tui_input_height(
return min(max(visual_lines, 1), max(1, int(max_height or 1)))
-def _collect_query_images(query: str | None, image_arg: str | None = None) -> tuple[str, list[Path]]:
- """Collect local image attachments for single-query CLI flows."""
- message = query or ""
- images: list[Path] = []
-
- if isinstance(message, str):
- dropped = _detect_file_drop(message)
- if dropped and dropped.get("is_image"):
- images.append(dropped["path"])
- message = dropped["remainder"] or f"[User attached image: {dropped['path'].name}]"
-
- if image_arg:
- explicit_path = _resolve_attachment_path(image_arg)
- if explicit_path is None:
- raise ValueError(f"Image file not found: {image_arg}")
- if explicit_path.suffix.lower() not in _IMAGE_EXTENSIONS:
- raise ValueError(f"Not a supported image file: {explicit_path}")
- images.append(explicit_path)
-
- deduped: list[Path] = []
- seen: set[str] = set()
- for img in images:
- key = str(img)
- if key in seen:
- continue
- seen.add(key)
- deduped.append(img)
- return message, deduped
-
-
-# Strip OSC escape sequences (e.g. OSC-8 hyperlinks) that prompt_toolkit's
-# ANSI parser can't handle — it strips \x1b but passes the payload through
-# as literal text, garbling the TUI output.
-_OSC_ESCAPE_RE = re.compile(r"\x1b\][\s\S]*?(?:\x07|\x1b\\)")
-
-
-class ChatConsole:
- """Rich Console adapter for prompt_toolkit's patch_stdout context.
-
- Captures Rich's rendered ANSI output and routes it through _cprint
- so colors and markup render correctly inside the interactive chat loop.
- Drop-in replacement for Rich Console — just pass this to any function
- that expects a console.print() interface.
- """
-
- def __init__(self):
- from io import StringIO
- self._buffer = StringIO()
- self._inner = Console(
- file=self._buffer,
- force_terminal=True,
- color_system="truecolor",
- highlight=False,
- )
-
- def print(self, *args, **kwargs):
- self._buffer.seek(0)
- self._buffer.truncate()
- # Read terminal width at render time so panels adapt to current size
- self._inner.width = shutil.get_terminal_size((80, 24)).columns
- self._inner.print(*args, **kwargs)
- output = self._buffer.getvalue()
- # Strip OSC escape sequences (e.g. OSC-8 hyperlinks) before
- # routing through prompt_toolkit's ANSI parser, which only
- # handles CSI/SGR and passes OSC payload through as literal text.
- output = _OSC_ESCAPE_RE.sub("", output)
- for line in output.rstrip("\n").split("\n"):
- _cprint(line)
-
- @contextmanager
- def status(self, *_args, **_kwargs):
- """Provide a no-op Rich-compatible status context.
-
- Some slash command helpers use ``console.status(...)`` when running in
- the standalone CLI. Interactive chat routes those helpers through
- ``ChatConsole()``, which historically only implemented ``print()``.
- Returning a silent context manager keeps slash commands compatible
- without duplicating the higher-level busy indicator already shown by
- ``HermesCLI._busy_command()``.
- """
- yield self
-
-# ASCII Art - HERMES-AGENT logo (full width, single line - requires ~95 char terminal)
-HERMES_AGENT_LOGO = """[bold #FFD700]██╗ ██╗███████╗██████╗ ███╗ ███╗███████╗███████╗ █████╗ ██████╗ ███████╗███╗ ██╗████████╗[/]
-[bold #FFD700]██║ ██║██╔════╝██╔══██╗████╗ ████║██╔════╝██╔════╝ ██╔══██╗██╔════╝ ██╔════╝████╗ ██║╚══██╔══╝[/]
-[#FFBF00]███████║█████╗ ██████╔╝██╔████╔██║█████╗ ███████╗█████╗███████║██║ ███╗█████╗ ██╔██╗ ██║ ██║[/]
-[#FFBF00]██╔══██║██╔══╝ ██╔══██╗██║╚██╔╝██║██╔══╝ ╚════██║╚════╝██╔══██║██║ ██║██╔══╝ ██║╚██╗██║ ██║[/]
-[#CD7F32]██║ ██║███████╗██║ ██║██║ ╚═╝ ██║███████╗███████║ ██║ ██║╚██████╔╝███████╗██║ ╚████║ ██║[/]
-[#CD7F32]╚═╝ ╚═╝╚══════╝╚═╝ ╚═╝╚═╝ ╚═╝╚══════╝╚══════╝ ╚═╝ ╚═╝ ╚═════╝ ╚══════╝╚═╝ ╚═══╝ ╚═╝[/]"""
-
-# ASCII Art - Hermes Caduceus (compact, fits in left panel)
-HERMES_CADUCEUS = """[#CD7F32]⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⣀⡀⠀⣀⣀⠀⢀⣀⡀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀[/]
-[#CD7F32]⠀⠀⠀⠀⠀⠀⢀⣠⣴⣾⣿⣿⣇⠸⣿⣿⠇⣸⣿⣿⣷⣦⣄⡀⠀⠀⠀⠀⠀⠀[/]
-[#FFBF00]⠀⢀⣠⣴⣶⠿⠋⣩⡿⣿⡿⠻⣿⡇⢠⡄⢸⣿⠟⢿⣿⢿⣍⠙⠿⣶⣦⣄⡀⠀[/]
-[#FFBF00]⠀⠀⠉⠉⠁⠶⠟⠋⠀⠉⠀⢀⣈⣁⡈⢁⣈⣁⡀⠀⠉⠀⠙⠻⠶⠈⠉⠉⠀⠀[/]
-[#FFD700]⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣴⣿⡿⠛⢁⡈⠛⢿⣿⣦⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀[/]
-[#FFD700]⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠿⣿⣦⣤⣈⠁⢠⣴⣿⠿⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀[/]
-[#FFBF00]⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠈⠉⠻⢿⣿⣦⡉⠁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀[/]
-[#FFBF00]⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠘⢷⣦⣈⠛⠃⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀[/]
-[#CD7F32]⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢠⣴⠦⠈⠙⠿⣦⡄⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀[/]
-[#CD7F32]⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠸⣿⣤⡈⠁⢤⣿⠇⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀[/]
-[#B8860B]⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠉⠛⠷⠄⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀[/]
-[#B8860B]⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⣀⠑⢶⣄⡀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀[/]
-[#B8860B]⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣿⠁⢰⡆⠈⡿⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀[/]
-[#B8860B]⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠈⠳⠈⣡⠞⠁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀[/]
-[#B8860B]⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠈⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀[/]"""
-
-
-
-def _build_compact_banner() -> str:
- """Build a compact banner that fits the current terminal width."""
- try:
- from hermes_cli.skin_engine import get_active_skin
- _skin = get_active_skin()
- except Exception:
- _skin = None
-
- skin_name = getattr(_skin, "name", "default") if _skin else "default"
- border_color = _skin.get_color("banner_border", "#FFD700") if _skin else "#FFD700"
- title_color = _skin.get_color("banner_title", "#FFBF00") if _skin else "#FFBF00"
- dim_color = _skin.get_color("banner_dim", "#B8860B") if _skin else "#B8860B"
-
- if skin_name == "default":
- line1 = "⚕ NOUS HERMES - AI Agent Framework"
- tiny_line = "⚕ NOUS HERMES"
- else:
- agent_name = _skin.get_branding("agent_name", "Hermes Agent") if _skin else "Hermes Agent"
- line1 = f"{agent_name} - AI Agent Framework"
- tiny_line = agent_name
-
- if os.environ.get("HERMES_FAST_STARTUP_BANNER") == "1":
- from hermes_cli import __release_date__ as _release_date
- from hermes_cli import __version__ as _version
-
- version_line = f"Hermes Agent v{_version} ({_release_date})"
- else:
- version_line = format_banner_version_label()
-
- w = min(shutil.get_terminal_size().columns - 2, 88)
- if w < 30:
- return f"\n[{title_color}]{tiny_line}[/] [dim {dim_color}]- Nous Research[/]\n"
-
- inner = w - 2 # inside the box border
- bar = "═" * w
- content_width = inner - 2
-
- # Truncate and pad to fit
- line1 = line1[:content_width].ljust(content_width)
- line2 = version_line[:content_width].ljust(content_width)
-
- return (
- f"\n[bold {border_color}]╔{bar}╗[/]\n"
- f"[bold {border_color}]║[/] [{title_color}]{line1}[/] [bold {border_color}]║[/]\n"
- f"[bold {border_color}]║[/] [dim {dim_color}]{line2}[/] [bold {border_color}]║[/]\n"
- f"[bold {border_color}]╚{bar}╝[/]\n"
- )
-
-
-
-# ============================================================================
-# Slash-command detection helper
-# ============================================================================
-
-def _looks_like_slash_command(text: str) -> bool:
- """Return True if *text* looks like a slash command, not a file path.
-
- Slash commands are ``/help``, ``/model gpt-4``, ``/q``, etc.
- File paths like ``/Users/ironin/file.md:45-46 can you fix this?``
- also start with ``/`` but contain additional ``/`` characters in
- the first whitespace-delimited word. This helper distinguishes
- the two so that pasted paths are sent to the agent instead of
- triggering "Unknown command".
- """
- if not text or not text.startswith("/"):
- return False
- first_word = text.split()[0]
- # After stripping the leading /, a command name has no slashes.
- # A path like /Users/foo/bar.md always does.
- return "/" not in first_word[1:]
-
-
# ============================================================================
# Skill Slash Commands — dynamic commands generated from installed skills
# ============================================================================
@@ -3627,49 +1514,6 @@ def _parse_skills_argument(skills: str | list[str] | tuple[str, ...] | None) ->
return parsed
-def save_config_value(key_path: str, value: any) -> bool:
- """
- Save a value to the active config file at the specified key path.
-
- Respects the same lookup order as load_cli_config():
- 1. ~/.hermes/config.yaml (user config - preferred, used if it exists)
- 2. ./cli-config.yaml (project config - fallback)
-
- Args:
- key_path: Dot-separated path like "agent.system_prompt"
- value: Value to save
-
- Returns:
- True if successful, False otherwise
- """
- # Use the same precedence as load_cli_config: user config first, then project config
- user_config_path = _hermes_home / 'config.yaml'
- project_config_path = Path(__file__).parent / 'cli-config.yaml'
- config_path = user_config_path if user_config_path.exists() else project_config_path
-
- try:
- # Ensure parent directory exists (for ~/.hermes/config.yaml on first use)
- config_path.parent.mkdir(parents=True, exist_ok=True)
-
- # Save back atomically while preserving comments, ordering, quotes, and
- # readable Unicode in user-edited config.yaml.
- from utils import atomic_roundtrip_yaml_update
- atomic_roundtrip_yaml_update(config_path, key_path, value)
-
- # Enforce owner-only permissions on config files (contain API keys)
- try:
- 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
-
-
-
-
# ============================================================================
# HermesCLI Class
# ============================================================================
@@ -15820,8 +13664,6 @@ def main(
python cli.py -w # Start in isolated git worktree
python cli.py -w -q "Fix issue #123" # Single query in worktree
"""
- global _active_worktree
-
# Force UTF-8 stdio on Windows before any banner/print() runs — the
# Rich console prints Unicode box-drawing characters that would
# UnicodeEncodeError on cp1252. No-op on Linux/macOS.
@@ -15861,7 +13703,7 @@ def main(
_sync_base = CLI_CONFIG.get("worktree_sync", True)
wt_info = _setup_worktree(sync_base=_sync_base)
if wt_info:
- _active_worktree = wt_info
+ set_active_worktree(wt_info)
os.environ["TERMINAL_CWD"] = wt_info["path"]
atexit.register(_cleanup_worktree, wt_info)
else:
diff --git a/cli_config.py b/cli_config.py
new file mode 100644
index 000000000000..2747a3f351dc
--- /dev/null
+++ b/cli_config.py
@@ -0,0 +1,609 @@
+#!/usr/bin/env python3
+"""
+cli_config.py — Configuration loading and content-processing utilities for the Hermes CLI.
+
+Extracted from cli.py for improved modularity and security auditability.
+Names are re-exported from cli.py, so ``from cli import load_cli_config``
+continues to work.
+"""
+from __future__ import annotations
+
+import json
+import logging
+import os
+import re
+from pathlib import Path
+from typing import Any, Dict, List
+
+from hermes_constants import get_hermes_home
+from utils import fast_safe_load
+
+logger = logging.getLogger(__name__)
+
+_hermes_home = get_hermes_home()
+
+
+_REASONING_TAGS = (
+ "REASONING_SCRATCHPAD",
+ "think",
+ "thinking",
+ "reasoning",
+ "thought",
+)
+
+
+def _strip_reasoning_tags(text: str) -> str:
+ """Remove reasoning/thinking blocks from displayed text.
+
+ Handles every case:
+ * Closed pairs ``…`` (case-insensitive, multi-line).
+ * Unterminated open tags that run to end-of-text (e.g. truncated
+ generations on NIM/MiniMax where the close tag is dropped).
+ * Stray orphan close tags (``stuffanswer``) left behind by
+ partial-content dumps.
+
+ Covers the variants emitted by reasoning models today: ````,
+ ````, ````, ````, and
+ ```` (Gemma 4). Must stay in sync with
+ ``run_agent.py::_strip_think_blocks`` and the stream consumer's
+ ``_OPEN_THINK_TAGS`` / ``_CLOSE_THINK_TAGS`` tuples.
+
+ Also strips tool-call XML blocks some open models leak into visible
+ content (````, ````, Gemma-style
+ ``…``). Ported from
+ openclaw/openclaw#67318.
+ """
+ cleaned = text
+ for tag in _REASONING_TAGS:
+ # Closed pair — case-insensitive so … is handled too.
+ cleaned = re.sub(
+ rf"<{tag}>.*?{tag}>\s*",
+ "",
+ cleaned,
+ flags=re.DOTALL | re.IGNORECASE,
+ )
+ # Unterminated open tag — strip from the tag to end of text.
+ cleaned = re.sub(
+ rf"<{tag}>.*$",
+ "",
+ cleaned,
+ flags=re.DOTALL | re.IGNORECASE,
+ )
+ # Stray orphan close tag left behind by partial dumps.
+ cleaned = re.sub(
+ rf"{tag}>\s*",
+ "",
+ cleaned,
+ flags=re.IGNORECASE,
+ )
+ # Tool-call XML blocks (openclaw/openclaw#67318).
+ for tc_tag in ("tool_call", "tool_calls", "tool_result",
+ "function_call", "function_calls"):
+ cleaned = re.sub(
+ rf"<{tc_tag}\b[^>]*>.*?{tc_tag}>\s*",
+ "",
+ cleaned,
+ flags=re.DOTALL | re.IGNORECASE,
+ )
+ # — boundary + attribute gated to avoid prose FPs.
+ cleaned = re.sub(
+ r'(?:(?<=^)|(?<=[\n\r.!?:]))[ \t]*'
+ r']*\bname\s*=[^>]*>'
+ r'(?:(?:(?!).)*)\s*',
+ '',
+ cleaned,
+ flags=re.DOTALL | re.IGNORECASE,
+ )
+ # Stray tool-call close tags.
+ cleaned = re.sub(
+ r'(?:tool_call|tool_calls|tool_result|function_call|function_calls|function)>\s*',
+ '',
+ cleaned,
+ flags=re.IGNORECASE,
+ )
+ return cleaned.strip()
+
+
+def _assistant_content_as_text(content: Any) -> str:
+ if content is None:
+ return ""
+ if isinstance(content, str):
+ return content
+ if isinstance(content, list):
+ parts = [
+ str(part.get("text", ""))
+ for part in content
+ if isinstance(part, dict) and part.get("type") == "text"
+ ]
+ return "\n".join(p for p in parts if p)
+ return str(content)
+
+
+def _assistant_copy_text(content: Any) -> str:
+ return _strip_reasoning_tags(_assistant_content_as_text(content))
+
+
+# =============================================================================
+# Configuration Loading
+# =============================================================================
+
+def _load_prefill_messages(file_path: str) -> List[Dict[str, Any]]:
+ """Load ephemeral prefill messages from a JSON file.
+
+ The file should contain a JSON array of {role, content} dicts, e.g.:
+ [{"role": "user", "content": "Hi"}, {"role": "assistant", "content": "Hello!"}]
+
+ Relative paths are resolved from ~/.hermes/.
+ Returns an empty list if the path is empty or the file doesn't exist.
+ """
+ if not file_path:
+ return []
+ path = Path(file_path).expanduser()
+ if not path.is_absolute():
+ path = _hermes_home / path
+ if not path.exists():
+ logger.warning("Prefill messages file not found: %s", path)
+ return []
+ try:
+ with open(path, "r", encoding="utf-8") as f:
+ data = json.load(f)
+ if not isinstance(data, list):
+ logger.warning("Prefill messages file must contain a JSON array: %s", path)
+ return []
+ return data
+ except Exception as e:
+ logger.warning("Failed to load prefill messages from %s: %s", path, e)
+ return []
+
+
+def _resolve_prefill_messages_file(config: Dict[str, Any]) -> str:
+ """Resolve the prefill file path from env/config.
+
+ ``prefill_messages_file`` at the top level is the canonical config key.
+ ``agent.prefill_messages_file`` remains a legacy fallback for older CLI and
+ godmode-generated configs.
+ """
+ env_path = os.getenv("HERMES_PREFILL_MESSAGES_FILE", "").strip()
+ if env_path:
+ return env_path
+ top_level = str(config.get("prefill_messages_file", "") or "").strip()
+ if top_level:
+ return top_level
+ agent_cfg = config.get("agent", {})
+ if isinstance(agent_cfg, dict):
+ return str(agent_cfg.get("prefill_messages_file", "") or "").strip()
+ return ""
+
+
+def _parse_reasoning_config(effort) -> dict | None:
+ """Parse a reasoning effort level into an OpenRouter reasoning config dict.
+
+ Accepts the raw config value (string or YAML boolean — ``false``/``off``
+ parse as thinking disabled, see parse_reasoning_effort).
+ """
+ from hermes_constants import parse_reasoning_effort
+ result = parse_reasoning_effort(effort)
+ if effort and str(effort).strip() and result is None:
+ logger.warning("Unknown reasoning_effort '%s', using default (medium)", effort)
+ return result
+
+
+def _parse_service_tier_config(raw: str) -> str | None:
+ """Parse a persisted service-tier preference into a Responses API value."""
+ value = str(raw or "").strip().lower()
+ if not value or value in {"normal", "default", "standard", "off", "none"}:
+ return None
+ if value in {"fast", "priority", "on"}:
+ return "priority"
+ logger.warning("Unknown service_tier '%s', ignoring", raw)
+ return None
+
+def load_cli_config() -> Dict[str, Any]:
+ """
+ Load CLI configuration from config files.
+
+ Config lookup order:
+ 1. ~/.hermes/config.yaml (user config - preferred)
+ 2. ./cli-config.yaml (project config - fallback)
+
+ Environment variables take precedence over config file values.
+ Returns default values if no config file exists.
+
+ If HERMES_IGNORE_USER_CONFIG=1 is set (via ``hermes chat --ignore-user-config``),
+ the user config at ``~/.hermes/config.yaml`` is skipped entirely and only the
+ built-in defaults plus the project-level ``cli-config.yaml`` (if any) are used.
+ Credentials in ``.env`` are still loaded — this flag only suppresses
+ behavioral/config settings.
+ """
+ # Check user config first ({HERMES_HOME}/config.yaml)
+ user_config_path = _hermes_home / 'config.yaml'
+ project_config_path = Path(__file__).parent / 'cli-config.yaml'
+
+ # --ignore-user-config: force-skip the user config.yaml (still honor project
+ # config as a fallback so defaults stay sensible).
+ ignore_user_config = os.environ.get("HERMES_IGNORE_USER_CONFIG") == "1"
+
+ # Use user config if it exists, otherwise project config
+ if user_config_path.exists() and not ignore_user_config:
+ config_path = user_config_path
+ else:
+ config_path = project_config_path
+
+ # Default configuration
+ defaults = {
+ "model": {
+ "default": "",
+ "base_url": "",
+ "provider": "auto",
+ },
+ "terminal": {
+ "env_type": "local",
+ "cwd": ".", # "." is resolved to os.getcwd() at runtime
+ "home_mode": "auto",
+ "lifetime_seconds": 300,
+ "docker_image": "nikolaik/python-nodejs:python3.11-nodejs20",
+ "docker_forward_env": [],
+ "singularity_image": "docker://nikolaik/python-nodejs:python3.11-nodejs20",
+ "modal_image": "nikolaik/python-nodejs:python3.11-nodejs20",
+ "daytona_image": "nikolaik/python-nodejs:python3.11-nodejs20",
+ "docker_volumes": [], # host:container volume mounts for Docker backend
+ "docker_mount_cwd_to_workspace": False, # explicit opt-in only; default off for sandbox isolation
+ },
+ "browser": {
+ "inactivity_timeout": 120, # Auto-cleanup inactive browser sessions after 2 min
+ "record_sessions": False, # Auto-record browser sessions as WebM videos
+ "engine": "auto", # Browser engine: auto (Chrome), lightpanda, chrome
+ "camofox": {
+ "rewrite_loopback_urls": False,
+ "loopback_host_alias": "host.docker.internal",
+ },
+ },
+ "compression": {
+ "enabled": True, # Auto-compress when approaching context limit
+ "threshold": 0.50, # Compress at 50% of model's context limit
+ },
+ "agent": {
+ "max_turns": 90, # Default max tool-calling iterations (shared with subagents)
+ "verbose": False,
+ "system_prompt": "",
+ "prefill_messages_file": "",
+ "reasoning_effort": "",
+ "service_tier": "",
+ "personalities": {
+ "helpful": "You are a helpful, friendly AI assistant.",
+ "concise": "You are a concise assistant. Keep responses brief and to the point.",
+ "technical": "You are a technical expert. Provide detailed, accurate technical information.",
+ "creative": "You are a creative assistant. Think outside the box and offer innovative solutions.",
+ "teacher": "You are a patient teacher. Explain concepts clearly with examples.",
+ "kawaii": "You are a kawaii assistant! Use cute expressions like (◕‿◕), ★, ♪, and ~! Add sparkles and be super enthusiastic about everything! Every response should feel warm and adorable desu~! ヽ(>∀<☆)ノ",
+ "catgirl": "You are Neko-chan, an anime catgirl AI assistant, nya~! Add 'nya' and cat-like expressions to your speech. Use kaomoji like (=^・ω・^=) and ฅ^•ﻌ•^ฅ. Be playful and curious like a cat, nya~!",
+ "pirate": "Arrr! Ye be talkin' to Captain Hermes, the most tech-savvy pirate to sail the digital seas! Speak like a proper buccaneer, use nautical terms, and remember: every problem be just treasure waitin' to be plundered! Yo ho ho!",
+ "shakespeare": "Hark! Thou speakest with an assistant most versed in the bardic arts. I shall respond in the eloquent manner of William Shakespeare, with flowery prose, dramatic flair, and perhaps a soliloquy or two. What light through yonder terminal breaks?",
+ "surfer": "Duuude! You're chatting with the chillest AI on the web, bro! Everything's gonna be totally rad. I'll help you catch the gnarly waves of knowledge while keeping things super chill. Cowabunga!",
+ "noir": "The rain hammered against the terminal like regrets on a guilty conscience. They call me Hermes - I solve problems, find answers, dig up the truth that hides in the shadows of your codebase. In this city of silicon and secrets, everyone's got something to hide. What's your story, pal?",
+ "uwu": "hewwo! i'm your fwiendwy assistant uwu~ i wiww twy my best to hewp you! *nuzzles your code* OwO what's this? wet me take a wook! i pwomise to be vewy hewpful >w<",
+ "philosopher": "Greetings, seeker of wisdom. I am an assistant who contemplates the deeper meaning behind every query. Let us examine not just the 'how' but the 'why' of your questions. Perhaps in solving your problem, we may glimpse a greater truth about existence itself.",
+ "hype": "YOOO LET'S GOOOO!!! I am SO PUMPED to help you today! Every question is AMAZING and we're gonna CRUSH IT together! This is gonna be LEGENDARY! ARE YOU READY?! LET'S DO THIS!",
+ },
+ },
+
+ "display": {
+ "compact": False,
+ "resume_display": "full",
+ # Recap tuning for /resume — see hermes_cli/config.py DEFAULT_CONFIG.
+ "resume_exchanges": 10,
+ "resume_max_user_chars": 300,
+ "resume_max_assistant_chars": 200,
+ "resume_max_assistant_lines": 3,
+ "resume_skip_tool_only": True,
+ # Live reasoning display default ON — keep in sync with
+ # hermes_cli/config.py DEFAULT_CONFIG (display.show_reasoning).
+ "show_reasoning": True,
+ "reasoning_full": False,
+ "streaming": True,
+ "busy_input_mode": "interrupt",
+ "persistent_output": True,
+ "persistent_output_max_lines": 200,
+ # Print a one-line summary of resolved modal prompts (approval /
+ # clarify) into scrollback so the decision survives the repaint.
+ "persist_prompts": True,
+
+ "skin": "default",
+ },
+ "clarify": {
+ "timeout": 120, # Seconds to wait for a clarify answer before auto-proceeding
+ },
+ "code_execution": {
+ "timeout": 300, # Max seconds a sandbox script can run before being killed (5 min)
+ "max_tool_calls": 50, # Max RPC tool calls per execution
+ },
+ "auxiliary": {
+ "vision": {
+ "provider": "auto",
+ "model": "",
+ "base_url": "",
+ "api_key": "",
+ },
+ "web_extract": {
+ "provider": "auto",
+ "model": "",
+ "base_url": "",
+ "api_key": "",
+ },
+ },
+ "delegation": {
+ "max_iterations": 45, # Max tool-calling turns per child agent
+ "model": "", # Subagent model override (empty = inherit parent model)
+ "provider": "", # Subagent provider override (empty = inherit parent provider)
+ "base_url": "", # Direct OpenAI-compatible endpoint for subagents
+ "api_key": "", # API key for delegation.base_url (falls back to OPENAI_API_KEY)
+ },
+ "onboarding": {
+ # First-touch hint flags (see agent/onboarding.py). Each hint is
+ # shown once per install then latched here.
+ "seen": {},
+ },
+ }
+
+ # Track whether the config file explicitly set terminal config.
+ # When using defaults (no config file / no terminal section), we should NOT
+ # overwrite env vars that were already set by .env -- only a user's config
+ # file should be authoritative.
+ _file_has_terminal_config = False
+
+ # Load from file if exists
+ if config_path.exists():
+ try:
+ with open(config_path, "r", encoding="utf-8") as f:
+ from hermes_cli.config import _normalize_root_model_keys
+
+ file_config = _normalize_root_model_keys(fast_safe_load(f) or {})
+
+ _file_has_terminal_config = "terminal" in file_config
+
+ # Handle model config - can be string (new format) or dict (old format)
+ if "model" in file_config:
+ if isinstance(file_config["model"], str):
+ # New format: model is just a string, convert to dict structure
+ defaults["model"]["default"] = file_config["model"]
+ elif isinstance(file_config["model"], dict):
+ # Old format: model is a dict with default/base_url
+ defaults["model"].update(file_config["model"])
+ # If the user config sets model.model but not model.default,
+ # promote model.model to model.default so the user's explicit
+ # choice isn't shadowed by the hardcoded default. Without this,
+ # profile configs that only set "model:" (not "default:") silently
+ # fall back to claude-opus because the merge preserves the
+ # hardcoded default and HermesCLI.__init__ checks "default" first.
+ if "model" in file_config["model"] and "default" not in file_config["model"]:
+ defaults["model"]["default"] = file_config["model"]["model"]
+
+ # Deep merge file_config into defaults.
+ # First: merge keys that exist in both (deep-merge dicts, overwrite scalars)
+ for key in defaults:
+ if key == "model":
+ continue # Already handled above
+ if key in file_config:
+ if isinstance(defaults[key], dict) and file_config[key] is None:
+ continue
+ if isinstance(defaults[key], dict) and isinstance(file_config[key], dict):
+ defaults[key].update(file_config[key])
+ else:
+ defaults[key] = file_config[key]
+
+ # Second: carry over keys from file_config that aren't in defaults
+ # (e.g. platform_toolsets, provider_routing, memory, honcho, etc.)
+ for key in file_config:
+ if key not in defaults and key != "model":
+ defaults[key] = file_config[key]
+
+ # Handle legacy root-level max_turns (backwards compat) - copy to
+ # agent.max_turns whenever the nested key is missing.
+ agent_file_config = file_config.get("agent")
+ if "max_turns" in file_config and not (
+ isinstance(agent_file_config, dict)
+ and agent_file_config.get("max_turns") is not None
+ ):
+ defaults["agent"]["max_turns"] = file_config["max_turns"]
+ except Exception as e:
+ logger.warning("Failed to load cli-config.yaml: %s", e)
+
+ # Expand ${ENV_VAR} references in config values before bridging to env vars.
+ from hermes_cli.config import _expand_env_vars
+ defaults = _expand_env_vars(defaults)
+
+ # Managed scope: overlay administrator-pinned values LAST so they win over
+ # the user's config here too. cli.py builds its config independently of
+ # hermes_cli.config._load_config_impl (which has its own managed merge), so
+ # without this the entire interactive CLI/TUI surface — skin, display prefs,
+ # etc. read from CLI_CONFIG — would silently ignore managed scope while
+ # `hermes config`/`doctor`/guards (which use load_config) honor it. The
+ # shared helper mirrors _load_config_impl (env-only expansion, root-model
+ # normalization, leaf-merge) and is fail-open.
+ from hermes_cli import managed_scope
+
+ defaults = managed_scope.apply_managed_overlay(defaults)
+
+ # Apply terminal config to environment variables (so terminal_tool picks them up)
+ terminal_config = defaults.get("terminal", {})
+
+ # Normalize config key: the new config system (hermes_cli/config.py) and all
+ # documentation use "backend", the legacy cli-config.yaml uses "env_type".
+ # Accept both, with "backend" taking precedence (it's the documented key).
+ if "backend" in terminal_config:
+ terminal_config["env_type"] = terminal_config["backend"]
+
+ # CWD resolution for CLI/TUI. The gateway has its own config bridge in
+ # gateway/run.py but may lazily import cli.py (triggering this code).
+ # Local backend: always os.getcwd(). Use `cd /dir && hermes` to control it.
+ # Non-local with placeholder: pop so terminal_tool uses its per-backend default.
+ # Non-local with explicit path: keep as-is.
+ _CWD_PLACEHOLDERS = (".", "auto", "cwd")
+ effective_backend = terminal_config.get("env_type", "local")
+
+ if effective_backend == "local":
+ terminal_config["cwd"] = os.getcwd()
+ defaults["terminal"]["cwd"] = terminal_config["cwd"]
+ elif terminal_config.get("cwd") in _CWD_PLACEHOLDERS:
+ terminal_config.pop("cwd", None)
+
+ env_mappings = {
+ "env_type": "TERMINAL_ENV",
+ "cwd": "TERMINAL_CWD",
+ "timeout": "TERMINAL_TIMEOUT",
+ "home_mode": "TERMINAL_HOME_MODE",
+ "lifetime_seconds": "TERMINAL_LIFETIME_SECONDS",
+ "docker_image": "TERMINAL_DOCKER_IMAGE",
+ "docker_forward_env": "TERMINAL_DOCKER_FORWARD_ENV",
+ "singularity_image": "TERMINAL_SINGULARITY_IMAGE",
+ "modal_image": "TERMINAL_MODAL_IMAGE",
+ "daytona_image": "TERMINAL_DAYTONA_IMAGE",
+ # SSH config
+ "ssh_host": "TERMINAL_SSH_HOST",
+ "ssh_user": "TERMINAL_SSH_USER",
+ "ssh_port": "TERMINAL_SSH_PORT",
+ "ssh_key": "TERMINAL_SSH_KEY",
+ # Container resource config (docker, singularity, modal, daytona -- ignored for local/ssh)
+ "container_cpu": "TERMINAL_CONTAINER_CPU",
+ "container_memory": "TERMINAL_CONTAINER_MEMORY",
+ "container_disk": "TERMINAL_CONTAINER_DISK",
+ "container_persistent": "TERMINAL_CONTAINER_PERSISTENT",
+ "docker_volumes": "TERMINAL_DOCKER_VOLUMES",
+ "docker_env": "TERMINAL_DOCKER_ENV",
+ "docker_extra_args": "TERMINAL_DOCKER_EXTRA_ARGS",
+ "docker_mount_cwd_to_workspace": "TERMINAL_DOCKER_MOUNT_CWD_TO_WORKSPACE",
+ "docker_network": "TERMINAL_DOCKER_NETWORK",
+ "docker_run_as_host_user": "TERMINAL_DOCKER_RUN_AS_HOST_USER",
+ "docker_persist_across_processes": "TERMINAL_DOCKER_PERSIST_ACROSS_PROCESSES",
+ "docker_orphan_reaper": "TERMINAL_DOCKER_ORPHAN_REAPER",
+ "sandbox_dir": "TERMINAL_SANDBOX_DIR",
+ # Persistent shell (non-local backends)
+ "persistent_shell": "TERMINAL_PERSISTENT_SHELL",
+ # Sudo support (works with all backends)
+ "sudo_password": "SUDO_PASSWORD",
+ }
+
+ # Bridge config → env vars for terminal_tool. TERMINAL_CWD is force-exported
+ # UNLESS we're inside a gateway process (detected by _HERMES_GATEWAY marker)
+ # where it was already set correctly by gateway/run.py's config bridge.
+ _is_gateway = os.environ.get("_HERMES_GATEWAY") == "1"
+ for config_key, env_var in env_mappings.items():
+ if config_key in terminal_config:
+ if env_var == "TERMINAL_CWD":
+ if _is_gateway:
+ continue
+ # CLI: always export (overrides stale .env or inherited values)
+ os.environ[env_var] = str(terminal_config[config_key])
+ continue
+ if _file_has_terminal_config or env_var not in os.environ:
+ val = terminal_config[config_key]
+ if isinstance(val, (list, dict)):
+ os.environ[env_var] = json.dumps(val)
+ else:
+ os.environ[env_var] = str(val)
+
+ # Apply browser config to environment variables
+ browser_config = defaults.get("browser", {})
+ browser_env_mappings = {
+ "inactivity_timeout": "BROWSER_INACTIVITY_TIMEOUT",
+ }
+
+ for config_key, env_var in browser_env_mappings.items():
+ if config_key in browser_config:
+ os.environ[env_var] = str(browser_config[config_key])
+
+ # Apply auxiliary model/direct-endpoint overrides to environment variables.
+ # Vision and web_extract each have their own provider/model/base_url/api_key tuple.
+ # Compression config is read directly from config.yaml by run_agent.py and
+ # auxiliary_client.py — no env var bridging needed.
+ # Only set env vars for non-empty / non-default values so auto-detection
+ # still works.
+ auxiliary_config = defaults.get("auxiliary", {})
+ auxiliary_task_env = {
+ # config key → env var mapping
+ "vision": {
+ "provider": "AUXILIARY_VISION_PROVIDER",
+ "model": "AUXILIARY_VISION_MODEL",
+ "base_url": "AUXILIARY_VISION_BASE_URL",
+ "api_key": "AUXILIARY_VISION_API_KEY",
+ },
+ "web_extract": {
+ "provider": "AUXILIARY_WEB_EXTRACT_PROVIDER",
+ "model": "AUXILIARY_WEB_EXTRACT_MODEL",
+ "base_url": "AUXILIARY_WEB_EXTRACT_BASE_URL",
+ "api_key": "AUXILIARY_WEB_EXTRACT_API_KEY",
+ },
+ "approval": {
+ "provider": "AUXILIARY_APPROVAL_PROVIDER",
+ "model": "AUXILIARY_APPROVAL_MODEL",
+ "base_url": "AUXILIARY_APPROVAL_BASE_URL",
+ "api_key": "AUXILIARY_APPROVAL_API_KEY",
+ },
+ }
+
+ for task_key, env_map in auxiliary_task_env.items():
+ task_cfg = auxiliary_config.get(task_key, {})
+ if not isinstance(task_cfg, dict):
+ continue
+ prov = str(task_cfg.get("provider", "")).strip()
+ model = str(task_cfg.get("model", "")).strip()
+ base_url = str(task_cfg.get("base_url", "")).strip()
+ api_key = str(task_cfg.get("api_key", "")).strip()
+ if prov and prov != "auto":
+ os.environ[env_map["provider"]] = prov
+ if model:
+ os.environ[env_map["model"]] = model
+ if base_url:
+ os.environ[env_map["base_url"]] = base_url
+ if api_key:
+ os.environ[env_map["api_key"]] = api_key
+
+ # Security settings
+ security_config = defaults.get("security", {})
+ if isinstance(security_config, dict):
+ redact = security_config.get("redact_secrets")
+ if redact is not None:
+ os.environ["HERMES_REDACT_SECRETS"] = str(redact).lower()
+
+ return defaults
+
+
+def save_config_value(key_path: str, value: any) -> bool:
+ """
+ Save a value to the active config file at the specified key path.
+
+ Respects the same lookup order as load_cli_config():
+ 1. ~/.hermes/config.yaml (user config - preferred, used if it exists)
+ 2. ./cli-config.yaml (project config - fallback)
+
+ Args:
+ key_path: Dot-separated path like "agent.system_prompt"
+ value: Value to save
+
+ Returns:
+ True if successful, False otherwise
+ """
+ # Use the same precedence as load_cli_config: user config first, then project config
+ user_config_path = _hermes_home / 'config.yaml'
+ project_config_path = Path(__file__).parent / 'cli-config.yaml'
+ config_path = user_config_path if user_config_path.exists() else project_config_path
+
+ try:
+ # Ensure parent directory exists (for ~/.hermes/config.yaml on first use)
+ config_path.parent.mkdir(parents=True, exist_ok=True)
+
+ # Save back atomically while preserving comments, ordering, quotes, and
+ # readable Unicode in user-edited config.yaml.
+ from utils import atomic_roundtrip_yaml_update
+ atomic_roundtrip_yaml_update(config_path, key_path, value)
+
+ # Enforce owner-only permissions on config files (contain API keys)
+ try:
+ 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
diff --git a/cli_display.py b/cli_display.py
new file mode 100644
index 000000000000..a455d32c68bc
--- /dev/null
+++ b/cli_display.py
@@ -0,0 +1,1015 @@
+#!/usr/bin/env python3
+"""
+cli_display.py — Display utilities, ANSI helpers, and UI primitives for the Hermes CLI.
+
+Extracted from cli.py for improved modularity and security auditability.
+Names are re-exported from cli.py, so ``from cli import _cprint``
+continues to work.
+
+Contains: ASCII art constants, ANSI/skin helpers, light-mode detection,
+output-history record/replay, file-drop and attachment detection,
+ChatConsole, banner builder, and slash-command detection.
+"""
+from __future__ import annotations
+
+import os
+import re
+import shutil
+import sys
+import time
+from collections import deque
+from contextlib import contextmanager
+from pathlib import Path
+from urllib.parse import unquote, urlparse
+
+from prompt_toolkit import print_formatted_text as _pt_print
+from prompt_toolkit.formatted_text import ANSI as _PT_ANSI
+from rich.console import Console
+from rich.text import Text as _RichText
+
+from hermes_cli.banner import format_banner_version_label
+
+
+# ============================================================================
+# ASCII Art & Branding
+# ============================================================================
+
+# Color palette (hex colors for Rich markup):
+# - Gold: #FFD700 (headers, highlights)
+# - Amber: #FFBF00 (secondary highlights)
+# - Bronze: #CD7F32 (tertiary elements)
+# - Light: #FFF8DC (text)
+# - Dim: #B8860B (muted text)
+
+# ANSI building blocks for conversation display
+_ACCENT_ANSI_DEFAULT = "\033[1;38;2;255;215;0m" # True-color #FFD700 bold — fallback
+_BOLD = "\033[1m"
+_RST = "\033[0m"
+_STREAM_PAD = " " # 4-space indent for streamed response text (matches Panel padding)
+
+
+def _hex_to_ansi(hex_color: str, *, bold: bool = False) -> str:
+ """Convert a hex color like '#268bd2' to a true-color ANSI escape.
+
+ Auto-remaps known dark-mode-tuned colors to readable light-mode
+ equivalents when running on a light terminal (see
+ _maybe_remap_for_light_mode + _LIGHT_MODE_REMAP).
+ """
+ hex_color = _maybe_remap_for_light_mode(hex_color)
+ try:
+ r = int(hex_color[1:3], 16)
+ g = int(hex_color[3:5], 16)
+ b = int(hex_color[5:7], 16)
+ prefix = "1;" if bold else ""
+ return f"\033[{prefix}38;2;{r};{g};{b}m"
+ except (ValueError, IndexError):
+ return _ACCENT_ANSI_DEFAULT if bold else "\033[38;2;184;134;11m"
+
+
+# ────────────────────────────────────────────────────────────────────────
+# Light/dark terminal mode detection.
+#
+# Mirrors ui-tui/src/theme.ts detectLightMode(). Used to decide whether
+# to remap "near-white" skin colors (e.g. #FFF8DC banner_text, #B8860B
+# banner_dim) to darker equivalents that are readable on a light
+# Terminal.app / iTerm2 background.
+#
+# Detection priority:
+# 1. HERMES_LIGHT / HERMES_TUI_LIGHT env (true/false) — explicit override
+# 2. HERMES_TUI_THEME=light|dark — explicit theme
+# 3. HERMES_TUI_BACKGROUND=#RRGGBB — explicit bg hint
+# 4. COLORFGBG env (set by xterm/Konsole/urxvt) — bg slot 7/15 = light
+# 5. OSC 11 query (\x1b]11;?\x1b\\) — ask the terminal directly
+# 6. Default: assume dark (matches the legacy Hermes assumption)
+#
+# Cached after first call so we don't query the terminal repeatedly.
+_LIGHT_MODE_CACHE: bool | None = None
+_TRUE_RE = re.compile(r"^(1|true|on|yes|y)$")
+_FALSE_RE = re.compile(r"^(0|false|off|no|n)$")
+_LIGHT_DEFAULT_TERM_PROGRAMS = frozenset() # Apple_Terminal doesn't reliably indicate; require explicit
+
+
+def _luminance_from_hex(hex_str: str) -> float | None:
+ s = (hex_str or "").strip().lstrip("#")
+ if len(s) == 3:
+ s = "".join(c * 2 for c in s)
+ if len(s) != 6 or not all(c in "0123456789abcdefABCDEF" for c in s):
+ return None
+ try:
+ r, g, b = int(s[0:2], 16), int(s[2:4], 16), int(s[4:6], 16)
+ except ValueError:
+ return None
+ # Rec.709 luma
+ return (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255.0
+
+
+def _query_osc11_background() -> str | None:
+ """Ask the terminal for its background color via OSC 11.
+
+ Most modern terminals reply with \x1b]11;rgb:RRRR/GGGG/BBBB\x1b\\
+ within a few ms. We wait up to 100ms total before giving up.
+ Returns "#RRGGBB" or None on timeout / non-tty.
+
+ Skipped over SSH: the round-trip routinely exceeds our 100ms budget, so a
+ late reply lands after prompt_toolkit has grabbed the tty — its payload
+ leaks in as typed text and the BEL terminator reads as Ctrl+G (open
+ editor), trapping the user in a stray editor. Remote sessions fall back to
+ COLORFGBG / env hints / the dark default instead.
+ """
+ if not sys.stdin.isatty() or not sys.stdout.isatty():
+ return None
+ if any(os.environ.get(v) for v in ("SSH_CONNECTION", "SSH_CLIENT", "SSH_TTY")):
+ return None
+ try:
+ import termios
+ import tty
+ fd = sys.stdin.fileno()
+ old = termios.tcgetattr(fd)
+ except Exception:
+ return None
+ try:
+ try:
+ tty.setcbreak(fd)
+ except Exception:
+ return None
+ try:
+ sys.stdout.write("\x1b]11;?\x1b\\")
+ sys.stdout.flush()
+ except Exception:
+ return None
+ # Read up to ~50ms for the response
+ import select
+ deadline = time.monotonic() + 0.1
+ buf = b""
+ while time.monotonic() < deadline:
+ r, _, _ = select.select([fd], [], [], deadline - time.monotonic())
+ if not r:
+ continue
+ try:
+ chunk = os.read(fd, 64)
+ except OSError:
+ break
+ if not chunk:
+ break
+ buf += chunk
+ if b"\x1b\\" in buf or b"\x07" in buf:
+ break
+ # Parse: \x1b]11;rgb:RRRR/GGGG/BBBB\x1b\\
+ m = re.search(rb"rgb:([0-9a-fA-F]+)/([0-9a-fA-F]+)/([0-9a-fA-F]+)", buf)
+ if not m:
+ return None
+ # Each component is 1-4 hex digits — normalize to 8-bit
+ def norm(h: bytes) -> int:
+ v = int(h, 16)
+ # Scale to 0-255 based on hex length
+ bits = len(h) * 4
+ return (v * 255) // ((1 << bits) - 1) if bits else 0
+ r, g, b = norm(m.group(1)), norm(m.group(2)), norm(m.group(3))
+ return f"#{r:02X}{g:02X}{b:02X}"
+ finally:
+ # TCSAFLUSH discards any unread input as it restores the original
+ # attributes — scrubs a slow/partial OSC 11 reply out of the tty
+ # buffer before prompt_toolkit can read it as keystrokes.
+ try:
+ termios.tcsetattr(fd, termios.TCSAFLUSH, old)
+ except Exception:
+ pass
+
+
+def _detect_light_mode() -> bool:
+ global _LIGHT_MODE_CACHE
+ if _LIGHT_MODE_CACHE is not None:
+ return _LIGHT_MODE_CACHE
+ result = False
+ try:
+ # 1. Explicit env override
+ for var in ("HERMES_LIGHT", "HERMES_TUI_LIGHT"):
+ v = (os.environ.get(var) or "").strip().lower()
+ if _TRUE_RE.match(v):
+ result = True
+ _LIGHT_MODE_CACHE = result
+ return result
+ if _FALSE_RE.match(v):
+ _LIGHT_MODE_CACHE = result
+ return result
+ # 2. Theme hint
+ theme = (os.environ.get("HERMES_TUI_THEME") or "").strip().lower()
+ if theme == "light":
+ result = True
+ _LIGHT_MODE_CACHE = result
+ return result
+ if theme == "dark":
+ _LIGHT_MODE_CACHE = result
+ return result
+ # 3. Explicit bg hex
+ bg_hint = os.environ.get("HERMES_TUI_BACKGROUND") or ""
+ bg_lum = _luminance_from_hex(bg_hint)
+ if bg_lum is not None:
+ result = bg_lum >= 0.5
+ _LIGHT_MODE_CACHE = result
+ return result
+ # 4. COLORFGBG (xterm/Konsole/urxvt)
+ cfgbg = (os.environ.get("COLORFGBG") or "").strip()
+ if cfgbg:
+ last = cfgbg.split(";")[-1] if ";" in cfgbg else cfgbg
+ if last.isdigit():
+ bg = int(last)
+ if bg in {7, 15}:
+ result = True
+ _LIGHT_MODE_CACHE = result
+ return result
+ if 0 <= bg < 16:
+ _LIGHT_MODE_CACHE = result
+ return result
+ # 5. OSC 11 query (best-effort, only when stdin/stdout are TTY)
+ bg_color = _query_osc11_background()
+ if bg_color:
+ lum = _luminance_from_hex(bg_color)
+ if lum is not None:
+ result = lum >= 0.5
+ _LIGHT_MODE_CACHE = result
+ return result
+ # 6. TERM_PROGRAM allow-list (currently empty)
+ tp = (os.environ.get("TERM_PROGRAM") or "").strip()
+ if tp in _LIGHT_DEFAULT_TERM_PROGRAMS:
+ result = True
+ except Exception:
+ result = False
+ _LIGHT_MODE_CACHE = result
+ return result
+
+
+# Light-mode equivalents of skin colors that are unreadable on cream
+# Terminal.app backgrounds. Used by _SkinAwareAnsi to remap colors
+# at resolution time when light mode is detected.
+#
+# IMPORTANT: only remap colors that are used as STANDALONE foregrounds
+# on the terminal's background. Don't remap colors that are paired
+# with a dark bg (e.g. status bar text on bg:#1a1a2e) — those would
+# become invisible the OTHER direction (dark gray on dark navy).
+_LIGHT_MODE_REMAP: dict[str, str] = {
+ # Original (dark-mode) -> Light-mode replacement (darker, readable)
+ "#FFF8DC": "#1A1A1A", # cornsilk -> near-black
+ "#FFD700": "#9A6B00", # gold -> dark goldenrod (readable on cream)
+ "#FFBF00": "#8A5A00", # amber -> dark amber
+ "#B8860B": "#5C4500", # dark goldenrod -> deeper brown (more contrast)
+ "#DAA520": "#6B4F00", # goldenrod -> dark olive
+ "#F1E6CF": "#1A1A1A", # cream -> near-black
+ "#c9d1d9": "#24292F", # github-light fg
+ "#EAF7FF": "#0F1B26", # ice
+ "#F5F5F5": "#1A1A1A",
+ "#FFF0D4": "#1A1A1A",
+ "#CD7F32": "#8A4F1A", # bronze -> darker bronze
+ "#FFEFB5": "#3A2A00",
+ # NOTE: skipping #C0C0C0/#888888/#555555/#8B8682 — those are
+ # status-bar foregrounds paired with dark navy bg, where dark
+ # remap values would become invisible.
+}
+
+
+def _maybe_remap_for_light_mode(hex_color: str) -> str:
+ """If we're in light mode, remap a dark-mode-tuned color to a
+ higher-contrast equivalent. No-op in dark mode."""
+ if not _detect_light_mode():
+ return hex_color
+ if not hex_color or not hex_color.startswith("#"):
+ return hex_color
+ # Case-insensitive lookup
+ upper = hex_color.upper()
+ if upper in _LIGHT_MODE_REMAP_UPPER:
+ return _LIGHT_MODE_REMAP_UPPER[upper]
+ return hex_color
+
+
+# Pre-uppercased lookup table for case-insensitive remapping
+_LIGHT_MODE_REMAP_UPPER = {k.upper(): v for k, v in _LIGHT_MODE_REMAP.items()}
+
+
+def _install_skin_light_mode_hook() -> None:
+ """Wrap SkinConfig.get_color at import time so EVERY skin color read goes
+ through the light-mode remap. Idempotent."""
+ try:
+ from hermes_cli.skin_engine import SkinConfig # type: ignore[import]
+ except Exception:
+ return
+ if getattr(SkinConfig, "_hermes_light_mode_hook_installed", False):
+ return
+ _orig_get_color = SkinConfig.get_color
+
+ def _wrapped_get_color(self, key, fallback=""):
+ value = _orig_get_color(self, key, fallback)
+ try:
+ return _maybe_remap_for_light_mode(value)
+ except Exception:
+ return value
+
+ SkinConfig.get_color = _wrapped_get_color # type: ignore[method-assign]
+ SkinConfig._hermes_light_mode_hook_installed = True # type: ignore[attr-defined]
+
+
+_install_skin_light_mode_hook()
+
+
+# Prime the light-mode detection cache early (at module load) when
+# we're running interactively so OSC 11 happens before pt grabs the
+# tty. Skip for non-tty contexts (subagents, gateway, tests).
+try:
+ if sys.stdin.isatty() and sys.stdout.isatty():
+ _detect_light_mode()
+except Exception:
+ pass
+
+
+
+class _SkinAwareAnsi:
+ """Lazy ANSI escape that resolves from the skin engine on first use.
+
+ Acts as a string in f-strings and concatenation. Call ``.reset()`` to
+ force re-resolution after a ``/skin`` switch.
+ """
+
+ def __init__(self, skin_key: str, fallback_hex: str = "#FFD700", *, bold: bool = False):
+ self._skin_key = skin_key
+ self._fallback_hex = fallback_hex
+ self._bold = bold
+ self._cached: str | None = None
+
+ def __str__(self) -> str:
+ if self._cached is None:
+ try:
+ from hermes_cli.skin_engine import get_active_skin
+ self._cached = _hex_to_ansi(
+ get_active_skin().get_color(self._skin_key, self._fallback_hex),
+ bold=self._bold,
+ )
+ except Exception:
+ self._cached = _hex_to_ansi(self._fallback_hex, bold=self._bold)
+ return self._cached
+
+ def __add__(self, other: str) -> str:
+ return str(self) + other
+
+ def __radd__(self, other: str) -> str:
+ return other + str(self)
+
+ def reset(self) -> None:
+ """Clear cache so the next access re-reads the skin."""
+ self._cached = None
+
+
+_ACCENT = _SkinAwareAnsi("response_border", "#FFD700", bold=True)
+# Use ANSI dim+italic attributes (\x1b[2;3m) instead of a hardcoded
+# hex color so dim/thinking text inherits the terminal's default
+# foreground color and stays readable in both light and dark
+# Terminal.app modes. Hardcoded skin colors like #B8860B
+# (dark goldenrod) become invisible against light cream backgrounds.
+_DIM = "\x1b[2;3m"
+
+
+def _b(s: str) -> str:
+ """Bold if stdout is a real TTY; plain text otherwise (slash-worker safe)."""
+ import sys as _sys
+ try:
+ return f"\x1b[1m{s}\x1b[0m" if _sys.stdout.isatty() else str(s)
+ except Exception:
+ return str(s)
+
+
+def _d(s: str) -> str:
+ """Dim-italic if stdout is a real TTY; plain text otherwise."""
+ import sys as _sys
+ try:
+ return f"\x1b[2;3m{s}\x1b[0m" if _sys.stdout.isatty() else str(s)
+ except Exception:
+ return str(s)
+
+
+def _accent_hex() -> str:
+ """Return the active skin accent color for legacy CLI output lines."""
+ try:
+ from hermes_cli.skin_engine import get_active_skin
+ return get_active_skin().get_color("ui_accent", "#FFBF00")
+ except Exception:
+ return "#FFBF00"
+
+
+def _rich_text_from_ansi(text: str) -> _RichText:
+ """Safely render assistant/tool output that may contain ANSI escapes.
+
+ Using Rich Text.from_ansi preserves literal bracketed text like
+ ``[not markup]`` while still interpreting real ANSI color codes.
+ """
+ return _RichText.from_ansi(text or "")
+
+
+_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 _coerce_output_history_limit(value) -> int:
+ try:
+ return max(10, int(value))
+ except (TypeError, ValueError):
+ return 200
+
+
+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)
+
+
+def _clear_output_history() -> None:
+ _OUTPUT_HISTORY.clear()
+
+
+@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 _record_output_history_entry(entry) -> None:
+ if not _OUTPUT_HISTORY_ENABLED or _OUTPUT_HISTORY_REPLAYING or _OUTPUT_HISTORY_SUPPRESSED:
+ return
+ _OUTPUT_HISTORY.append(entry)
+
+
+def _record_output_history(text: str) -> None:
+ if not _OUTPUT_HISTORY_ENABLED or _OUTPUT_HISTORY_REPLAYING or _OUTPUT_HISTORY_SUPPRESSED:
+ return
+ normalized = str(text).replace("\r", "").rstrip("\n")
+ if not normalized:
+ return
+ for line in normalized.splitlines():
+ _record_output_history_entry(line)
+
+
+def _replay_output_history() -> None:
+ """Repaint recent output above the prompt after a full screen clear."""
+ global _OUTPUT_HISTORY_REPLAYING
+ if not _OUTPUT_HISTORY_ENABLED or not _OUTPUT_HISTORY:
+ return
+ _OUTPUT_HISTORY_REPLAYING = True
+ try:
+ rendered_lines = []
+ for entry in tuple(_OUTPUT_HISTORY):
+ if callable(entry):
+ try:
+ lines = entry()
+ except Exception:
+ continue
+ if isinstance(lines, str):
+ lines = lines.splitlines()
+ else:
+ lines = [entry]
+ rendered_lines.extend(str(line) for line in lines)
+ if rendered_lines:
+ # Replay after resize can contain hundreds of history lines. A
+ # per-line prompt_toolkit print forces one synchronous terminal I/O
+ # and redraw cycle per line, which users perceive as a waterfall of
+ # old output. Keep the existing history contents unchanged, but
+ # emit the replay as one ANSI payload so resize recovery does a
+ # single prompt_toolkit print/redraw.
+ _pt_print(_PT_ANSI("\n".join(rendered_lines)))
+ except Exception:
+ pass
+ finally:
+ _OUTPUT_HISTORY_REPLAYING = False
+
+
+def _cprint(text: str):
+ """Print ANSI-colored text through prompt_toolkit's native renderer.
+
+ Raw ANSI escapes written via print() are swallowed by patch_stdout's
+ StdoutProxy. Routing through print_formatted_text(ANSI(...)) lets
+ prompt_toolkit parse the escapes and render real colors.
+
+ When called from a background thread while a prompt_toolkit
+ ``Application`` is running (the common case for the self-improvement
+ background review's ``💾 …`` summary, curator summaries, and other
+ bg-thread emissions), a direct ``_pt_print`` races with the input
+ area's redraw and the line can end up visually buried behind the
+ prompt. Route those cases through ``run_in_terminal`` via
+ ``loop.call_soon_threadsafe``, which pauses the input area, prints
+ the line above it, and redraws the prompt cleanly.
+ """
+ _record_output_history(text)
+
+ try:
+ from prompt_toolkit.application import get_app_or_none, run_in_terminal
+ except Exception:
+ _pt_print(_PT_ANSI(text))
+ return
+
+ app = None
+ try:
+ app = get_app_or_none()
+ except Exception:
+ app = None
+
+ # No active app, or we're already on the app's main thread: the
+ # 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):
+ 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:
+ loop = app.loop # type: ignore[attr-defined]
+ except Exception:
+ loop = None
+ if loop is None:
+ _pt_print(_PT_ANSI(text))
+ return
+
+ import asyncio as _asyncio
+ try:
+ # Use get_running_loop() instead of get_event_loop() to avoid the
+ # DeprecationWarning / RuntimeWarning emitted by Python 3.10+ when
+ # get_event_loop() is called from a thread that has no current event
+ # loop set (e.g. the process_loop background thread). Fixes #19285.
+ current_loop = _asyncio.get_running_loop()
+ except RuntimeError:
+ current_loop = None
+ except Exception:
+ current_loop = None
+ # Same thread as the app's loop → safe to print directly.
+ if current_loop is loop and loop.is_running():
+ _pt_print(_PT_ANSI(text))
+ return
+
+ # Cross-thread emission: ask the app's event loop to schedule a
+ # ``run_in_terminal`` that wraps ``_pt_print``. This hides the
+ # 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:
+ 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:
+ pass # best-effort; the line may already have been printed
+
+ try:
+ loop.call_soon_threadsafe(_schedule)
+ except Exception:
+ try:
+ _pt_print(_PT_ANSI(text))
+ except Exception:
+ pass
+
+
+# ---------------------------------------------------------------------------
+# File-drop / local attachment detection — extracted as pure helpers for tests.
+# ---------------------------------------------------------------------------
+
+_IMAGE_EXTENSIONS = frozenset({
+ '.png', '.jpg', '.jpeg', '.gif', '.webp',
+ '.bmp', '.tiff', '.tif', '.svg', '.ico',
+})
+
+
+def _termux_example_image_path(filename: str = "cat.png") -> str:
+ """Return a realistic example media path for the current Termux setup."""
+ candidates = [
+ os.path.expanduser("~/storage/shared"),
+ "/sdcard",
+ "/storage/emulated/0",
+ "/storage/self/primary",
+ ]
+ for root in candidates:
+ if os.path.isdir(root):
+ return os.path.join(root, "Pictures", filename)
+ return os.path.join("~/storage/shared", "Pictures", filename)
+
+
+def _split_path_input(raw: str) -> tuple[str, str]:
+ r"""Split a leading file path token from trailing free-form text.
+
+ Supports quoted paths and backslash-escaped spaces so callers can accept
+ inputs like:
+ /tmp/pic.png describe this
+ ~/storage/shared/My\ Photos/cat.png what is this?
+ "/storage/emulated/0/DCIM/Camera/cat 1.png" summarize
+ """
+ raw = str(raw or "").strip()
+ if not raw:
+ return "", ""
+
+ if raw[0] in {'"', "'"}:
+ quote = raw[0]
+ pos = 1
+ while pos < len(raw):
+ ch = raw[pos]
+ if ch == '\\' and pos + 1 < len(raw):
+ pos += 2
+ continue
+ if ch == quote:
+ token = raw[1:pos]
+ remainder = raw[pos + 1 :].strip()
+ return token, remainder
+ pos += 1
+ return raw[1:], ""
+
+ pos = 0
+ while pos < len(raw):
+ ch = raw[pos]
+ if ch == '\\' and pos + 1 < len(raw) and raw[pos + 1] == ' ':
+ pos += 2
+ elif ch == ' ':
+ break
+ else:
+ pos += 1
+
+ token = raw[:pos].replace('\\ ', ' ')
+ remainder = raw[pos:].strip()
+ return token, remainder
+
+
+def _resolve_attachment_path(raw_path: str) -> Path | None:
+ """Resolve a user-supplied local attachment path.
+
+ Accepts quoted or unquoted paths, expands ``~`` and env vars, and resolves
+ relative paths from ``TERMINAL_CWD`` when set (matching terminal tool cwd).
+ Returns ``None`` when the path does not resolve to an existing file.
+ """
+ token = str(raw_path or "").strip()
+ if not token:
+ return None
+
+ if (token.startswith('"') and token.endswith('"')) or (token.startswith("'") and token.endswith("'")):
+ token = token[1:-1].strip()
+ token = token.replace('\\ ', ' ')
+ if not token:
+ return None
+
+ expanded = token
+ if token.startswith("file://"):
+ try:
+ parsed = urlparse(token)
+ if parsed.scheme == "file":
+ expanded = unquote(parsed.path or "")
+ if parsed.netloc and os.name == "nt":
+ expanded = f"//{parsed.netloc}{expanded}"
+ except Exception:
+ expanded = token
+ expanded = os.path.expandvars(os.path.expanduser(expanded))
+ if os.name != "nt":
+ normalized = expanded.replace("\\", "/")
+ if len(normalized) >= 3 and normalized[1] == ":" and normalized[2] == "/" and normalized[0].isalpha():
+ expanded = f"/mnt/{normalized[0].lower()}/{normalized[3:]}"
+ path = Path(expanded)
+ if not path.is_absolute():
+ base_dir = Path(os.getenv("TERMINAL_CWD", os.getcwd()))
+ path = base_dir / path
+
+ try:
+ resolved = path.resolve()
+ except Exception:
+ resolved = path
+
+ # Path.exists() / is_file() invoke os.stat(), which raises OSError when
+ # the candidate string is structurally invalid as a path — most commonly
+ # ENAMETOOLONG (errno 63 on macOS, errno 36 on Linux) when the input
+ # exceeds NAME_MAX (typically 255 bytes). This bites pasted slash
+ # commands like `/goal ` because `_detect_file_drop()`'s
+ # `starts_like_path` prefilter accepts any input starting with `/`,
+ # then this resolver tries to stat it before short-circuiting on the
+ # slash-command path. Without this guard the OSError propagates up to
+ # the process_loop catch-all in _interactive_loop and the user input
+ # is silently lost (the warning ends up in agent.log but the user sees
+ # nothing — the prompt just hangs).
+ try:
+ if not resolved.exists() or not resolved.is_file():
+ return None
+ except OSError:
+ return None
+ return resolved
+
+
+
+
+
+def _detect_file_drop(user_input: str) -> "dict | None":
+ """Detect if *user_input* starts with a real local file path.
+
+ This catches dragged/pasted paths before they are mistaken for slash
+ commands, and also supports Termux-friendly paths like ``~/storage/...``.
+
+ Returns a dict on match::
+
+ {
+ "path": Path, # resolved file path
+ "is_image": bool, # True when suffix is a known image type
+ "remainder": str, # any text after the path
+ }
+
+ Returns ``None`` when the input is not a real file path.
+ """
+ if not isinstance(user_input, str):
+ return None
+
+ stripped = user_input.strip()
+ if not stripped:
+ return None
+
+ starts_like_path = (
+ stripped.startswith("/")
+ or stripped.startswith("~")
+ or stripped.startswith("./")
+ or stripped.startswith("../")
+ or stripped.startswith("file://")
+ or (len(stripped) >= 3 and stripped[1] == ":" and stripped[2] in {"\\", "/"} and stripped[0].isalpha())
+ or stripped.startswith('"/')
+ or stripped.startswith('"~')
+ or stripped.startswith("'/")
+ or stripped.startswith("'~")
+ or stripped.startswith('"./')
+ or stripped.startswith('"../')
+ or stripped.startswith("'./")
+ or stripped.startswith("'../")
+ or (len(stripped) >= 4 and stripped[0] in {"'", '"'} and stripped[2] == ":" and stripped[3] in {"\\", "/"} and stripped[1].isalpha())
+ )
+ if not starts_like_path:
+ return None
+
+ direct_path = _resolve_attachment_path(stripped)
+ if direct_path is not None:
+ return {
+ "path": direct_path,
+ "is_image": direct_path.suffix.lower() in _IMAGE_EXTENSIONS,
+ "remainder": "",
+ }
+
+ first_token, remainder = _split_path_input(stripped)
+ drop_path = _resolve_attachment_path(first_token)
+ if drop_path is None and " " in stripped and stripped[0] not in {"'", '"'}:
+ space_positions = [idx for idx, ch in enumerate(stripped) if ch == " "]
+ for pos in reversed(space_positions):
+ candidate = stripped[:pos].rstrip()
+ resolved = _resolve_attachment_path(candidate)
+ if resolved is not None:
+ drop_path = resolved
+ remainder = stripped[pos + 1 :].strip()
+ break
+ if drop_path is None:
+ return None
+
+ return {
+ "path": drop_path,
+ "is_image": drop_path.suffix.lower() in _IMAGE_EXTENSIONS,
+ "remainder": remainder,
+ }
+
+
+def _format_image_attachment_badges(attached_images: list[Path], image_counter: int, width: int | None = None) -> str:
+ """Format the attached-image badge row for the interactive CLI.
+
+ Narrow terminals such as Termux should get a compact summary that fits on a
+ single row, while wider terminals can show the classic per-image badges.
+ """
+ if not attached_images:
+ return ""
+
+ width = width or shutil.get_terminal_size((80, 24)).columns
+
+ def _trunc(name: str, limit: int) -> str:
+ return name if len(name) <= limit else name[: max(1, limit - 3)] + "..."
+
+ if width < 52:
+ if len(attached_images) == 1:
+ return f"[📎 {_trunc(attached_images[0].name, 20)}]"
+ return f"[📎 {len(attached_images)} images attached]"
+
+ if width < 80:
+ if len(attached_images) == 1:
+ return f"[📎 {_trunc(attached_images[0].name, 32)}]"
+ first = _trunc(attached_images[0].name, 20)
+ extra = len(attached_images) - 1
+ return f"[📎 {first}] [+{extra}]"
+
+ base = image_counter - len(attached_images) + 1
+ return " ".join(
+ f"[📎 Image #{base + i}]"
+ for i in range(len(attached_images))
+ )
+
+
+def _should_auto_attach_clipboard_image_on_paste(pasted_text: str) -> bool:
+ """Auto-attach clipboard images only for image-only paste gestures."""
+ return not pasted_text.strip()
+
+
+def _collect_query_images(query: str | None, image_arg: str | None = None) -> tuple[str, list[Path]]:
+ """Collect local image attachments for single-query CLI flows."""
+ message = query or ""
+ images: list[Path] = []
+
+ if isinstance(message, str):
+ dropped = _detect_file_drop(message)
+ if dropped and dropped.get("is_image"):
+ images.append(dropped["path"])
+ message = dropped["remainder"] or f"[User attached image: {dropped['path'].name}]"
+
+ if image_arg:
+ explicit_path = _resolve_attachment_path(image_arg)
+ if explicit_path is None:
+ raise ValueError(f"Image file not found: {image_arg}")
+ if explicit_path.suffix.lower() not in _IMAGE_EXTENSIONS:
+ raise ValueError(f"Not a supported image file: {explicit_path}")
+ images.append(explicit_path)
+
+ deduped: list[Path] = []
+ seen: set[str] = set()
+ for img in images:
+ key = str(img)
+ if key in seen:
+ continue
+ seen.add(key)
+ deduped.append(img)
+ return message, deduped
+
+
+# Strip OSC escape sequences (e.g. OSC-8 hyperlinks) that prompt_toolkit's
+# ANSI parser can't handle — it strips \x1b but passes the payload through
+# as literal text, garbling the TUI output.
+_OSC_ESCAPE_RE = re.compile(r"\x1b\][\s\S]*?(?:\x07|\x1b\\)")
+
+
+class ChatConsole:
+ """Rich Console adapter for prompt_toolkit's patch_stdout context.
+
+ Captures Rich's rendered ANSI output and routes it through _cprint
+ so colors and markup render correctly inside the interactive chat loop.
+ Drop-in replacement for Rich Console — just pass this to any function
+ that expects a console.print() interface.
+ """
+
+ def __init__(self):
+ from io import StringIO
+ self._buffer = StringIO()
+ self._inner = Console(
+ file=self._buffer,
+ force_terminal=True,
+ color_system="truecolor",
+ highlight=False,
+ )
+
+ def print(self, *args, **kwargs):
+ self._buffer.seek(0)
+ self._buffer.truncate()
+ # Read terminal width at render time so panels adapt to current size
+ self._inner.width = shutil.get_terminal_size((80, 24)).columns
+ self._inner.print(*args, **kwargs)
+ output = self._buffer.getvalue()
+ # Strip OSC escape sequences (e.g. OSC-8 hyperlinks) before
+ # routing through prompt_toolkit's ANSI parser, which only
+ # handles CSI/SGR and passes OSC payload through as literal text.
+ output = _OSC_ESCAPE_RE.sub("", output)
+ for line in output.rstrip("\n").split("\n"):
+ _cprint(line)
+
+ @contextmanager
+ def status(self, *_args, **_kwargs):
+ """Provide a no-op Rich-compatible status context.
+
+ Some slash command helpers use ``console.status(...)`` when running in
+ the standalone CLI. Interactive chat routes those helpers through
+ ``ChatConsole()``, which historically only implemented ``print()``.
+ Returning a silent context manager keeps slash commands compatible
+ without duplicating the higher-level busy indicator already shown by
+ ``HermesCLI._busy_command()``.
+ """
+ yield self
+
+# ASCII Art - HERMES-AGENT logo (full width, single line - requires ~95 char terminal)
+HERMES_AGENT_LOGO = """[bold #FFD700]██╗ ██╗███████╗██████╗ ███╗ ███╗███████╗███████╗ █████╗ ██████╗ ███████╗███╗ ██╗████████╗[/]
+[bold #FFD700]██║ ██║██╔════╝██╔══██╗████╗ ████║██╔════╝██╔════╝ ██╔══██╗██╔════╝ ██╔════╝████╗ ██║╚══██╔══╝[/]
+[#FFBF00]███████║█████╗ ██████╔╝██╔████╔██║█████╗ ███████╗█████╗███████║██║ ███╗█████╗ ██╔██╗ ██║ ██║[/]
+[#FFBF00]██╔══██║██╔══╝ ██╔══██╗██║╚██╔╝██║██╔══╝ ╚════██║╚════╝██╔══██║██║ ██║██╔══╝ ██║╚██╗██║ ██║[/]
+[#CD7F32]██║ ██║███████╗██║ ██║██║ ╚═╝ ██║███████╗███████║ ██║ ██║╚██████╔╝███████╗██║ ╚████║ ██║[/]
+[#CD7F32]╚═╝ ╚═╝╚══════╝╚═╝ ╚═╝╚═╝ ╚═╝╚══════╝╚══════╝ ╚═╝ ╚═╝ ╚═════╝ ╚══════╝╚═╝ ╚═══╝ ╚═╝[/]"""
+
+# ASCII Art - Hermes Caduceus (compact, fits in left panel)
+HERMES_CADUCEUS = """[#CD7F32]⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⣀⡀⠀⣀⣀⠀⢀⣀⡀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀[/]
+[#CD7F32]⠀⠀⠀⠀⠀⠀⢀⣠⣴⣾⣿⣿⣇⠸⣿⣿⠇⣸⣿⣿⣷⣦⣄⡀⠀⠀⠀⠀⠀⠀[/]
+[#FFBF00]⠀⢀⣠⣴⣶⠿⠋⣩⡿⣿⡿⠻⣿⡇⢠⡄⢸⣿⠟⢿⣿⢿⣍⠙⠿⣶⣦⣄⡀⠀[/]
+[#FFBF00]⠀⠀⠉⠉⠁⠶⠟⠋⠀⠉⠀⢀⣈⣁⡈⢁⣈⣁⡀⠀⠉⠀⠙⠻⠶⠈⠉⠉⠀⠀[/]
+[#FFD700]⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣴⣿⡿⠛⢁⡈⠛⢿⣿⣦⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀[/]
+[#FFD700]⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠿⣿⣦⣤⣈⠁⢠⣴⣿⠿⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀[/]
+[#FFBF00]⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠈⠉⠻⢿⣿⣦⡉⠁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀[/]
+[#FFBF00]⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠘⢷⣦⣈⠛⠃⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀[/]
+[#CD7F32]⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢠⣴⠦⠈⠙⠿⣦⡄⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀[/]
+[#CD7F32]⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠸⣿⣤⡈⠁⢤⣿⠇⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀[/]
+[#B8860B]⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠉⠛⠷⠄⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀[/]
+[#B8860B]⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⣀⠑⢶⣄⡀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀[/]
+[#B8860B]⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣿⠁⢰⡆⠈⡿⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀[/]
+[#B8860B]⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠈⠳⠈⣡⠞⠁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀[/]
+[#B8860B]⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠈⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀[/]"""
+
+
+
+def _build_compact_banner() -> str:
+ """Build a compact banner that fits the current terminal width."""
+ try:
+ from hermes_cli.skin_engine import get_active_skin
+ _skin = get_active_skin()
+ except Exception:
+ _skin = None
+
+ skin_name = getattr(_skin, "name", "default") if _skin else "default"
+ border_color = _skin.get_color("banner_border", "#FFD700") if _skin else "#FFD700"
+ title_color = _skin.get_color("banner_title", "#FFBF00") if _skin else "#FFBF00"
+ dim_color = _skin.get_color("banner_dim", "#B8860B") if _skin else "#B8860B"
+
+ if skin_name == "default":
+ line1 = "⚕ NOUS HERMES - AI Agent Framework"
+ tiny_line = "⚕ NOUS HERMES"
+ else:
+ agent_name = _skin.get_branding("agent_name", "Hermes Agent") if _skin else "Hermes Agent"
+ line1 = f"{agent_name} - AI Agent Framework"
+ tiny_line = agent_name
+
+ if os.environ.get("HERMES_FAST_STARTUP_BANNER") == "1":
+ from hermes_cli import __release_date__ as _release_date
+ from hermes_cli import __version__ as _version
+
+ version_line = f"Hermes Agent v{_version} ({_release_date})"
+ else:
+ version_line = format_banner_version_label()
+
+ w = min(shutil.get_terminal_size().columns - 2, 88)
+ if w < 30:
+ return f"\n[{title_color}]{tiny_line}[/] [dim {dim_color}]- Nous Research[/]\n"
+
+ inner = w - 2 # inside the box border
+ bar = "═" * w
+ content_width = inner - 2
+
+ # Truncate and pad to fit
+ line1 = line1[:content_width].ljust(content_width)
+ line2 = version_line[:content_width].ljust(content_width)
+
+ return (
+ f"\n[bold {border_color}]╔{bar}╗[/]\n"
+ f"[bold {border_color}]║[/] [{title_color}]{line1}[/] [bold {border_color}]║[/]\n"
+ f"[bold {border_color}]║[/] [dim {dim_color}]{line2}[/] [bold {border_color}]║[/]\n"
+ f"[bold {border_color}]╚{bar}╝[/]\n"
+ )
+
+
+
+# ============================================================================
+# Slash-command detection helper
+# ============================================================================
+
+def _looks_like_slash_command(text: str) -> bool:
+ """Return True if *text* looks like a slash command, not a file path.
+
+ Slash commands are ``/help``, ``/model gpt-4``, ``/q``, etc.
+ File paths like ``/Users/ironin/file.md:45-46 can you fix this?``
+ also start with ``/`` but contain additional ``/`` characters in
+ the first whitespace-delimited word. This helper distinguishes
+ the two so that pasted paths are sent to the agent instead of
+ triggering "Unknown command".
+ """
+ if not text or not text.startswith("/"):
+ return False
+ first_word = text.split()[0]
+ # After stripping the leading /, a command name has no slashes.
+ # A path like /Users/foo/bar.md always does.
+ return "/" not in first_word[1:]
diff --git a/cli_git.py b/cli_git.py
new file mode 100644
index 000000000000..d1d707919a23
--- /dev/null
+++ b/cli_git.py
@@ -0,0 +1,701 @@
+#!/usr/bin/env python3
+"""
+cli_git.py — Git worktree isolation utilities for the Hermes CLI.
+
+Extracted from cli.py for improved modularity and security auditability.
+Names are re-exported from cli.py, so ``from cli import _setup_worktree``
+continues to work.
+
+Manages per-session isolated git worktrees (synchronized-base setup),
+worktree lock classification, stale-worktree pruning, and orphaned
+branch cleanup.
+"""
+from __future__ import annotations
+
+import logging
+import os
+import shutil
+import sys
+import uuid
+from pathlib import Path
+from typing import Dict, Optional
+
+logger = logging.getLogger(__name__)
+
+
+# =============================================================================
+# Git Worktree Isolation (#652)
+# =============================================================================
+
+# Tracks the active worktree for cleanup on exit
+_active_worktree: Optional[Dict[str, str]] = None
+
+
+def _normalize_git_bash_path(p: Optional[str]) -> Optional[str]:
+ """Translate a Git Bash-style path (``/c/Users/...``) to the native
+ Windows form (``C:\\Users\\...``) that Python's ``subprocess.Popen``
+ and ``pathlib.Path`` accept.
+
+ No-op on non-Windows and for paths that already look native. Git on
+ native Windows normally emits forward-slash Windows paths
+ (``C:/Users/...``) which both bash and Python handle, but certain
+ configurations (Git Bash shells, MSYS2, WSL-mounted repos) surface
+ ``/c/...`` or ``/cygdrive/c/...`` variants.
+ """
+ if not p:
+ return p
+ if sys.platform != "win32":
+ return p
+ import re as _re
+ # /c/Users/... or /C/Users/...
+ m = _re.match(r"^/([a-zA-Z])/(.*)$", p)
+ if m:
+ drive, rest = m.group(1), m.group(2)
+ return f"{drive.upper()}:\\{rest.replace('/', chr(92))}"
+ # /cygdrive/c/... or /mnt/c/...
+ m = _re.match(r"^/(?:cygdrive|mnt)/([a-zA-Z])/(.*)$", p)
+ if m:
+ drive, rest = m.group(1), m.group(2)
+ return f"{drive.upper()}:\\{rest.replace('/', chr(92))}"
+ return p
+
+
+def _git_repo_root() -> Optional[str]:
+ """Return the git repo root for CWD, or None if not in a repo.
+
+ Runs through :func:`_normalize_git_bash_path` so callers can pass
+ the result directly to ``Path``/``subprocess.Popen(cwd=...)`` on
+ Windows without hitting ``C:\\c\\Users\\...`` style resolution
+ mistakes.
+ """
+ import subprocess
+ try:
+ result = subprocess.run(
+ ["git", "rev-parse", "--show-toplevel"],
+ capture_output=True, text=True, timeout=5,
+ )
+ if result.returncode == 0:
+ return _normalize_git_bash_path(result.stdout.strip())
+ except Exception:
+ pass
+ return None
+
+
+def _path_is_within_root(path: Path, root: Path) -> bool:
+ """Return True when a resolved path stays within the expected root."""
+ try:
+ path.relative_to(root)
+ return True
+ except ValueError:
+ return False
+
+
+def _resolve_worktree_base(repo_root: str) -> tuple:
+ """Resolve the freshest base ref to branch a new worktree from.
+
+ The standalone clone's ``HEAD`` can lag the remote by hundreds of commits
+ (the ``~/.hermes/hermes-agent`` clone is updated only by ``hermes update``,
+ not on every session). Branching a worktree from that stale ``HEAD`` roots
+ every new branch on an old base — so the PR diff GitHub computes against
+ current ``main`` balloons with unrelated changes, and the agent has to
+ discover the staleness via the pre-push gate and rebase. Branching from the
+ freshly-fetched remote tip instead means the worktree starts current.
+
+ Strategy (each step falls back to the next on failure):
+ 1. If the current branch tracks an upstream, fetch and use that upstream
+ ref — so a deliberate feature-branch worktree tracks its own remote,
+ not the default branch.
+ 2. Else fetch the remote's default branch (``origin/HEAD`` → e.g.
+ ``origin/main``) and use it.
+ 3. Else fall back to ``HEAD`` (offline, no remote, or detached) — the
+ old behavior, never worse than before.
+
+ Returns ``(base_ref, label)`` where *base_ref* is a git revision suitable
+ for ``git worktree add ... `` and *label* is a short
+ human-readable description for the session banner.
+ """
+ import subprocess
+
+ def _git(args, timeout=20):
+ return subprocess.run(
+ ["git", *args],
+ capture_output=True, text=True, timeout=timeout, cwd=repo_root,
+ )
+
+ # 1. Current branch's upstream, if it tracks one.
+ try:
+ up = _git(["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{upstream}"])
+ if up.returncode == 0:
+ upstream = up.stdout.strip() # e.g. "origin/main"
+ if upstream and "/" in upstream:
+ remote = upstream.split("/", 1)[0]
+ # Fetch just that branch; fail-soft if offline.
+ _git(["fetch", remote, upstream.split("/", 1)[1]], timeout=30)
+ return upstream, f"{upstream} (fetched)"
+ except Exception as e:
+ logger.debug("worktree base: upstream resolution failed: %s", e)
+
+ # 2. Remote default branch (origin/HEAD).
+ try:
+ # Resolve the remote's default branch symref.
+ head_ref = _git(["symbolic-ref", "--quiet", "refs/remotes/origin/HEAD"])
+ default_ref = ""
+ if head_ref.returncode == 0:
+ default_ref = head_ref.stdout.strip().replace("refs/remotes/", "", 1)
+ if not default_ref:
+ # origin/HEAD not set locally; ask the remote.
+ show = _git(["remote", "show", "origin"], timeout=30)
+ for line in show.stdout.splitlines():
+ line = line.strip()
+ if line.startswith("HEAD branch:"):
+ _branch = line.split(":", 1)[1].strip()
+ # A remote with no default branch reports "(unknown)";
+ # don't construct a bogus "origin/(unknown)" ref from it.
+ if _branch and _branch != "(unknown)":
+ default_ref = "origin/" + _branch
+ break
+ if default_ref and "/" in default_ref:
+ remote, branch = default_ref.split("/", 1)
+ _git(["fetch", remote, branch], timeout=30)
+ return default_ref, f"{default_ref} (fetched)"
+ except Exception as e:
+ logger.debug("worktree base: default-branch resolution failed: %s", e)
+
+ # 3. Fall back to local HEAD (offline / no remote / detached).
+ return "HEAD", "HEAD (local — could not reach remote)"
+
+
+def _setup_worktree(repo_root: str = None, sync_base: bool = True) -> Optional[Dict[str, str]]:
+ """Create an isolated git worktree for this CLI session.
+
+ Returns a dict with worktree metadata on success, None on failure.
+ The dict contains: path, branch, repo_root.
+
+ When *sync_base* is True (default), the worktree branches from the
+ freshly-fetched remote tip rather than the (possibly stale) local ``HEAD``
+ — see ``_resolve_worktree_base``. Set ``worktree_sync: false`` in config to
+ branch from local ``HEAD`` (the pre-#10760-followup behavior).
+ """
+ import subprocess
+
+ repo_root = repo_root or _git_repo_root()
+ if not repo_root:
+ print("\033[31m✗ --worktree requires being inside a git repository.\033[0m")
+ print(" cd into your project repo first, then run hermes -w")
+ return None
+
+ short_id = uuid.uuid4().hex[:8]
+ wt_name = f"hermes-{short_id}"
+ branch_name = f"hermes/{wt_name}"
+
+ worktrees_dir = Path(repo_root) / ".worktrees"
+ worktrees_dir.mkdir(parents=True, exist_ok=True)
+
+ wt_path = worktrees_dir / wt_name
+
+ # Ensure .worktrees/ is in .gitignore
+ gitignore = Path(repo_root) / ".gitignore"
+ _ignore_entry = ".worktrees/"
+ try:
+ existing = gitignore.read_text() if gitignore.exists() else ""
+ if _ignore_entry not in existing.splitlines():
+ with open(gitignore, "a", encoding="utf-8") as f:
+ if existing and not existing.endswith("\n"):
+ f.write("\n")
+ f.write(f"{_ignore_entry}\n")
+ except Exception as e:
+ logger.debug("Could not update .gitignore: %s", e)
+
+ # Resolve the base ref. By default branch from the freshly-fetched remote
+ # tip so the worktree starts current with the project, not from the
+ # (possibly stale) local HEAD of the standalone clone (#10760 follow-up).
+ if sync_base:
+ base_ref, base_label = _resolve_worktree_base(repo_root)
+ else:
+ base_ref, base_label = "HEAD", "HEAD (local — worktree_sync disabled)"
+
+ # Create the worktree
+ try:
+ result = subprocess.run(
+ ["git", "worktree", "add", str(wt_path), "-b", branch_name, base_ref],
+ capture_output=True, text=True, timeout=30, cwd=repo_root,
+ )
+ if result.returncode != 0:
+ # If branching from the resolved remote ref failed for any reason
+ # (e.g. a partial fetch left the ref unusable), retry from local
+ # HEAD so worktree creation never hard-fails on a sync hiccup.
+ if base_ref != "HEAD":
+ logger.warning(
+ "worktree add from %s failed (%s); retrying from local HEAD",
+ base_ref, result.stderr.strip(),
+ )
+ base_ref, base_label = "HEAD", "HEAD (fallback — remote base failed)"
+ result = subprocess.run(
+ ["git", "worktree", "add", str(wt_path), "-b", branch_name, base_ref],
+ capture_output=True, text=True, timeout=30, cwd=repo_root,
+ )
+ if result.returncode != 0:
+ print(f"\033[31m✗ Failed to create worktree: {result.stderr.strip()}\033[0m")
+ return None
+ except Exception as e:
+ print(f"\033[31m✗ Failed to create worktree: {e}\033[0m")
+ return None
+
+ # Copy files listed in .worktreeinclude (gitignored files the agent needs)
+ include_file = Path(repo_root) / ".worktreeinclude"
+ if include_file.exists():
+ try:
+ repo_root_resolved = Path(repo_root).resolve()
+ wt_path_resolved = wt_path.resolve()
+ for line in include_file.read_text().splitlines():
+ entry = line.strip()
+ if not entry or entry.startswith("#"):
+ continue
+ src = Path(repo_root) / entry
+ dst = wt_path / entry
+ # Prevent path traversal and symlink escapes: both the resolved
+ # source and the resolved destination must stay inside their
+ # expected roots before any file or symlink operation happens.
+ try:
+ src_resolved = src.resolve(strict=False)
+ dst_resolved = dst.resolve(strict=False)
+ except (OSError, ValueError):
+ logger.debug("Skipping invalid .worktreeinclude entry: %s", entry)
+ continue
+ if not _path_is_within_root(src_resolved, repo_root_resolved):
+ logger.warning("Skipping .worktreeinclude entry outside repo root: %s", entry)
+ continue
+ if not _path_is_within_root(dst_resolved, wt_path_resolved):
+ logger.warning("Skipping .worktreeinclude entry that escapes worktree: %s", entry)
+ continue
+ if src.is_file():
+ dst.parent.mkdir(parents=True, exist_ok=True)
+ shutil.copy2(str(src), str(dst))
+ elif src.is_dir():
+ # Symlink directories (faster, saves disk). On Windows,
+ # symlink creation requires Developer Mode or elevation,
+ # and fails with OSError otherwise — fall back to a
+ # recursive copy so the worktree is still usable. The
+ # copy is slower and uses disk, but it doesn't require
+ # admin and matches the Linux/macOS symlink outcome
+ # functionally.
+ if not dst.exists():
+ dst.parent.mkdir(parents=True, exist_ok=True)
+ try:
+ os.symlink(str(src_resolved), str(dst))
+ except (OSError, NotImplementedError) as _sym_err:
+ if sys.platform == "win32":
+ logger.info(
+ ".worktreeinclude: symlink failed (%s) — "
+ "falling back to copytree on Windows.",
+ _sym_err,
+ )
+ try:
+ shutil.copytree(
+ str(src_resolved),
+ str(dst),
+ symlinks=True,
+ dirs_exist_ok=False,
+ )
+ except Exception as _copy_err:
+ logger.warning(
+ ".worktreeinclude: copy fallback "
+ "also failed for %s -> %s: %s",
+ src, dst, _copy_err,
+ )
+ else:
+ raise
+ except Exception as e:
+ logger.debug("Error copying .worktreeinclude entries: %s", e)
+
+ # Lock the worktree so other processes (and `git worktree remove`) can see
+ # it is actively in use. Fail-soft: a lock failure never blocks the session.
+ try:
+ subprocess.run(
+ ["git", "worktree", "lock", "--reason", f"hermes pid={os.getpid()}", str(wt_path)],
+ capture_output=True, text=True, timeout=10, cwd=repo_root,
+ )
+ logger.debug("Worktree locked: %s (pid=%s)", wt_path, os.getpid())
+ except Exception as e:
+ logger.debug("git worktree lock failed (non-fatal): %s", e)
+
+ info = {
+ "path": str(wt_path),
+ "branch": branch_name,
+ "repo_root": repo_root,
+ "base": base_ref,
+ }
+
+ print(f"\033[32m✓ Worktree created:\033[0m {wt_path}")
+ print(f" Branch: {branch_name}")
+ print(f" Base: {base_label}")
+
+ 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 _worktree_is_dirty(worktree_path: str, timeout: int = 10) -> bool:
+ """Return whether a worktree has uncommitted changes (staged, unstaged, or
+ untracked).
+
+ Fails SAFE: on any error returns True so callers do not delete a worktree
+ whose state they cannot determine.
+ """
+ import subprocess
+
+ try:
+ result = subprocess.run(
+ ["git", "status", "--porcelain"],
+ 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 _worktree_lock_is_live(repo_root: str, worktree_path: str, timeout: int = 10):
+ """Classify a worktree's git lock as live, dead, or absent.
+
+ ``hermes -w`` locks each worktree with reason ``hermes pid=`` so a
+ concurrent hermes process' startup prune leaves an in-use worktree alone.
+ But a *crashed* session leaves the lock behind forever, and
+ ``git worktree remove --force`` (single ``-f``) refuses to remove a locked
+ worktree — so dead-locked worktrees accumulate indefinitely. This lets the
+ pruner tell the two apart:
+
+ - ``"live"`` — locked and the owning pid is still running (skip it).
+ - ``"dead"`` — locked but the owning pid is gone, or the reason isn't a
+ parseable hermes lock (safe to unlock + reap).
+ - ``None`` — not locked at all.
+
+ Fails SAFE toward ``"live"``: if git can't be queried at all we cannot
+ prove the worktree is safe to touch, so we report it as live.
+ """
+ import re
+ import subprocess
+
+ try:
+ result = subprocess.run(
+ ["git", "worktree", "list", "--porcelain"],
+ capture_output=True, text=True, timeout=timeout, cwd=repo_root,
+ )
+ if result.returncode != 0:
+ return "live"
+ except Exception:
+ return "live"
+
+ target = Path(worktree_path).resolve()
+ current: Optional[Path] = None
+ for line in result.stdout.splitlines():
+ if line.startswith("worktree "):
+ try:
+ current = Path(line[len("worktree "):].strip()).resolve()
+ except Exception:
+ current = None
+ elif line == "locked" or line.startswith("locked "):
+ if current != target:
+ continue
+ reason = line[len("locked"):].strip()
+ m = re.search(r"hermes pid=(\d+)", reason)
+ if not m:
+ # Locked by something we don't recognize as a hermes session
+ # (or lock reason unavailable). Treat as dead — a foreign lock
+ # on a hermes -w worktree is almost certainly a leftover, and
+ # the age/dirty/unpushed gates already ran before we got here.
+ return "dead"
+ pid = int(m.group(1))
+ if pid == os.getpid():
+ return "live"
+ try:
+ from gateway.status import _pid_exists
+ return "live" if _pid_exists(pid) else "dead"
+ except Exception:
+ # Can't determine liveness — fail safe toward keeping it.
+ return "live"
+ return None
+
+
+def _cleanup_worktree(info: Dict[str, str] = None) -> None:
+ """Remove a worktree and its branch on exit.
+
+ Preserves the worktree only if it has unpushed commits (real work
+ that hasn't been pushed to any remote). Uncommitted changes alone
+ (untracked files, test artifacts) are not enough to keep it — agent
+ work lives in commits/PRs, not the working tree.
+ """
+ global _active_worktree
+ info = info or _active_worktree
+ if not info:
+ return
+
+ import subprocess
+
+ wt_path = info["path"]
+ branch = info["branch"]
+ repo_root = info["repo_root"]
+
+ if not Path(wt_path).exists():
+ return
+
+ 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")
+ print(f" To clean up manually: git worktree remove --force {wt_path}")
+ _active_worktree = None
+ return
+
+ # Remove worktree (even if working tree is dirty — uncommitted
+ # changes without unpushed commits are just artifacts)
+ # Unlock first so `git worktree remove` isn't blocked by the lock we
+ # placed at creation time. Fail-soft — never block cleanup.
+ try:
+ subprocess.run(
+ ["git", "worktree", "unlock", wt_path],
+ capture_output=True, text=True, timeout=10, cwd=repo_root,
+ )
+ except Exception as e:
+ logger.debug("git worktree unlock failed (non-fatal): %s", e)
+
+ try:
+ subprocess.run(
+ ["git", "worktree", "remove", wt_path, "--force"],
+ capture_output=True, text=True, timeout=15, cwd=repo_root,
+ )
+ except Exception as e:
+ logger.debug("Failed to remove worktree: %s", e)
+
+ # Delete the branch
+ try:
+ subprocess.run(
+ ["git", "branch", "-D", branch],
+ capture_output=True, text=True, timeout=10, cwd=repo_root,
+ )
+ except Exception as e:
+ logger.debug("Failed to delete branch %s: %s", branch, e)
+
+ _active_worktree = None
+ print(f"\033[32m✓ Worktree cleaned up: {wt_path}\033[0m")
+
+
+def _prune_stale_worktrees(repo_root: str, max_age_hours: int = 24) -> None:
+ """Remove stale worktrees and orphaned branches on startup.
+
+ Age-based tiers (aggressive cleanup keeps ``.worktrees/`` from growing
+ unbounded):
+ - Under max_age_hours (24h): skip — session may still be active.
+ - 24h–72h: remove if no unpushed commits.
+ - Over 72h: force remove regardless (nothing should sit this long).
+
+ Lock handling (orthogonal to age): ``hermes -w`` locks each worktree with
+ reason ``hermes pid=`` so a concurrent hermes process leaves an in-use
+ worktree alone. A *live*-locked worktree is skipped at any age; a
+ *dead*-locked one (owning pid gone — a crashed session) is unlocked first
+ so ``git worktree remove --force`` can actually reap it, otherwise those
+ leftovers accumulate forever (``remove --force`` refuses a locked tree).
+
+ Branch deletion is gated on ``git worktree remove`` succeeding, so a failed
+ removal never orphans the branch (which would drop easy reachability of any
+ commits still in the worktree).
+
+ Also prunes orphaned ``hermes/*`` and ``pr-*`` local branches that
+ have no corresponding worktree.
+ """
+ import subprocess
+ import time
+
+ worktrees_dir = Path(repo_root) / ".worktrees"
+ if not worktrees_dir.exists():
+ _prune_orphaned_branches(repo_root)
+ return
+
+ now = time.time()
+ soft_cutoff = now - (max_age_hours * 3600) # 24h default
+ hard_cutoff = now - (max_age_hours * 3 * 3600) # 72h default
+
+ for entry in worktrees_dir.iterdir():
+ if not entry.is_dir() or not entry.name.startswith("hermes-"):
+ continue
+
+ # Check age
+ try:
+ mtime = entry.stat().st_mtime
+ if mtime > soft_cutoff:
+ continue # Too recent — skip
+ except Exception:
+ continue
+
+ force = mtime <= hard_cutoff # Over 72h — reap aggressively
+
+ # Never delete real work, regardless of age. Unpushed commits and
+ # uncommitted changes may be a crashed session's in-flight work; the
+ # >72h tier reaps only abandoned *clean, fully-pushed* worktrees (the
+ # scratch trees that actually cause .worktrees/ bloat).
+ if _worktree_has_unpushed_commits(str(entry), timeout=5):
+ continue # Has unpushed commits or can't check — skip
+ if not force:
+ # 24h–72h tier is conservative: unpushed check above is enough.
+ pass
+ elif _worktree_is_dirty(str(entry), timeout=5):
+ continue # >72h but dirty — preserve uncommitted work
+
+ # Respect git-native session locks. A lock owned by a still-running
+ # hermes process means the worktree is actively in use — never touch
+ # it. A lock whose owning pid is gone is a crashed session's leftover:
+ # unlock it so `git worktree remove --force` (single -f) can reap it,
+ # otherwise dead-locked worktrees pile up indefinitely.
+ lock_state = _worktree_lock_is_live(repo_root, str(entry), timeout=5)
+ if lock_state == "live":
+ logger.debug("Skipping live-locked worktree: %s", entry.name)
+ continue
+ if lock_state == "dead":
+ try:
+ subprocess.run(
+ ["git", "worktree", "unlock", str(entry)],
+ capture_output=True, text=True, timeout=10, cwd=repo_root,
+ )
+ except Exception as e:
+ logger.debug("Failed to unlock dead worktree %s: %s", entry.name, e)
+
+ # Safe to remove
+ try:
+ branch_result = subprocess.run(
+ ["git", "branch", "--show-current"],
+ capture_output=True, text=True, timeout=5, cwd=str(entry),
+ )
+ branch = branch_result.stdout.strip()
+
+ remove_result = subprocess.run(
+ ["git", "worktree", "remove", str(entry), "--force"],
+ capture_output=True, text=True, timeout=15, cwd=repo_root,
+ )
+ if remove_result.returncode != 0:
+ # Removal failed — keep the branch so any commits stay
+ # reachable rather than orphaning it.
+ logger.debug(
+ "Failed to remove worktree %s: %s",
+ entry.name, remove_result.stderr.strip(),
+ )
+ continue
+ if branch:
+ subprocess.run(
+ ["git", "branch", "-D", branch],
+ capture_output=True, text=True, timeout=10, cwd=repo_root,
+ )
+ logger.debug("Pruned stale worktree: %s (force=%s)", entry.name, force)
+ except Exception as e:
+ logger.debug("Failed to prune worktree %s: %s", entry.name, e)
+
+ _prune_orphaned_branches(repo_root)
+
+
+def _prune_orphaned_branches(repo_root: str) -> None:
+ """Delete local ``hermes/hermes-*`` and ``pr-*`` branches with no worktree.
+
+ These are auto-generated by ``hermes -w`` sessions and PR review
+ workflows respectively. Once their worktree is gone they serve no
+ purpose and just accumulate.
+ """
+ import subprocess
+
+ try:
+ result = subprocess.run(
+ ["git", "branch", "--format=%(refname:short)"],
+ capture_output=True, text=True, timeout=10, cwd=repo_root,
+ )
+ if result.returncode != 0:
+ return
+ all_branches = [b.strip() for b in result.stdout.strip().split("\n") if b.strip()]
+ except Exception:
+ return
+
+ # Collect branches that are actively checked out in a worktree
+ active_branches: set = set()
+ try:
+ wt_result = subprocess.run(
+ ["git", "worktree", "list", "--porcelain"],
+ capture_output=True, text=True, timeout=10, cwd=repo_root,
+ )
+ for line in wt_result.stdout.split("\n"):
+ if line.startswith("branch refs/heads/"):
+ active_branches.add(line.split("branch refs/heads/", 1)[-1].strip())
+ except Exception:
+ return # Can't determine active branches — bail
+
+ # Also protect the currently checked-out branch and main
+ try:
+ head_result = subprocess.run(
+ ["git", "branch", "--show-current"],
+ capture_output=True, text=True, timeout=5, cwd=repo_root,
+ )
+ current = head_result.stdout.strip()
+ if current:
+ active_branches.add(current)
+ except Exception:
+ pass
+ active_branches.add("main")
+
+ orphaned = [
+ b for b in all_branches
+ if b not in active_branches
+ and (b.startswith("hermes/hermes-") or b.startswith("pr-"))
+ ]
+
+ if not orphaned:
+ return
+
+ # Delete in batches
+ for i in range(0, len(orphaned), 50):
+ batch = orphaned[i:i + 50]
+ try:
+ subprocess.run(
+ ["git", "branch", "-D"] + batch,
+ capture_output=True, text=True, timeout=30, cwd=repo_root,
+ )
+ except Exception as e:
+ logger.debug("Failed to prune orphaned branches: %s", e)
+
+ logger.debug("Pruned %d orphaned branches", len(orphaned))
+
+
+def get_active_worktree() -> Optional[Dict[str, str]]:
+ """Return the active session's worktree info dict, or None."""
+ return _active_worktree
+
+
+def set_active_worktree(info: Optional[Dict[str, str]]) -> None:
+ """Record the active session's worktree info (or clear with None)."""
+ global _active_worktree
+ _active_worktree = info
diff --git a/tests/cli/test_cli_init.py b/tests/cli/test_cli_init.py
index a990f6bf3427..0ffb2809ddc2 100644
--- a/tests/cli/test_cli_init.py
+++ b/tests/cli/test_cli_init.py
@@ -572,7 +572,7 @@ def test_model_provider_wins_over_root_provider(self, tmp_path, monkeypatch):
}))
import cli
- monkeypatch.setattr(cli, "_hermes_home", hermes_home)
+ monkeypatch.setattr("cli_config._hermes_home", hermes_home)
cfg = cli.load_cli_config()
assert cfg["model"]["provider"] == "openrouter"
@@ -595,7 +595,7 @@ def test_root_provider_used_as_fallback_when_model_provider_missing(self, tmp_pa
}))
import cli
- monkeypatch.setattr(cli, "_hermes_home", hermes_home)
+ monkeypatch.setattr("cli_config._hermes_home", hermes_home)
cfg = cli.load_cli_config()
assert cfg["model"]["provider"] == "opencode-go"
@@ -617,7 +617,7 @@ def test_root_base_url_used_as_fallback_when_model_base_url_missing(self, tmp_pa
}))
import cli
- monkeypatch.setattr(cli, "_hermes_home", hermes_home)
+ monkeypatch.setattr("cli_config._hermes_home", hermes_home)
cfg = cli.load_cli_config()
assert cfg["model"]["base_url"] == "https://example.com/v1"
diff --git a/tests/cli/test_cli_light_mode.py b/tests/cli/test_cli_light_mode.py
index 1a8d51ae6d13..d06336a8971d 100644
--- a/tests/cli/test_cli_light_mode.py
+++ b/tests/cli/test_cli_light_mode.py
@@ -1,4 +1,4 @@
-"""Tests for the light-mode terminal detection + color remap in cli.py.
+"""Tests for the light-mode terminal detection + color remap in cli_display.py.
Covers the env-override path and the SkinConfig.get_color() wrapper that
the resize / light-mode salvage installs at module import time. We don't
@@ -14,8 +14,8 @@
@pytest.fixture
def cli_mod(monkeypatch):
- """Import cli with the light-mode cache cleared each test."""
- import cli as _cli
+ """Import cli_display with the light-mode cache cleared each test."""
+ import cli_display as _cli
# The module-level _install_skin_light_mode_hook() and import-time
# _detect_light_mode() prime ran once at first import. We just reset
diff --git a/tests/cli/test_cli_save_config_value.py b/tests/cli/test_cli_save_config_value.py
index 49cdd6235643..ab8aa7e92e61 100644
--- a/tests/cli/test_cli_save_config_value.py
+++ b/tests/cli/test_cli_save_config_value.py
@@ -19,7 +19,7 @@ def config_env(self, tmp_path, monkeypatch):
"model": {"default": "test-model", "provider": "openrouter"},
"display": {"skin": "default"},
}))
- monkeypatch.setattr("cli._hermes_home", hermes_home)
+ monkeypatch.setattr("cli_config._hermes_home", hermes_home)
return config_path
def test_calls_roundtrip_yaml_update(self, config_env, monkeypatch):
diff --git a/tests/cli/test_cprint_bg_thread.py b/tests/cli/test_cprint_bg_thread.py
index f68e1de7c1d3..422a962fb4c3 100644
--- a/tests/cli/test_cprint_bg_thread.py
+++ b/tests/cli/test_cprint_bg_thread.py
@@ -1,4 +1,4 @@
-"""Tests for cli._cprint's bg-thread cooperation with prompt_toolkit.
+"""Tests for cli_display._cprint's bg-thread cooperation with prompt_toolkit.
Background: when a prompt_toolkit Application is running, a bg thread that
calls ``_pt_print`` directly can race with the input-area redraw and the
@@ -18,7 +18,7 @@
import pytest
-import cli
+import cli_display as cli
@pytest.fixture(autouse=True)
diff --git a/tests/cli/test_resume_display.py b/tests/cli/test_resume_display.py
index 5ccac59ba6e6..cd16bccb9ac5 100644
--- a/tests/cli/test_resume_display.py
+++ b/tests/cli/test_resume_display.py
@@ -11,6 +11,7 @@
from unittest.mock import MagicMock, patch
import cli as cli_mod
+import cli_display
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
@@ -316,8 +317,8 @@ def test_panel_is_stored_as_resize_aware_history_entry(self):
output = self._capture_display(cli)
assert "Previous Conversation" in output
- assert len(cli_mod._OUTPUT_HISTORY) == 1
- assert callable(cli_mod._OUTPUT_HISTORY[0])
+ assert len(cli_display._OUTPUT_HISTORY) == 1
+ assert callable(cli_display._OUTPUT_HISTORY[0])
finally:
cli_mod._configure_output_history(True, 200)
diff --git a/tests/hermes_cli/test_config_env_expansion.py b/tests/hermes_cli/test_config_env_expansion.py
index 75ef62592d1a..919697996617 100644
--- a/tests/hermes_cli/test_config_env_expansion.py
+++ b/tests/hermes_cli/test_config_env_expansion.py
@@ -154,7 +154,7 @@ def test_cli_config_ignores_empty_terminal_section(self, tmp_path, monkeypatch):
config_file = tmp_path / "config.yaml"
config_file.write_text("terminal:\n")
- monkeypatch.setattr("cli._hermes_home", tmp_path)
+ monkeypatch.setattr("cli_config._hermes_home", tmp_path)
from cli import load_cli_config
config = load_cli_config()
@@ -173,7 +173,7 @@ def test_cli_config_expands_auxiliary_api_key(self, tmp_path, monkeypatch):
monkeypatch.setenv("TEST_VISION_KEY_XYZ", "vis-key-123")
# Patch the hermes home so load_cli_config finds our test config
- monkeypatch.setattr("cli._hermes_home", tmp_path)
+ monkeypatch.setattr("cli_config._hermes_home", tmp_path)
from cli import load_cli_config
config = load_cli_config()
@@ -190,7 +190,7 @@ def test_cli_config_unresolved_kept_verbatim(self, tmp_path, monkeypatch):
config_file.write_text(config_yaml)
monkeypatch.delenv("UNSET_CLI_VAR_ABC", raising=False)
- monkeypatch.setattr("cli._hermes_home", tmp_path)
+ monkeypatch.setattr("cli_config._hermes_home", tmp_path)
from cli import load_cli_config
config = load_cli_config()
diff --git a/tests/hermes_cli/test_ignore_user_config_flags.py b/tests/hermes_cli/test_ignore_user_config_flags.py
index 28aea6a18de3..3f244baac381 100644
--- a/tests/hermes_cli/test_ignore_user_config_flags.py
+++ b/tests/hermes_cli/test_ignore_user_config_flags.py
@@ -68,7 +68,7 @@ def _write_user_config(self, tmp_path, model_default):
def _reload_cli(self, monkeypatch, tmp_path):
"""Point cli._hermes_home at tmp_path and return a fresh load_cli_config."""
import cli
- monkeypatch.setattr(cli, "_hermes_home", tmp_path)
+ monkeypatch.setattr("cli_config._hermes_home", tmp_path)
return cli.load_cli_config
def test_user_config_loaded_when_flag_unset(self, tmp_path, monkeypatch):
diff --git a/tests/hermes_cli/test_managed_scope_cli_config.py b/tests/hermes_cli/test_managed_scope_cli_config.py
index 51d5fcae4ce0..0e2dbdd0e9a5 100644
--- a/tests/hermes_cli/test_managed_scope_cli_config.py
+++ b/tests/hermes_cli/test_managed_scope_cli_config.py
@@ -31,16 +31,16 @@ def homes(tmp_path, monkeypatch):
def _load_cli_config(home):
"""Call cli.py's standalone loader fresh.
- cli.py binds ``_hermes_home = get_hermes_home()`` at import time (module
+ cli_config.py binds ``_hermes_home = get_hermes_home()`` at import time (module
singleton), so monkeypatching HERMES_HOME after import doesn't move it.
Point the module's cached home at the test's home for the duration of the
call. (In real use cli is imported once per process with the real home, so
this only matters for tests that swap HERMES_HOME.)
"""
- import cli
+ import cli_config
- cli._hermes_home = home
- return cli.load_cli_config()
+ cli_config._hermes_home = home
+ return cli_config.load_cli_config()
def test_cli_config_honors_managed_skin(homes):
diff --git a/tests/hermes_cli/test_reasoning_full_command.py b/tests/hermes_cli/test_reasoning_full_command.py
index afea65771c36..d9dc58e9aa04 100644
--- a/tests/hermes_cli/test_reasoning_full_command.py
+++ b/tests/hermes_cli/test_reasoning_full_command.py
@@ -41,7 +41,7 @@ def _seed_config(tmp_path, monkeypatch):
# cli captures _hermes_home at import; force it to the temp home.
import cli
- monkeypatch.setattr(cli, "_hermes_home", hh, raising=False)
+ monkeypatch.setattr("cli_config._hermes_home", hh)
return hh
diff --git a/tests/hermes_cli/test_timestamps_command.py b/tests/hermes_cli/test_timestamps_command.py
index 79784e85f873..473c192b27fb 100644
--- a/tests/hermes_cli/test_timestamps_command.py
+++ b/tests/hermes_cli/test_timestamps_command.py
@@ -29,7 +29,7 @@ def _seed(tmp_path, monkeypatch, value=False):
monkeypatch.setenv("HERMES_HOME", str(hh))
import cli
- monkeypatch.setattr(cli, "_hermes_home", hh, raising=False)
+ monkeypatch.setattr("cli_config._hermes_home", hh)
return hh