simplify(compat): hermes_cli small facades — drop 7 re-exports/aliases (+relay_runtime alias module), repoint 12 callers/tests

This commit is contained in:
Teknium
2026-09-03 13:05:57 -07:00
parent b39948106a
commit eeb7671e69
46 changed files with 171 additions and 231 deletions
+2 -51
View File
@@ -14,7 +14,6 @@ import shutil
import sys
import json
import re
import concurrent.futures # noqa: F401 (tests monkeypatch cli.concurrent.futures)
import atexit
import errno
import time
@@ -86,10 +85,6 @@ def _lazy_shim(module: str, name: str, alias: str | None = None):
return shim
CanonicalUsage = _lazy_shim("agent.usage_pricing", "CanonicalUsage")
estimate_usage_cost = _lazy_shim("agent.usage_pricing", "estimate_usage_cost")
def format_duration_compact(*args, **kwargs):
seconds = float(args[0] if args else kwargs.get("seconds", 0.0))
if seconds < 60:
@@ -162,8 +157,6 @@ def format_token_count_compact(*args, **kwargs):
return f"{value:,}"
is_table_divider = _lazy_shim("agent.markdown_tables", "is_table_divider")
looks_like_table_row = _lazy_shim("agent.markdown_tables", "looks_like_table_row")
realign_markdown_tables = _lazy_shim("agent.markdown_tables", "realign_markdown_tables")
from hermes_cli.banner import format_banner_version_label
@@ -171,7 +164,7 @@ _COMMAND_SPINNER_FRAMES = ("⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧
# ~/.hermes/.env first, project .env as dev fallback; user env files override stale shell exports.
from hermes_constants import get_hermes_home, display_hermes_home # noqa: F401 (mixins import via cli)
from hermes_constants import get_hermes_home
from hermes_cli.env_loader import load_hermes_dotenv
from utils import base_url_host_matches, base_url_hostname, fast_safe_load
@@ -575,9 +568,6 @@ from rich.markup import escape as _escape
from rich.text import Text as _RichText
# Agent/tool systems load lazily: bare startup only needs the prompt.
AIAgent = _lazy_shim("run_agent", "AIAgent")
def get_tool_definitions(*args, **kwargs):
from hermes_cli.mcp_startup import wait_for_mcp_discovery
from model_tools import get_tool_definitions as _get_tool_definitions
@@ -586,12 +576,6 @@ def get_tool_definitions(*args, **kwargs):
return _get_tool_definitions(*args, **kwargs)
get_toolset_for_tool = _lazy_shim("model_tools", "get_toolset_for_tool")
from hermes_cli.banner import build_welcome_banner # noqa: F401 (CLIInfoMixin imports via cli)
get_all_toolsets = _lazy_shim("toolsets", "get_all_toolsets")
get_toolset_info = _lazy_shim("toolsets", "get_toolset_info")
validate_toolset = _lazy_shim("toolsets", "validate_toolset")
@@ -602,7 +586,6 @@ def _sync_process_session_id(session_id: str) -> None:
set_current_session_id(session_id)
get_job = _lazy_shim("cron", "get_job")
_cleanup_all_terminals = _lazy_shim("tools.terminal_tool", "cleanup_all_environments", "_cleanup_all_terminals")
set_sudo_password_callback = _lazy_shim("tools.terminal_tool", "set_sudo_password_callback")
set_approval_callback = _lazy_shim("tools.terminal_tool", "set_approval_callback")
@@ -1036,39 +1019,14 @@ def _reset_terminal_input_modes_on_exit() -> None:
tty.flush()
from hermes_cli.worktree_ops import ( # noqa: F401 (mixins/tests/worktree_gc import via cli)
_PACK_SPRAWL_THRESHOLD,
_WORKTREE_MERGE_CACHE_MAX,
_classify_prune_candidates,
_cleanup_failed_worktree_add,
_copy_worktree_includes,
_deepen_shallow_repo,
_ensure_worktrees_gitignored,
_fetch_remote_branch_heads,
_git,
from hermes_cli.worktree_ops import (
_git_quiet,
_git_repo_root,
_load_worktree_merge_cache,
_maintain_pack_health,
_normalize_git_bash_path,
_path_is_within_root,
_prune_candidates,
_prune_orphaned_branches,
_prune_stale_worktrees,
_reap_prune_verdicts,
_repo_is_shallow,
_resolve_worktree_base,
_save_worktree_merge_cache,
_setup_worktree,
_worktree_add,
_worktree_branch_pr_merged,
_worktree_branch_pushed_exact,
_worktree_commits_all_merged_upstream,
_worktree_current_branch,
_worktree_has_unpushed_commits,
_worktree_is_dirty,
_worktree_lock_is_live,
_worktree_merge_cache_path,
)
# ============================================================================= Git Worktree Isolation
@@ -2239,12 +2197,6 @@ def _strip_leaked_terminal_responses_with_meta(text: str) -> tuple[str, bool]:
return text, had_mouse_reports
def _strip_leaked_terminal_responses(text: str) -> str:
"""Compatibility wrapper returning only cleaned text."""
cleaned, _ = _strip_leaked_terminal_responses_with_meta(text)
return cleaned
def _estimate_tui_input_height(
lines: list[str] | tuple[str, ...], prompt_text: str, terminal_columns: int, *, max_height: int = 8,
) -> int:
@@ -2325,7 +2277,6 @@ class ChatConsole:
"""No-op ``console.status`` so slash helpers don't duplicate ``_busy_command()``'s indicator."""
yield self
from hermes_cli.banner import HERMES_AGENT_LOGO, HERMES_CADUCEUS # noqa: E402,F401 (re-exported)
def _build_compact_banner() -> str:
+1 -1
View File
@@ -126,7 +126,7 @@ shared-metrics consent, local SQLite state, or ATIF trajectory grouping.
Hermes core owns one Relay host and one isolated Relay session scope per Hermes
session. Core lifecycle producers use
`hermes_cli.observability.relay_runtime` to obtain the shared session handle or
`agent.relay_runtime` to obtain the shared session handle or
run Relay scope, LLM, tool, and mark APIs in that session context. New product
marks do not require Hermes plugin registration. Shared-metrics marks must
still contain only fields approved by the versioned allowlist; the hard
+1 -4
View File
@@ -452,7 +452,7 @@ def bounded_git_probe(argv: Sequence[str], *, timeout: float) -> str:
``text`` with UTF-8 ``errors="replace"`` decoding, and the hidden-window ``creationflags`` on Windows
only. On POSIX the probe is additionally placed in its own process group (``process_group=0``, Python
≥3.11) so timeout cleanup can take down descendants — credential helpers, ``git-remote-https``, hook
children — with the launcher instead of orphaning them (see :func:`_kill_git_process_tree`; port of
children — with the launcher instead of orphaning them (see :func:`kill_process_tree`; port of
openai/codex#36793). ``process_group`` only changes which group the child belongs to; it does not detach
the terminal or alter the fast path.
"""
@@ -461,6 +461,3 @@ def bounded_git_probe(argv: Sequence[str], *, timeout: float) -> str:
return ""
return (result.stdout or "").strip()
# Backward-compat alias — existing call sites/tests import the historical name.
_kill_git_process_tree = kill_process_tree
-1
View File
@@ -23,7 +23,6 @@ from utils import (
_preserve_file_mode, _preserve_file_owner, _restore_file_mode, _restore_file_owner, atomic_replace,
)
# Private alias kept: claw.py and the backup tests import ``_format_size`` from here.
from hermes_cli.sizefmt import format_bytes as _format_size
logger = logging.getLogger(__name__)
+2 -1
View File
@@ -340,7 +340,8 @@ def _apply_migration(run_migrator: Callable[[bool], dict], opts: SimpleNamespace
backup_archive: Optional[Path] = None
if not opts.no_backup:
try:
from hermes_cli.backup import create_pre_migration_backup, _format_size
from hermes_cli.backup import create_pre_migration_backup
from hermes_cli.sizefmt import format_bytes as _format_size
backup_archive = create_pre_migration_backup(hermes_home=opts.hermes_home)
if backup_archive:
print()
+2 -1
View File
@@ -474,7 +474,8 @@ class CLIAgentSetupMixin:
def _init_agent(self, *, model_override: str = None, runtime_override: dict = None, request_overrides: dict | None = None) -> bool:
"""Build the agent on first use; when resuming, restore history from SQLite.
Returns True on success."""
from cli import AIAgent, ChatConsole, _cprint, _prepare_deferred_agent_startup, logger
from cli import ChatConsole, _cprint, _prepare_deferred_agent_startup, logger
from run_agent import AIAgent
if self.agent is not None:
return True
+7 -6
View File
@@ -398,9 +398,9 @@ def _refresh_tui_before_print(cli) -> None:
def _print_lightpanda_engine_status() -> None:
"""``/browser status`` line(s) about ``browser.engine: lightpanda`` — silent unless set;
says whether it is in use or which higher-precedence setting shadows it."""
if not _probe("tools.browser_tool", "_using_lightpanda_engine", False):
if not _probe("tools.browser_tool_lightpanda_fallback", "_using_lightpanda_engine", False):
return
used, reason = _probe("tools.browser_tool", "lightpanda_engine_status", (None, None))
used, reason = _probe("tools.browser_tool_lightpanda_fallback", "lightpanda_engine_status", (None, None))
if reason is None:
return
if not used:
@@ -521,7 +521,7 @@ def _browser_connect(cli, cdp_url: str) -> None:
"debugging and retry /browser connect")
os.environ["BROWSER_CDP_URL"] = found
# Eagerly start the CDP supervisor so pending_dialogs + frame_tree show up in the next snapshot.
_probe("tools.browser_tool", "_ensure_cdp_supervisor", None, "default")
_probe("tools.browser_tool_cdp", "_ensure_cdp_supervisor", None, "default")
_say_block("🌐 Browser connected to live Chromium-family browser via CDP", f" Endpoint: {found}")
# Tell the model the CDP browser was made available on purpose.
if hasattr(cli, '_pending_input'):
@@ -543,7 +543,8 @@ def _browser_disconnect(cli) -> None:
"(already using default mode)")
os.environ.pop("BROWSER_CDP_URL", None)
with suppress(Exception):
from tools.browser_tool import cleanup_all_browsers, _stop_cdp_supervisor
from tools.browser_tool import cleanup_all_browsers
from tools.browser_tool_cdp import _stop_cdp_supervisor
_stop_cdp_supervisor("default")
cleanup_all_browsers()
_say_block("🌐 Browser disconnected from live Chromium-family browser",
@@ -590,7 +591,7 @@ def _browser_status() -> None:
print(f"🌐 Browser: {provider.provider_name()} (cloud)")
_print_lightpanda_engine_status()
else:
engine = _probe("tools.browser_tool", "_get_browser_engine", "auto")
engine = _probe("tools.browser_tool_cloud", "_get_browser_engine", "auto")
_pr(*_LOCAL_ENGINE_LINES.get(engine, _LOCAL_ENGINE_LINES["auto"]))
if engine == "lightpanda":
_print_lightpanda_engine_status()
@@ -1708,7 +1709,7 @@ class CLICommandsMixin:
print(f" Next run: {result['next_run_at']}")
def _cron_edit(self, subcommand: str, opts: dict) -> None:
from cli import get_job
from cron import get_job
positionals = opts["positionals"]
if not positionals:
return print("(._.) Usage: /cron edit <job_id> "
+6 -4
View File
@@ -76,7 +76,8 @@ class CLIInfoMixin:
def show_banner(self):
"""Display the welcome banner in Claude Code style."""
from cli import _build_compact_banner, build_welcome_banner, get_tool_definitions, logger
from cli import _build_compact_banner, get_tool_definitions, logger
from hermes_cli.banner import build_welcome_banner
self.console.clear()
ctx_len = None
if hasattr(self, 'agent') and self.agent and hasattr(self.agent, 'context_compressor'):
@@ -333,7 +334,8 @@ class CLIInfoMixin:
def show_tools(self):
"""Display available tools with kawaii ASCII art."""
from cli import get_tool_definitions, get_toolset_for_tool
from cli import get_tool_definitions
from model_tools import get_toolset_for_tool
# Pre-assembly list: /tools is a discovery surface, so it must show the full catalog
# including tools deferred behind the tool_search bridge (users verify MCP installs here).
tools = get_tool_definitions(enabled_toolsets=self.enabled_toolsets, quiet_mode=True,
@@ -367,7 +369,7 @@ class CLIInfoMixin:
def show_toolsets(self):
"""Display available toolsets with kawaii ASCII art."""
from cli import get_all_toolsets, get_toolset_info
from toolsets import get_all_toolsets, get_toolset_info
all_toolsets = get_all_toolsets()
print()
@@ -475,7 +477,7 @@ class CLIInfoMixin:
def _show_gateway_status(self):
"""Show status of the gateway and connected messaging platforms."""
from cli import display_hermes_home
from hermes_constants import display_hermes_home
from gateway.config import load_gateway_config, Platform
print()
+3 -4
View File
@@ -54,9 +54,8 @@ class CLILoopsMixin:
_cprint(f" {_DIM}✓ UI redrawn{_RST}")
def _cmd_clear(self, cmd_original: str):
from cli import (
ChatConsole, _build_compact_banner, _clear_output_history, _cprint,
build_welcome_banner, get_tool_definitions)
from cli import ChatConsole, _build_compact_banner, _clear_output_history, _cprint, get_tool_definitions
from hermes_cli.banner import build_welcome_banner
if self._confirm_destructive_slash(
"clear",
"This clears the screen and starts a new session.\n"
@@ -221,7 +220,7 @@ class CLILoopsMixin:
self._reload_skills()
def _cmd_plugins(self, cmd_original: str):
from cli import display_hermes_home
from hermes_constants import display_hermes_home
try:
# Discover from disk (bundled + user) like `hermes plugins list`, so
# installed-but-not-enabled plugins show up; the plugin manager only knows
+3 -3
View File
@@ -24,7 +24,7 @@ def _user_turn_indices(history: list) -> list[int]:
"""Indices of *real* user turns: excludes ephemeral scaffolding, display_kind timeline
rows and compaction handoffs — the same predicate as resume turn counting."""
from agent.context_compressor import user_originated_turn_view
from run_agent import _is_ephemeral_scaffolding
from agent.session_persistence import _is_ephemeral_scaffolding
return [
i for i, m in enumerate(history)
@@ -244,7 +244,7 @@ class CLISessionMixin:
def _show_session_status(self):
"""Show gateway-style status for the current CLI session."""
from cli import display_hermes_home
from hermes_constants import display_hermes_home
session_meta = {}
if self._session_db:
with contextlib.suppress(Exception):
@@ -715,7 +715,7 @@ class CLISessionMixin:
user_originated_turn_view)
from agent.memory_manager import sanitize_context
from agent.tool_dispatch_helpers import _is_multimodal_tool_result, _multimodal_text_summary
from run_agent import _is_ephemeral_scaffolding
from agent.session_persistence import _is_ephemeral_scaffolding
def _persistence_content(content: Any) -> Any:
"""Project warm content exactly as the session DB flush does."""
-5
View File
@@ -12,11 +12,6 @@ sys.path.insert(0, str(PROJECT_ROOT))
from hermes_cli.colors import Colors, color
# Re-exported: ``tools/terminal_tool.py`` imports it from here to hard-block the same
# gateway-lifecycle commands at execution time when ``_HERMES_GATEWAY=1``.
from cron.lifecycle_guard import ( # noqa: F401
contains_gateway_lifecycle_command as _contains_gateway_lifecycle_command)
def _normalize_skills(single_skill=None, skills: Optional[Iterable[str]] = None) -> Optional[List[str]]:
"""Deduped, stripped skill names; None when neither argument was given."""
+5 -5
View File
@@ -27,7 +27,8 @@ def _system_package_install_cmd(pkg: str) -> str:
def _sqlite_upgrade_hint(install_method: str | None = None) -> str:
"""Return an actionable SQLite upgrade hint for this install layout."""
from hermes_cli.doctor import PROJECT_ROOT, detect_install_method
from hermes_cli.doctor import PROJECT_ROOT
from hermes_cli.config import detect_install_method
method = install_method or detect_install_method(PROJECT_ROOT)
cmd = recommended_update_command_for_method(method)
action = cmd if is_nix_install_method(method) else { # nix: prose guidance, not a shell command
@@ -81,7 +82,7 @@ def _read_journal_mode(db_path: Path) -> tuple[str | None, str | None]:
def _format_db_size(db_path: Path) -> str:
from hermes_cli.backup import _format_size # backup.py owns size formatting
from hermes_cli.sizefmt import format_bytes as _format_size
try:
return _format_size(db_path.stat().st_size)
except OSError:
@@ -91,7 +92,7 @@ def _format_db_size(db_path: Path) -> str:
def _report_database_journal_modes(hermes_home: Path | None = None, version_info: tuple[int, ...] | None = None) -> None:
"""List each database's journal mode; warn on WAL under a vulnerable SQLite."""
from hermes_cli.doctor import HERMES_HOME
from hermes_state import _wal_reset_repair_hint, is_sqlite_wal_reset_vulnerable
from hermes_state_wal import _wal_reset_repair_hint, is_sqlite_wal_reset_vulnerable
vulnerable = is_sqlite_wal_reset_vulnerable(version_info)
try:
databases = _hermes_database_paths(hermes_home if hermes_home is not None else HERMES_HOME)
@@ -261,7 +262,6 @@ def check_macos_tcc_grants() -> None:
See #86385.
"""
from hermes_cli.doctor import _desktop_app_bundle, _macos_desktop_dr
app = _desktop_app_bundle() if sys.platform == "darwin" else None
if app is None:
return
@@ -379,7 +379,7 @@ def _check_python_environment(should_fix: bool, f: Finding) -> None:
# python-build-standalone can keep a vulnerable SQLite across upgrades).
with warn_on_error("SQLite version probe failed: {e}", ""):
import sqlite3
from hermes_state import is_sqlite_wal_reset_vulnerable, sqlite_source_id
from hermes_state_wal import is_sqlite_wal_reset_vulnerable, sqlite_source_id
src = sqlite_source_id()
# Warn-only: Hermes already refuses WAL on fresh DBs and runtime repair is best-effort.
check_bool(not is_sqlite_wal_reset_vulnerable(), f"SQLite {sqlite3.sqlite_version}",
+1 -1
View File
@@ -180,7 +180,7 @@ def _cleanup_worktree_workspace(
it. The auto-generated ``wt/<task-id>`` branch is deleted with it; custom
branches are kept. Best-effort."""
try:
from cli import _worktree_has_unpushed_commits, _worktree_is_dirty
from hermes_cli.worktree_ops import _worktree_has_unpushed_commits, _worktree_is_dirty
except Exception:
return # CLI safety predicates unavailable — preserve
try:
-1
View File
@@ -3,7 +3,6 @@
from __future__ import annotations
import functools
import importlib.metadata # noqa: F401 — tests patch ``plugins_cmd.importlib.metadata.entry_points``
import json
import logging
import os
+4 -5
View File
@@ -13,9 +13,7 @@ import sys
from functools import partial
from pathlib import Path
from hermes_cli.sessions_cmd_browse import ( # noqa: F401 (re-exports)
_annotate_session_statuses, _relative_time, _session_browse_picker, _session_status_tag,
)
from hermes_cli.sessions_cmd_browse import _relative_time, _session_browse_picker
def get_hermes_home():
@@ -88,7 +86,8 @@ def _write_output(output, text, summary) -> None:
# -- handlers that must run BEFORE SessionDB() is opened ----------------------
def _cmd_repair(args):
from hermes_state import DEFAULT_DB_PATH as db_path, SessionDB, _db_opens_cleanly, repair_state_db_schema
from hermes_state import DEFAULT_DB_PATH as db_path, SessionDB
from hermes_state_repair import _db_opens_cleanly, repair_state_db_schema
if not db_path.exists():
print(f"No session database at {db_path} (nothing to repair).")
return
@@ -250,7 +249,7 @@ def _default_exclude(args):
def _cmd_list(db, args):
from hermes_state import workspace_key as _ws_key
from hermes_state_sessions import workspace_key as _ws_key
sessions = db.list_sessions_rich(source=args.source, exclude_sources=_default_exclude(args), limit=args.limit)
# Workspace filter: workspace key (git repo root, else cwd) — path substring or exact basename.
+14 -14
View File
@@ -108,23 +108,23 @@ def _archive_untracked(tree: Path, untracked: List[str]) -> Optional[Path]:
return None
def _classify_tree(_cli, repo_root: str, entry: Path, merge_cache, remote_heads) -> tuple[str, str, List[str]]:
def _classify_tree(_ops, repo_root: str, entry: Path, merge_cache, remote_heads) -> tuple[str, str, List[str]]:
"""Return (verdict, reason, untracked) for one tree under ``.worktrees/``."""
path = str(entry)
if _KANBAN_RE.match(entry.name):
return "keep", "kanban task tree (owned by kanban gc)", []
if _cli._worktree_lock_is_live(repo_root, path, timeout=5) == "live":
if _ops._worktree_lock_is_live(repo_root, path, timeout=5) == "live":
return "keep", "in use by a running hermes session", []
tracked_dirty, untracked = _dirty_split(path)
if tracked_dirty:
return "keep", "uncommitted tracked changes (real work)", []
archive_note = f"{len(untracked)} untracked file(s) will be archived"
if _cli._worktree_has_unpushed_commits(path, timeout=5) and not _cli._worktree_commits_all_merged_upstream(
if _ops._worktree_has_unpushed_commits(path, timeout=5) and not _ops._worktree_commits_all_merged_upstream(
path, timeout=30, cache=merge_cache, max_ahead=_MAX_CHERRY_AHEAD):
# Pushed-branch tier: single-branch fetch refspecs (managed-install default) leave pushed
# PR branches with no refs/remotes/* entry, so `git log HEAD --not --remotes` reads them
# as unpushed forever. A head EXACTLY matching the remote branch has nothing origin lacks.
if not _cli._worktree_branch_pushed_exact(path, remote_heads, timeout=10):
if not _ops._worktree_branch_pushed_exact(path, remote_heads, timeout=10):
return "keep", "unpushed commits not found upstream", []
if untracked:
return "reap-keep-branch", f"pushed to origin (open-PR lane); branch kept; {archive_note}", untracked
@@ -136,18 +136,18 @@ def _classify_tree(_cli, repo_root: str, entry: Path, merge_cache, remote_heads)
def audit_worktrees(repo_root: str, *, with_sizes: bool = True) -> List[TreeRecord]:
"""Classify every tree under ``.worktrees/`` without mutating anything."""
import cli as _cli # lazy: cli.py is heavy
from hermes_cli import worktree_ops as _ops
worktrees_dir = Path(repo_root) / ".worktrees"
if not worktrees_dir.exists():
return []
if _cli._repo_is_shallow(repo_root):
_cli._deepen_shallow_repo(repo_root)
if _ops._repo_is_shallow(repo_root):
_ops._deepen_shallow_repo(repo_root)
merge_cache = _cli._load_worktree_merge_cache()
merge_cache = _ops._load_worktree_merge_cache()
cache_size_before = len(merge_cache)
# One ls-remote for the whole sweep; None (offline) degrades pushed-tier verdicts to keep.
remote_heads = _cli._fetch_remote_branch_heads(repo_root)
remote_heads = _ops._fetch_remote_branch_heads(repo_root)
now = time.time()
records: List[TreeRecord] = []
@@ -162,14 +162,14 @@ def audit_worktrees(repo_root: str, *, with_sizes: bool = True) -> List[TreeReco
branch = _git(["branch", "--show-current"], cwd=str(entry), timeout=5).stdout.strip()
except Exception:
branch = ""
verdict, reason, untracked = _classify_tree(_cli, repo_root, entry, merge_cache, remote_heads)
verdict, reason, untracked = _classify_tree(_ops, repo_root, entry, merge_cache, remote_heads)
records.append(TreeRecord(
name=entry.name, path=str(entry), branch=branch,
age_days=age_days, size_mb=_tree_size_mb(entry) if with_sizes else None,
verdict=verdict, reason=reason, untracked=untracked))
if len(merge_cache) != cache_size_before:
_cli._save_worktree_merge_cache(merge_cache)
_ops._save_worktree_merge_cache(merge_cache)
return records
@@ -225,9 +225,9 @@ def reclaim_worktrees(
def audit_branches(repo_root: str) -> List[BranchRecord]:
"""Classify EVERY local branch: deletable when fully merged OR every commit is patch-equivalent
upstream (``git cherry``) and not checked out. The gate is content reachability, not name."""
import cli as _cli
if _cli._repo_is_shallow(repo_root):
_cli._deepen_shallow_repo(repo_root)
from hermes_cli import worktree_ops as _ops
if _ops._repo_is_shallow(repo_root):
_ops._deepen_shallow_repo(repo_root)
def _lines(result) -> List[str]:
return [b.strip() for b in result.stdout.splitlines() if b.strip()]
+1 -9
View File
@@ -74,14 +74,6 @@ class TestSubprocessCompatDelegation:
assert shell_hooks.kill_process_tree is _subprocess_compat.kill_process_tree
def test_backcompat_alias_preserved(self):
from hermes_cli import _subprocess_compat
assert (
_subprocess_compat._kill_git_process_tree
is _subprocess_compat.kill_process_tree
)
# ---------------------------------------------------------------------------
# (2) tools.browser_tool._kill_process_tree
@@ -108,7 +100,7 @@ class TestBrowserToolDelegation:
monkeypatch.setattr(deadline_mod, "kill_process_tree", _boom)
legacy_calls = []
monkeypatch.setattr(
browser_tool, "_legacy_kill_process_tree", lambda proc: legacy_calls.append(proc)
"tools.browser_tool_lifecycle._legacy_kill_process_tree", lambda proc: legacy_calls.append(proc)
)
proc = _FakeProc(pid=4444)
browser_tool._kill_process_tree(proc) # must not raise
+7 -7
View File
@@ -24,7 +24,7 @@ def cli_obj(_isolate):
"display": {"tool_progress": "new"},
"terminal": {},
}), patch("cli.get_tool_definitions", return_value=[]), \
patch("cli.build_welcome_banner"):
patch("hermes_cli.banner.build_welcome_banner"):
from cli import HermesCLI
obj = HermesCLI.__new__(HermesCLI)
obj.model = "test-model"
@@ -50,7 +50,7 @@ class TestLowContextWarning:
"""Warning shown when context is below Hermes' minimum."""
cli_obj.agent.context_compressor.context_length = 32768
with patch("cli.get_tool_definitions", return_value=[]), \
patch("cli.build_welcome_banner"):
patch("hermes_cli.banner.build_welcome_banner"):
cli_obj.show_banner()
calls = [str(c) for c in cli_obj.console.print.call_args_list]
@@ -64,7 +64,7 @@ class TestLowContextWarning:
"""Warning shown for 2048 tokens (common LM Studio default)."""
cli_obj.agent.context_compressor.context_length = 2048
with patch("cli.get_tool_definitions", return_value=[]), \
patch("cli.build_welcome_banner"):
patch("hermes_cli.banner.build_welcome_banner"):
cli_obj.show_banner()
calls = [str(c) for c in cli_obj.console.print.call_args_list]
@@ -75,7 +75,7 @@ class TestLowContextWarning:
"""No warning at exactly Hermes' minimum context length."""
cli_obj.agent.context_compressor.context_length = MINIMUM_CONTEXT_LENGTH
with patch("cli.get_tool_definitions", return_value=[]), \
patch("cli.build_welcome_banner"):
patch("hermes_cli.banner.build_welcome_banner"):
cli_obj.show_banner()
calls = [str(c) for c in cli_obj.console.print.call_args_list]
@@ -86,7 +86,7 @@ class TestLowContextWarning:
"""No warning above Hermes' minimum context length."""
cli_obj.agent.context_compressor.context_length = MINIMUM_CONTEXT_LENGTH + 1
with patch("cli.get_tool_definitions", return_value=[]), \
patch("cli.build_welcome_banner"):
patch("hermes_cli.banner.build_welcome_banner"):
cli_obj.show_banner()
calls = [str(c) for c in cli_obj.console.print.call_args_list]
@@ -98,7 +98,7 @@ class TestLowContextWarning:
cli_obj.agent.context_compressor.context_length = 4096
cli_obj.base_url = "http://localhost:11434/v1"
with patch("cli.get_tool_definitions", return_value=[]), \
patch("cli.build_welcome_banner"):
patch("hermes_cli.banner.build_welcome_banner"):
cli_obj.show_banner()
calls = [str(c) for c in cli_obj.console.print.call_args_list]
@@ -112,7 +112,7 @@ class TestLowContextWarning:
cli_obj.agent.context_compressor.context_length = 4096
cli_obj.base_url = "http://localhost:8080/v1"
with patch("cli.get_tool_definitions", return_value=[]), \
patch("cli.build_welcome_banner"):
patch("hermes_cli.banner.build_welcome_banner"):
cli_obj.show_banner()
calls = [str(c) for c in cli_obj.console.print.call_args_list]
+1 -1
View File
@@ -140,7 +140,7 @@ def test_show_banner_does_not_print_skills():
cli_obj.preloaded_skills = ["hermes-agent-dev", "github-auth"]
cli_obj.console = MagicMock()
with patch("cli.build_welcome_banner") as mock_banner, patch(
with patch("hermes_cli.banner.build_welcome_banner") as mock_banner, patch(
"shutil.get_terminal_size", return_value=os.terminal_size((120, 40))
):
cli_obj.show_banner()
+1 -1
View File
@@ -249,7 +249,7 @@ def test_runtime_resolution_failure_is_not_sticky(monkeypatch):
monkeypatch.setattr("hermes_cli.runtime_provider.resolve_runtime_provider", _runtime_resolve)
monkeypatch.setattr("hermes_cli.runtime_provider.format_runtime_provider_error", lambda exc: str(exc))
monkeypatch.setattr(cli, "AIAgent", _DummyAgent)
monkeypatch.setattr("run_agent.AIAgent", _DummyAgent)
shell = cli.HermesCLI(model="gpt-5", compact=True, max_turns=1)
+2 -2
View File
@@ -78,7 +78,7 @@ def test_show_session_status_prints_gateway_style_summary():
"started_at": 1775791440,
}
with patch("cli.display_hermes_home", return_value="~/.hermes"):
with patch("hermes_constants.display_hermes_home", return_value="~/.hermes"):
cli_obj._show_session_status()
printed = "\n".join(str(call.args[0]) for call in cli_obj.console.print.call_args_list)
@@ -107,7 +107,7 @@ def test_show_session_status_includes_reasoning_approvals_context():
"context_tokens": 50000, "context_length": 200000, "context_percent": 25,
}
with patch("cli.display_hermes_home", return_value="~/.hermes"), \
with patch("hermes_constants.display_hermes_home", return_value="~/.hermes"), \
patch("tools.approval._get_approval_mode", return_value="manual"), \
patch("tools.approval.is_approval_bypass_active_for_session", return_value=False):
cli_obj._show_session_status()
@@ -5,7 +5,12 @@ leak into the input buffer after terminal resize storms or multiplexer
tab switches — see issue #14692.
"""
from cli import _strip_leaked_terminal_responses
from cli import _strip_leaked_terminal_responses_with_meta
def _strip_leaked_terminal_responses(text: str) -> str:
cleaned, _ = _strip_leaked_terminal_responses_with_meta(text)
return cleaned
class TestStripLeakedTerminalResponses:
+25 -21
View File
@@ -8,6 +8,8 @@ import os
import shutil
import subprocess
import pytest
from hermes_cli import worktree_ops
from pathlib import Path
@@ -845,7 +847,7 @@ class TestWorktreeLockReaping:
import cli
wt = self._mk(cli, git_repo, "hermes-dead", pid=999999)
# sanity: this is the accumulation bug — remove --force alone can't do it
assert cli._worktree_lock_is_live(str(git_repo), str(wt)) == "dead"
assert worktree_ops._worktree_lock_is_live(str(git_repo), str(wt)) == "dead"
cli._prune_stale_worktrees(str(git_repo))
assert not wt.exists(), "dead-locked clean worktree should be unlocked + reaped"
@@ -899,19 +901,19 @@ class TestWorktreeLockPredicate:
["git", "worktree", "add", str(p), "-b", "hermes/hermes-x", "HEAD"],
cwd=git_repo, capture_output=True,
)
assert cli._worktree_lock_is_live(str(git_repo), str(p)) is None
assert worktree_ops._worktree_lock_is_live(str(git_repo), str(p)) is None
def test_foreign_lock_reason_returns_dead(self, git_repo):
import cli
p = self._mk_locked(git_repo, "hermes-foreign", "some other tool")
assert cli._worktree_lock_is_live(str(git_repo), str(p)) == "dead"
assert worktree_ops._worktree_lock_is_live(str(git_repo), str(p)) == "dead"
def test_bad_repo_root_fails_safe_to_live(self, tmp_path):
import cli
# Not a git repo -> git query fails -> must report "live" (never delete)
assert cli._worktree_lock_is_live(str(tmp_path), str(tmp_path / "x")) == "live"
assert worktree_ops._worktree_lock_is_live(str(tmp_path), str(tmp_path / "x")) == "live"
class TestWidenedPruner:
@@ -1021,7 +1023,7 @@ class TestWidenedPruner:
["git", "worktree", "add", str(p), "-b", "wt/noremote", "HEAD"],
cwd=repo, capture_output=True,
)
assert cli._worktree_commits_all_merged_upstream(str(p)) is False
assert worktree_ops._worktree_commits_all_merged_upstream(str(p)) is False
# -- preserved-work warning ----------------------------------------------
@@ -1050,10 +1052,10 @@ class TestMergeVerdictCache:
wt, sha = self._mk(git_repo, "hermes-cachehit", commit=True)
self._merge_upstream(git_repo, sha)
uncached = cli._worktree_commits_all_merged_upstream(str(wt))
uncached = worktree_ops._worktree_commits_all_merged_upstream(str(wt))
cache = {}
cold = cli._worktree_commits_all_merged_upstream(str(wt), cache=cache)
warm = cli._worktree_commits_all_merged_upstream(str(wt), cache=cache)
cold = worktree_ops._worktree_commits_all_merged_upstream(str(wt), cache=cache)
warm = worktree_ops._worktree_commits_all_merged_upstream(str(wt), cache=cache)
assert cache, "verdict should have been memoized"
assert uncached is cold is warm is True
@@ -1070,7 +1072,7 @@ class TestMergeVerdictCache:
self._merge_upstream(git_repo, sha)
cache = {}
assert cli._worktree_commits_all_merged_upstream(str(wt), cache=cache) is True
assert worktree_ops._worktree_commits_all_merged_upstream(str(wt), cache=cache) is True
key_after_merge = set(cache)
# New local-only work lands in the worktree.
@@ -1078,7 +1080,7 @@ class TestMergeVerdictCache:
subprocess.run(["git", "add", "more.txt"], cwd=wt, capture_output=True)
subprocess.run(["git", "commit", "-m", "new work"], cwd=wt, capture_output=True)
assert cli._worktree_commits_all_merged_upstream(str(wt), cache=cache) is False
assert worktree_ops._worktree_commits_all_merged_upstream(str(wt), cache=cache) is False
assert set(cache) != key_after_merge, "moved HEAD must produce a new key"
@@ -1091,8 +1093,8 @@ class TestMergeVerdictCache:
monkeypatch.setattr(worktree_ops, "_worktree_merge_cache_path", lambda: path)
monkeypatch.setattr(worktree_ops, "_WORKTREE_MERGE_CACHE_MAX", 10)
cli._save_worktree_merge_cache({f"sha{i}..sha{i}:20": True for i in range(50)})
assert len(cli._load_worktree_merge_cache()) == 10
worktree_ops._save_worktree_merge_cache({f"sha{i}..sha{i}:20": True for i in range(50)})
assert len(worktree_ops._load_worktree_merge_cache()) == 10
class TestPruneParallelEquivalence:
@@ -1151,7 +1153,7 @@ class TestPruneParallelEquivalence:
# Phase B — an independent board (distinct branch names so creation
# can't collide with phase A's leftover refs) run through a real pool.
try:
cli._worktree_merge_cache_path().unlink()
worktree_ops._worktree_merge_cache_path().unlink()
except Exception:
pass
board2 = self._board(git_repo, tag="b")
@@ -1177,7 +1179,9 @@ class TestPruneParallelEquivalence:
def __init__(self, *a, **kw):
raise RuntimeError("cannot start thread")
monkeypatch.setattr(cli.concurrent.futures, "ThreadPoolExecutor", _Boom)
import concurrent.futures
monkeypatch.setattr(concurrent.futures, "ThreadPoolExecutor", _Boom)
monkeypatch.setattr(cli.os, "cpu_count", lambda: 8)
cli._prune_stale_worktrees(str(git_repo))
@@ -1289,7 +1293,7 @@ class TestShallowCloneDeepening:
_, clone, wt = self._stuck_worktree(tmp_path)
assert cli._worktree_has_unpushed_commits(str(wt))
assert cli._deepen_shallow_repo(str(clone)) is True
assert worktree_ops._deepen_shallow_repo(str(clone)) is True
assert not cli._repo_is_shallow(str(clone))
assert not cli._worktree_has_unpushed_commits(str(wt)), (
"after deepening, the worktree's HEAD is an ancestor of "
@@ -1317,7 +1321,7 @@ class TestShallowCloneDeepening:
["git", "remote", "set-url", "origin", f"file://{tmp_path}/gone"],
clone,
)
assert cli._deepen_shallow_repo(str(clone), timeout=30) is False
assert worktree_ops._deepen_shallow_repo(str(clone), timeout=30) is False
cli._prune_stale_worktrees(str(clone))
assert wt.exists(), (
"offline deepen failure must fall back to preserving the tree"
@@ -1325,7 +1329,7 @@ class TestShallowCloneDeepening:
def test_deepen_noop_on_full_clone(self, git_repo):
import cli
assert cli._deepen_shallow_repo(str(git_repo)) is True
assert worktree_ops._deepen_shallow_repo(str(git_repo)) is True
def test_real_unpushed_work_survives_deepening(self, tmp_path):
"""Deepening must not turn genuinely unpushed commits reapable."""
@@ -1384,7 +1388,7 @@ class TestPrMergedEscapeHatch:
def test_merged_pr_tree_is_reaped(self, git_repo, tmp_path, monkeypatch):
import cli
wt = self._mk_diverged(git_repo, "hermes-rebase-merged")
assert cli._worktree_commits_all_merged_upstream(str(wt)) is False, (
assert worktree_ops._worktree_commits_all_merged_upstream(str(wt)) is False, (
"precondition: cherry must NOT consider this merged — the PR "
"check is the only thing that can reap it"
)
@@ -1426,19 +1430,19 @@ class TestPrMergedEscapeHatch:
wt = self._mk_diverged(git_repo, "hermes-memo")
self._stub_gh(tmp_path, monkeypatch)
cache: dict = {}
assert cli._worktree_branch_pr_merged(str(wt), cache=cache) is True
assert worktree_ops._worktree_branch_pr_merged(str(wt), cache=cache) is True
keys = [k for k in cache if k.startswith("pr-merged:")]
assert len(keys) == 1 and cache[keys[0]] is True
# Break gh: a cached True verdict must not re-consult it.
self._stub_gh(tmp_path, monkeypatch, stdout="", exit_code=1)
assert cli._worktree_branch_pr_merged(str(wt), cache=cache) is True
assert worktree_ops._worktree_branch_pr_merged(str(wt), cache=cache) is True
def test_negative_verdict_not_cached(self, git_repo, tmp_path, monkeypatch):
import cli
wt = self._mk_diverged(git_repo, "hermes-nocache-neg")
self._stub_gh(tmp_path, monkeypatch, stdout="[]")
cache: dict = {}
assert cli._worktree_branch_pr_merged(str(wt), cache=cache) is False
assert worktree_ops._worktree_branch_pr_merged(str(wt), cache=cache) is False
assert not [k for k in cache if k.startswith("pr-merged:")], (
"False must not be memoized — the PR can merge later with the "
"same (branch, head) key"
+12 -10
View File
@@ -15,6 +15,8 @@ import time
import pytest
from hermes_cli import worktree_ops
def _run(args, cwd):
return subprocess.run(
@@ -93,7 +95,7 @@ class TestFetchRemoteBranchHeads:
import cli
repo = repo_with_bare_origin
_mk_worktree(repo, "hermes-a", "hermes/hermes-a", push=True)
heads = cli._fetch_remote_branch_heads(str(repo))
heads = worktree_ops._fetch_remote_branch_heads(str(repo))
assert heads is not None
assert "main" in heads
assert "hermes/hermes-a" in heads
@@ -104,7 +106,7 @@ class TestFetchRemoteBranchHeads:
repo.mkdir()
_run(["git", "init"], repo)
_run(["git", "remote", "add", "origin", str(tmp_path / "missing.git")], repo)
assert cli._fetch_remote_branch_heads(str(repo)) is None
assert worktree_ops._fetch_remote_branch_heads(str(repo)) is None
class TestBranchPushedExact:
@@ -112,8 +114,8 @@ class TestBranchPushedExact:
import cli
repo = repo_with_bare_origin
wt = _mk_worktree(repo, "hermes-x", "hermes/hermes-x", push=True)
heads = cli._fetch_remote_branch_heads(str(repo))
assert cli._worktree_branch_pushed_exact(str(wt), heads) is True
heads = worktree_ops._fetch_remote_branch_heads(str(repo))
assert worktree_ops._worktree_branch_pushed_exact(str(wt), heads) is True
def test_local_ahead_of_push_false(self, repo_with_bare_origin):
import cli
@@ -122,22 +124,22 @@ class TestBranchPushedExact:
repo, "hermes-y", "hermes/hermes-y",
push=True, extra_commit_after_push=True,
)
heads = cli._fetch_remote_branch_heads(str(repo))
assert cli._worktree_branch_pushed_exact(str(wt), heads) is False
heads = worktree_ops._fetch_remote_branch_heads(str(repo))
assert worktree_ops._worktree_branch_pushed_exact(str(wt), heads) is False
def test_never_pushed_false(self, repo_with_bare_origin):
import cli
repo = repo_with_bare_origin
wt = _mk_worktree(repo, "hermes-z", "hermes/hermes-z", push=False)
heads = cli._fetch_remote_branch_heads(str(repo))
assert cli._worktree_branch_pushed_exact(str(wt), heads) is False
heads = worktree_ops._fetch_remote_branch_heads(str(repo))
assert worktree_ops._worktree_branch_pushed_exact(str(wt), heads) is False
def test_none_heads_false(self, repo_with_bare_origin):
import cli
repo = repo_with_bare_origin
wt = _mk_worktree(repo, "hermes-n", "hermes/hermes-n", push=True)
assert cli._worktree_branch_pushed_exact(str(wt), None) is False
assert cli._worktree_branch_pushed_exact(str(wt), {}) is False
assert worktree_ops._worktree_branch_pushed_exact(str(wt), None) is False
assert worktree_ops._worktree_branch_pushed_exact(str(wt), {}) is False
class TestStartupPrunerPushedTier:
+3 -3
View File
@@ -53,7 +53,7 @@ class TestCleanupFailedWorktreeAdd:
return wt
def test_sweeps_dir_admin_entry_and_branch(self, repo):
from cli import _cleanup_failed_worktree_add
from hermes_cli.worktree_ops import _cleanup_failed_worktree_add
wt = self._simulate_timed_out_add(repo)
admin = repo / ".git" / "worktrees" / "hermes-dead00"
@@ -68,7 +68,7 @@ class TestCleanupFailedWorktreeAdd:
def test_retry_succeeds_after_cleanup(self, repo):
"""The whole point: the same worktree name is creatable again."""
from cli import _cleanup_failed_worktree_add
from hermes_cli.worktree_ops import _cleanup_failed_worktree_add
wt = self._simulate_timed_out_add(repo)
_cleanup_failed_worktree_add(str(repo), wt, "hermes/hermes-dead00")
@@ -80,7 +80,7 @@ class TestCleanupFailedWorktreeAdd:
def test_noop_when_nothing_exists(self, repo):
"""Fail-soft on an error path where git never created anything."""
from cli import _cleanup_failed_worktree_add
from hermes_cli.worktree_ops import _cleanup_failed_worktree_add
_cleanup_failed_worktree_add(
str(repo), repo / ".worktrees" / "never-existed", "hermes/never-existed"
+9 -7
View File
@@ -4,7 +4,7 @@ A worktree created off the standalone clone's local ``HEAD`` roots the new
branch on a stale base when that clone lags the remote. ``_resolve_worktree_base``
fetches and branches from the remote tip instead so the worktree starts current.
These tests exercise the REAL ``cli._resolve_worktree_base`` /
These tests exercise the REAL ``worktree_ops._resolve_worktree_base`` /
``cli._setup_worktree`` against a real local "remote" repo (so ``git fetch``
works offline in the hermetic sandbox), proving the worktree includes commits
that exist on the remote but not on the stale local HEAD.
@@ -17,6 +17,8 @@ from pathlib import Path
import pytest
from hermes_cli import worktree_ops
import cli
@@ -76,7 +78,7 @@ def remote_and_clone(tmp_path):
class TestResolveWorktreeBase:
def test_resolves_to_fetched_upstream(self, remote_and_clone):
clone, remote_head, stale_local_head = remote_and_clone
base_ref, label = cli._resolve_worktree_base(str(clone))
base_ref, label = worktree_ops._resolve_worktree_base(str(clone))
# Should resolve to the upstream tracking ref and have fetched it.
assert base_ref == "origin/main"
assert "fetched" in label
@@ -92,7 +94,7 @@ class TestResolveWorktreeBase:
_run(["git", "config", "user.email", "t@t.com"], repo)
_run(["git", "config", "user.name", "T"], repo)
_commit(repo, "README.md", "only commit")
base_ref, label = cli._resolve_worktree_base(str(repo))
base_ref, label = worktree_ops._resolve_worktree_base(str(repo))
assert base_ref == "HEAD"
assert "HEAD" in label
@@ -119,7 +121,7 @@ class TestResolveWorktreeBaseStartupCost:
return real_run(args, **kw)
monkeypatch.setattr(subprocess, "run", spy)
base_ref, label = cli._resolve_worktree_base(str(clone))
base_ref, label = worktree_ops._resolve_worktree_base(str(clone))
assert base_ref == "origin/main"
assert "fetched" in label and "ago" in label
assert calls == [], "fresh FETCH_HEAD must skip the network fetch"
@@ -134,7 +136,7 @@ class TestResolveWorktreeBaseStartupCost:
fetch_head = Path(clone) / ".git" / "FETCH_HEAD"
old = time.time() - 3600
os.utime(fetch_head, (old, old))
base_ref, label = cli._resolve_worktree_base(str(clone))
base_ref, label = worktree_ops._resolve_worktree_base(str(clone))
assert base_ref == "origin/main"
assert label == "origin/main (fetched)"
@@ -154,7 +156,7 @@ class TestResolveWorktreeBaseStartupCost:
monkeypatch.setattr(subprocess, "run", stall_fetches)
start = time.monotonic()
base_ref, label = cli._resolve_worktree_base(str(clone))
base_ref, label = worktree_ops._resolve_worktree_base(str(clone))
elapsed = time.monotonic() - start
# Cached tracking ref, single fetch attempt, no step-2 cascade.
assert base_ref == "origin/main"
@@ -185,7 +187,7 @@ class TestResolveWorktreeBaseStartupCost:
_run(
["git", "config", "branch.main.merge", "refs/heads/main"], repo
)
base_ref, label = cli._resolve_worktree_base(str(repo))
base_ref, label = worktree_ops._resolve_worktree_base(str(repo))
assert base_ref == "HEAD"
assert "HEAD" in label
+3 -3
View File
@@ -632,16 +632,16 @@ class TestRoundTrip:
class TestFormatSize:
def test_bytes(self):
from hermes_cli.backup import _format_size
from hermes_cli.sizefmt import format_bytes as _format_size
assert _format_size(512) == "512 B"
def test_kilobytes(self):
from hermes_cli.backup import _format_size
from hermes_cli.sizefmt import format_bytes as _format_size
assert "KB" in _format_size(2048)
def test_terabytes(self):
from hermes_cli.backup import _format_size
from hermes_cli.sizefmt import format_bytes as _format_size
assert "TB" in _format_size(2 * 1024 ** 4)
@@ -12,10 +12,8 @@ from argparse import Namespace
import pytest
from hermes_cli.cron import (
_contains_gateway_lifecycle_command,
cron_command,
)
from cron.lifecycle_guard import contains_gateway_lifecycle_command as _contains_gateway_lifecycle_command
from hermes_cli.cron import cron_command
# ---------------------------------------------------------------------------
+5 -5
View File
@@ -3,7 +3,7 @@
Timing out a git probe must not leave helper descendants (credential helpers,
``git-remote-https``, hook children) running after the probe returns. The
probe spawns the child in its own process group (``process_group=0``) and
``_kill_git_process_tree`` signals the whole group with ``os.killpg`` — but
``kill_process_tree`` signals the whole group with ``os.killpg`` — but
only when the child actually leads its own group, so a shared-group spawn can
never take down unrelated processes.
@@ -20,7 +20,7 @@ import time
import pytest
from hermes_cli import _subprocess_compat
from hermes_cli._subprocess_compat import _kill_git_process_tree, bounded_git_probe
from hermes_cli._subprocess_compat import bounded_git_probe, kill_process_tree
pytestmark = pytest.mark.skipif(
sys.platform == "win32", reason="POSIX process-group semantics"
@@ -94,7 +94,7 @@ def test_posix_spawn_uses_own_process_group(tmp_path):
def test_group_kill_skipped_when_child_shares_our_group():
"""_kill_git_process_tree must never killpg a group the child doesn't lead.
"""kill_process_tree must never killpg a group the child doesn't lead.
Spawn WITHOUT process_group=0 (child inherits OUR group): the ownership
check (pgid == pid) must skip the group signal, or the test process itself
@@ -107,7 +107,7 @@ def test_group_kill_skipped_when_child_shares_our_group():
stdin=subprocess.DEVNULL,
)
assert os.getpgid(proc.pid) == os.getpgid(0) # shared group precondition
_kill_git_process_tree(proc)
kill_process_tree(proc)
proc.wait(timeout=5)
# We are alive to make this assertion — killpg on our own group would have
# taken the test runner down. The direct child is still killed.
@@ -132,4 +132,4 @@ def test_kill_helper_swallow_all_failures():
def kill(self):
raise OSError("already reaped")
_kill_git_process_tree(_Dead()) # must not raise
kill_process_tree(_Dead()) # must not raise
@@ -144,7 +144,9 @@ def test_tree_dirtied_between_check_and_removal_preserved(
(wt / "late-wip.txt").write_text("dirtied after the check\n", encoding="utf-8")
# Pre-check lies (as if the file appeared just after it ran) — real git
# must still refuse the removal.
monkeypatch.setattr(cli, "_worktree_is_dirty", lambda _p: False)
from hermes_cli import worktree_ops
monkeypatch.setattr(worktree_ops, "_worktree_is_dirty", lambda _p: False)
kb._cleanup_worktree_workspace("t_gggg7777", str(wt))
assert wt.is_dir()
assert (wt / "late-wip.txt").exists()
+3 -2
View File
@@ -1,3 +1,4 @@
import importlib.metadata
import argparse
import json
from types import SimpleNamespace
@@ -75,7 +76,7 @@ def test_discover_all_plugins_includes_entrypoint_plugins(monkeypatch, tmp_path)
lambda: bundled_dir,
)
monkeypatch.setattr(
plugins_cmd.importlib.metadata,
importlib.metadata,
"entry_points",
lambda: [entry_point],
)
@@ -117,7 +118,7 @@ def test_declared_capabilities_for_entrypoint_uses_distribution_metadata(
"hermes_cli.plugins.get_bundled_plugins_dir", lambda: bundled_dir
)
monkeypatch.setattr(
plugins_cmd.importlib.metadata,
importlib.metadata,
"entry_points",
lambda: [plugin_ep, capability_ep],
)
@@ -15,7 +15,8 @@ from typing import Any
import pytest
from hermes_cli import lifecycle, plugins
from hermes_cli.observability import relay_runtime, relay_shared_metrics
from agent import relay_runtime
from hermes_cli.observability import relay_shared_metrics
from hermes_cli.plugins import PluginManager
+2 -2
View File
@@ -284,7 +284,7 @@ class TestPreToolCallBlocking:
monkeypatch.setattr("hermes_cli.plugins.invoke_hook", fake_invoke_hook)
monkeypatch.setattr("model_tools.registry.dispatch",
lambda *a, **kw: (_ for _ in ()).throw(AssertionError("should not run")))
monkeypatch.setattr("tools.file_tools.notify_other_tool_call",
monkeypatch.setattr("tools.file_tools_read_tracking.notify_other_tool_call",
lambda task_id: notifications.append(task_id))
result = json.loads(handle_function_call("web_search", {"q": "test"}, task_id="t1"))
@@ -327,7 +327,7 @@ class TestPreToolCallBlocking:
return json.dumps({"ok": True})
monkeypatch.setattr(
"hermes_cli.observability.relay_runtime.apply_tool_request_intercepts",
"agent.relay_runtime.apply_tool_request_intercepts",
rewrite,
)
monkeypatch.setattr("hermes_cli.plugins.invoke_hook", fake_invoke_hook)
+6 -6
View File
@@ -20726,7 +20726,7 @@ def test_fallback_session_info_reports_session_cwd_not_launch_dir(monkeypatch):
wrong project for any session resumed without a built agent (#71254).
"""
monkeypatch.setattr(server, "_default_session_cwd", lambda: "/gateway/launch/dir")
monkeypatch.setattr(server, "_git_branch_for_cwd", lambda cwd: "bb/feature")
monkeypatch.setattr(server.git_probe, "branch", lambda cwd: "bb/feature")
monkeypatch.setattr(server, "_project_info_for_cwd", lambda cwd: None)
monkeypatch.setattr(server, "_resolve_model", lambda: "test-model")
@@ -20743,7 +20743,7 @@ def test_fallback_session_info_always_emits_branch(monkeypatch):
after switching into a non-git session.
"""
monkeypatch.setattr(server, "_default_session_cwd", lambda: "/gateway/launch/dir")
monkeypatch.setattr(server, "_git_branch_for_cwd", lambda cwd: "")
monkeypatch.setattr(server.git_probe, "branch", lambda cwd: "")
monkeypatch.setattr(server, "_project_info_for_cwd", lambda cwd: None)
monkeypatch.setattr(server, "_resolve_model", lambda: "test-model")
@@ -22238,13 +22238,13 @@ def test_workspace_move_rehomes_running_session(monkeypatch, tmp_path):
monkeypatch.setattr(server, "_profile_db", _fake_db)
monkeypatch.setattr(
server,
"_git_branch_for_cwd",
server.git_probe,
"branch",
lambda cwd: "main",
)
monkeypatch.setattr(
server,
"_git_common_repo_root_for_cwd",
server.git_probe,
"common_repo_root",
lambda cwd: str(new_cwd),
)
+1 -1
View File
@@ -749,7 +749,7 @@ class TestGitBashPathNormalization:
def test_posix_noop(self):
"""Must NOT mutate paths on Linux/macOS."""
from cli import _normalize_git_bash_path
from hermes_cli.worktree_ops import _normalize_git_bash_path
if sys.platform != "win32":
assert _normalize_git_bash_path("/home/teknium/foo") == "/home/teknium/foo"
assert _normalize_git_bash_path("/c/Users/foo") == "/c/Users/foo"
+2 -2
View File
@@ -370,7 +370,7 @@ class TestDelegationCleanup:
reset_hermes_home_override,
set_hermes_home_override,
)
from hermes_cli.observability import relay_runtime
from agent import relay_runtime
from tools.delegate_tool import _run_single_child
parent = MagicMock()
@@ -415,7 +415,7 @@ class TestDelegationCleanup:
def test_active_child_turn_owns_relay_scope_cleanup(self, monkeypatch):
from unittest.mock import MagicMock
from hermes_cli.observability import relay_runtime
from agent import relay_runtime
from tools.delegate_tool import _run_single_child
parent = MagicMock()
+1 -1
View File
@@ -999,7 +999,7 @@ def test_session_resume_active_turn_payload_matches_desktop_fixture(server, monk
"session_key": fixture["session_key"],
}
monkeypatch.setattr(server, "_get_db", lambda: _DB())
monkeypatch.setattr(server, "_session_info", lambda _agent: fixture["info"])
monkeypatch.setattr(server, "_session_info", lambda _agent, _session=None: fixture["info"])
# JSON round-trip the real RPC envelope: the desktop fixture must stay
# faithful to what the gateway actually serializes, not a copied shape.
@@ -32,11 +32,11 @@ def test_cwd_claim_precedes_probe_and_generation_reaches_publish(monkeypatch):
monkeypatch.setattr(server, "_get_db", lambda: DB())
monkeypatch.setattr(server.threading, "Thread", _ImmediateThread)
monkeypatch.setattr(
server,
"_git_branch_for_cwd",
server.git_probe,
"branch",
lambda cwd: events.append(("probe", cwd)) or "feature",
)
monkeypatch.setattr(server, "_git_common_repo_root_for_cwd", lambda _cwd: "/repo")
monkeypatch.setattr(server.git_probe, "common_repo_root", lambda _cwd: "/repo")
generation = server._persist_session_cwd_and_schedule_git_meta(
{"session_key": "session"}, "/repo/worktree"
@@ -61,7 +61,7 @@ def test_missing_db_claim_never_starts_git_probe(monkeypatch):
probed = []
monkeypatch.setattr(server, "_get_db", lambda: None)
monkeypatch.setattr(
server, "_git_branch_for_cwd", lambda cwd: probed.append(cwd)
server.git_probe, "branch", lambda cwd: probed.append(cwd)
)
generation = server._persist_session_cwd_and_schedule_git_meta(
+1 -1
View File
@@ -145,7 +145,7 @@ def _apply_project_workspace(task_id: str, path: str, _name: str = "") -> None:
try:
agent = session.get("agent")
info = _session_info(agent, session) if agent is not None else {
"cwd": resolved, "branch": _git_branch_for_cwd(resolved),
"cwd": resolved, "branch": git_probe.branch(resolved),
"project": _project_info_for_cwd(resolved), "lazy": True}
_emit("session.info", sid, info)
except Exception:
+1 -5
View File
@@ -75,11 +75,7 @@ def _metadata_mirror(session: dict | None) -> dict:
def _compute_host_session_info(session: dict) -> dict:
# Tolerate a legacy one-arg _session_info (tests patch it that way).
try:
return _session_info(session.get("agent"), session)
except TypeError:
return _session_info(session.get("agent"))
return _session_info(session.get("agent"), session)
def _compute_host_adopt_frame_meta(session: dict, frame: dict) -> None:
+1 -1
View File
@@ -139,7 +139,7 @@ def _cfg_get_provider(params):
def _cfg_get_project(params):
raw = str(params.get("cwd", "") or (_load_cfg().get("terminal") or {}).get("cwd", "") or "").strip()
cwd = _completion_cwd({"cwd": raw} if raw else {})
return {"cwd": cwd, "branch": _git_branch_for_cwd(cwd)}
return {"cwd": cwd, "branch": git_probe.branch(cwd)}
def _cfg_get_personality(params):
+1 -1
View File
@@ -401,7 +401,7 @@ def _set_cwd(rid, params, key, value, session):
return _err(rid, 4002, f"working directory does not exist: {raw}")
_write_config_key("terminal.cwd", cwd)
os.environ["TERMINAL_CWD"] = cwd
return _kv(rid, "terminal.cwd", cwd, cwd=cwd, branch=_git_branch_for_cwd(cwd))
return _kv(rid, "terminal.cwd", cwd, cwd=cwd, branch=git_probe.branch(cwd))
@_cfgset_guarded
+3 -3
View File
@@ -125,7 +125,7 @@ def _(rid, params, pdb, conn) -> dict:
proj = pdb.project_for_path(conn, cwd)
return _ok(rid, {
"project": proj.to_dict() if proj else None, "cwd": cwd,
"branch": _git_branch_for_cwd(cwd)})
"branch": git_probe.branch(cwd)})
def _non_workspace_dirs() -> set[str]:
@@ -277,7 +277,7 @@ def _discover_repos_payload(
cwd_to_root: dict[str, str] = {}
for row in cwd_rows:
cwd = str(row.get("cwd") or "")
root = _git_common_repo_root_for_cwd(cwd)
root = git_probe.common_repo_root(cwd)
if not root:
continue
cwd_to_root[cwd] = root
@@ -386,7 +386,7 @@ def _build_project_tree(
[str(f.get("path") or "") for p in projects for f in (p.get("folders") or [])]
+ [str(r.get("root") or "") for r in discovered])
tree = project_tree.build_tree(
projects, sessions, discovered, _resolve_cwd_git, preview_limit=preview_limit,
projects, sessions, discovered, git_probe.resolve, preview_limit=preview_limit,
hydrate=hydrate, is_junk_root=_is_repo_junk, is_junk_cwd=_is_session_cwd_junk,
exists=_dir_exists_cached)
return tree, active_id
+3 -3
View File
@@ -117,7 +117,7 @@ def _cwd_info(session: dict, cwd: str, branch=None) -> dict:
"""session.info after a cwd change: the full agent view, or the lazy shape."""
if (agent := session.get("agent")) is not None:
return _session_info(agent, session)
return {"cwd": cwd, "branch": _git_branch_for_cwd(cwd) if branch is None else branch,
return {"cwd": cwd, "branch": git_probe.branch(cwd) if branch is None else branch,
"project": _project_info_for_cwd(cwd), "lazy": True}
@@ -355,7 +355,7 @@ def _(rid, params: dict) -> dict:
# Reflect the override now so the client doesn't clobber its sticky pick.
"info": {"model": override.get("model") if override else _resolve_model(),
**({"provider": override["provider"]} if override.get("provider") else {}),
"tools": {}, "skills": {}, "cwd": cwd, "branch": _git_branch_for_cwd(cwd),
"tools": {}, "skills": {}, "cwd": cwd, "branch": git_probe.branch(cwd),
"project": _project_info_for_cwd(cwd), "lazy": True, "desktop_contract": DESKTOP_BACKEND_CONTRACT,
"profile_name": _response_profile_name(profile)}})
@@ -851,7 +851,7 @@ def _(rid, params: dict) -> dict:
with _sessions_lock:
live_sid, live = next(
((sid, sess) for sid, sess in list(_sessions.items()) if sess.get("session_key") == target), ("", None))
branch, root = _git_branch_for_cwd(resolved), _git_common_repo_root_for_cwd(resolved)
branch, root = git_probe.branch(resolved), git_probe.common_repo_root(resolved)
with _profile_db(params) as db:
if db is None:
return _db_unavailable_error(rid, code=5007)
+4 -4
View File
@@ -31,7 +31,7 @@ from agent.replay_cleanup import sanitize_replay_history
from agent.compaction_display import project_compaction_message_for_display # noqa: F401
from agent.skill_commands import describe_skill_invocation # noqa: F401
from agent.conversation_loop import INTERRUPT_WAITING_FOR_MODEL_PREFIX # noqa: F401
from tui_gateway import git_probe # noqa: F401
from tui_gateway import git_probe
from tui_gateway._env import env_float, env_int
from tui_gateway.turn_marker import clear_turn_marker, read_turn_marker, record_turn_start # noqa: F401
from tui_gateway.transport import (StdioTransport, Transport, bind_transport, current_transport, reset_transport)
@@ -2062,7 +2062,7 @@ def _session_info(agent, session: dict | None = None) -> dict:
"yolo": yolo, "approval_mode": approval_mode,
"tools": dict(mirror.get("tools") or {}) if isinstance(mirror.get("tools"), dict) else {},
"skills": dict(mirror.get("skills") or {}) if isinstance(mirror.get("skills"), dict) else {},
"cwd": cwd, "branch": _git_branch_for_cwd(cwd), "project": _project_info_for_cwd(cwd),
"cwd": cwd, "branch": git_probe.branch(cwd), "project": _project_info_for_cwd(cwd),
"terminal_backend": _effective_terminal_backend(), "personality": str(personality or ""),
"running": bool(sess.get("running")), "turn_started_at": _turn_started_at(session),
"title": _session_live_title(sess, session_key) if session_key else "",
@@ -2390,7 +2390,7 @@ def _resolve_checkpoint_hash(mgr, cwd: str, ref: str) -> str:
def _lazy_resume_info(cwd: str, *, model: str = "", provider: str = "", profile: str | None = None) -> dict:
"""session.info for a not-yet-built session (session.create's shape); tools/skills land with the deferred build."""
return {
"cwd": cwd, "branch": _git_branch_for_cwd(cwd), "project": _project_info_for_cwd(cwd),
"cwd": cwd, "branch": git_probe.branch(cwd), "project": _project_info_for_cwd(cwd),
"model": model or _resolve_model(), "tools": {}, "skills": {}, "lazy": True,
"desktop_contract": DESKTOP_BACKEND_CONTRACT, "profile_name": _response_profile_name(profile),
**({"provider": provider} if provider else {}),
@@ -2644,7 +2644,7 @@ def _fallback_session_info(session: dict) -> dict:
# above already follows.
cwd = _session_cwd(session)
return {
"cwd": cwd, "branch": _git_branch_for_cwd(cwd), "project": _project_info_for_cwd(cwd), "lazy": True,
"cwd": cwd, "branch": git_probe.branch(cwd), "project": _project_info_for_cwd(cwd), "lazy": True,
"model": _resolve_model(), "skills": {}, "tools": {}, "desktop_contract": DESKTOP_BACKEND_CONTRACT,
}
+5 -12
View File
@@ -69,13 +69,6 @@ def _terminal_task_cwd_with_source(session: dict | None) -> tuple[str, str]:
return _completion_cwd(), "process"
# Git probing lives in git_probe; these keep the in-server names call sites use.
_git_branch_for_cwd = git_probe.branch
_git_repo_root_for_cwd = git_probe.repo_root
_git_common_repo_root_for_cwd = git_probe.common_repo_root
_resolve_cwd_git = git_probe.resolve
def _session_cwd(session: dict | None) -> str:
return str(session["cwd"]) if session and session.get("cwd") else _completion_cwd()
@@ -116,7 +109,7 @@ def _heal_dead_cwd(cwd: str) -> str:
if not os.path.isdir(probe):
return raw
with contextlib.suppress(Exception):
return _git_common_repo_root_for_cwd(probe) or _git_repo_root_for_cwd(probe) or probe
return git_probe.common_repo_root(probe) or git_probe.repo_root(probe) or probe
return probe
@@ -170,11 +163,11 @@ def _reconcile_session_cwd_from_terminal(session: dict | None) -> bool:
return False
# Worktree ROOTS (folding to the common root would hide the move), both in a git tree, different from each other,
# sharing the SAME common .git dir.
landed, current_root = _git_repo_root_for_cwd(resolved), _git_repo_root_for_cwd(current)
landed, current_root = git_probe.repo_root(resolved), git_probe.repo_root(current)
if not landed or not current_root or landed == current_root:
return False
landed_common = _git_common_repo_root_for_cwd(resolved)
if not landed_common or landed_common != _git_common_repo_root_for_cwd(current):
landed_common = git_probe.common_repo_root(resolved)
if not landed_common or landed_common != git_probe.common_repo_root(current):
return False
# This is the session's workspace now (a desktop launch-artifact cwd earns a real row); the settle marker keeps it
# overridable by the NEXT settle.
@@ -490,7 +483,7 @@ def _persist_session_git_meta(session: dict, cwd: str, generation: int) -> None:
def _run() -> None:
try:
branch, root = _git_branch_for_cwd(cwd), _git_common_repo_root_for_cwd(cwd)
branch, root = git_probe.branch(cwd), git_probe.common_repo_root(cwd)
if not (branch or root):
return
with _session_db(db_session) as db: