diff --git a/README.es.md b/README.es.md
index af8558513c..037be2b3cc 100644
--- a/README.es.md
+++ b/README.es.md
@@ -34,7 +34,7 @@ Usa cualquier modelo que quieras — [Nous Portal](https://portal.nousresearch.c
## Instalación rápida
-### Linux, macOS, WSL2, Termux
+### Linux, macOS, WSL2
```bash
curl -fsSL https://hermes-agent.nousresearch.com/install.sh | bash
@@ -54,7 +54,7 @@ El instalador se encarga de todo: uv, Python 3.11, Node.js, ripgrep, ffmpeg, **y
Si ya tienes Git instalado, el instalador lo detecta y lo usa en su lugar. De lo contrario, una descarga de ~45MB de MinGit es todo lo que necesitas — no tocará ni interferirá con ningún Git del sistema.
-> **Android / Termux:** La ruta manual probada está documentada en la [guía de Termux](https://hermes-agent.nousresearch.com/docs/getting-started/termux). En Termux, Hermes instala el extra `.[termux]` curado porque el extra completo `.[all]` actualmente incluye dependencias de voz incompatibles con Android.
+> **Android:** Hermes ya no es compatible con Android ni Termux. Consulta la [página de plataformas compatibles](https://hermes-agent.nousresearch.com/docs/getting-started/platform-support) para ver las plataformas admitidas.
>
> **Windows:** Windows nativo es totalmente compatible — el comando de PowerShell de arriba instala todo. Si prefieres usar WSL2, el comando de Linux también funciona allí. La instalación nativa de Windows se encuentra en `%LOCALAPPDATA%\hermes`; WSL2 instala en `~/.hermes` como en Linux.
diff --git a/README.md b/README.md
index c051122667..57d3f91629 100644
--- a/README.md
+++ b/README.md
@@ -34,7 +34,7 @@ Use any model you want — [Nous Portal](https://portal.nousresearch.com), OpenR
## Quick Install
-### Linux, macOS, WSL2, Termux
+### Linux, macOS, WSL2
```bash
curl -fsSL https://hermes-agent.nousresearch.com/install.sh | bash
@@ -54,7 +54,7 @@ The installer handles everything: uv, Python 3.11, Node.js, ripgrep, ffmpeg, **a
If you already have Git installed, the installer detects it and uses that instead. Otherwise a ~45MB MinGit download is all you need — it won't touch or interfere with any system Git.
-> **Android / Termux:** The tested manual path is documented in the [Termux guide](https://hermes-agent.nousresearch.com/docs/getting-started/termux). On Termux, Hermes installs a curated `.[termux]` extra because the full `.[all]` extra currently pulls Android-incompatible voice dependencies.
+> **Android:** Hermes no longer supports Android or Termux. See the [platform support page](https://hermes-agent.nousresearch.com/docs/getting-started/platform-support) for the supported platforms.
>
> **Windows:** Native Windows is fully supported — the PowerShell one-liner above installs everything. If you'd rather use WSL2, the Linux command works there too. Native Windows install lives under `%LOCALAPPDATA%\hermes`; WSL2 installs under `~/.hermes` as on Linux.
diff --git a/README.ur-pk.md b/README.ur-pk.md
index 100b7461a0..56c091975e 100644
--- a/README.ur-pk.md
+++ b/README.ur-pk.md
@@ -33,7 +33,7 @@
## فوری انسٹالیشن (Quick Install)
-### لینکس (Linux)، میک او ایس (macOS)، ڈبلیو ایس ایل ٹو (WSL2)، ٹرمکس (Termux)
+### لینکس (Linux)، میک او ایس (macOS)، ڈبلیو ایس ایل ٹو (WSL2)
@@ -61,7 +61,7 @@ iex (irm https://hermes-agent.nousresearch.com/install.ps1)
اگر آپ کے پاس پہلے سے گٹ (Git) انسٹال ہے، تو انسٹالر اسے شناخت کر لیتا ہے اور اسے ہی استعمال کرتا ہے۔ بصورت دیگر آپ کو صرف ~45MB کے MinGit ڈاؤنلوڈ کی ضرورت ہوگی — یہ آپ کے سسٹم کے گٹ پر کوئی اثر نہیں ڈالے گا۔
-> **اینڈرائیڈ (Android) / ٹرمکس (Termux):** ٹیسٹ کیا گیا مینوئل طریقہ [Termux گائیڈ](https://hermes-agent.nousresearch.com/docs/getting-started/termux) میں موجود ہے۔ ٹرمکس پر ہرمیس ایک مخصوص `.[termux]` ایکسٹرا انسٹال کرتا ہے کیونکہ مکمل `.[all]` ایکسٹرا میں ایسی وائس ڈیپینڈینسیز شامل ہیں جو اینڈرائیڈ کے ساتھ مطابقت نہیں رکھتیں۔
+> **اینڈرائیڈ (Android):** ہرمیس اب اینڈرائیڈ یا ٹرمکس کو سپورٹ نہیں کرتا۔ معاونت یافتہ پلیٹ فارمز کے لیے [پلیٹ فارم سپورٹ پیج](https://hermes-agent.nousresearch.com/docs/getting-started/platform-support) دیکھیں۔
>
> **ونڈوز (Windows):** مقامی ونڈوز کی مکمل سپورٹ موجود ہے — اوپر دی گئی پاور شیل کی کمانڈ سب کچھ انسٹال کر دیتی ہے۔ اگر آپ WSL2 استعمال کرنا چاہتے ہیں، تو لینکس کی کمانڈ وہاں کام کرتی ہے۔ مقامی ونڈوز میں انسٹالیشن `%LOCALAPPDATA%\hermes` میں ہوتی ہے؛ جبکہ WSL2 میں لینکس کی طرح `~/.hermes` میں ہوتی ہے۔ ہرمیس کا وہ واحد فیچر جسے فی الحال خاص طور پر WSL2 کی ضرورت ہے وہ براؤزر پر مبنی ڈیش بورڈ چیٹ پین ہے (یہ POSIX PTY استعمال کرتا ہے — کلاسک CLI اور گیٹ وے دونوں مقامی طور پر چلتے ہیں)۔
diff --git a/README.zh-CN.md b/README.zh-CN.md
index 5ebfe1a7c5..0e96a22217 100644
--- a/README.zh-CN.md
+++ b/README.zh-CN.md
@@ -35,9 +35,9 @@
curl -fsSL https://hermes-agent.nousresearch.com/install.sh | bash
```
-支持 Linux、macOS、WSL2 和 Android (Termux)。安装程序会自动处理平台特定的配置。
+支持 Linux、macOS 和 WSL2。安装程序会自动处理平台特定的配置。
-> **Android / Termux:** 已测试的手动安装路径请参考 [Termux 指南](https://hermes-agent.nousresearch.com/docs/getting-started/termux)。在 Termux 上,Hermes 会安装精选的 `.[termux]` 扩展,因为完整的 `.[all]` 扩展会拉取 Android 不兼容的语音依赖。
+> **Android:** Hermes 不再支持 Android 或 Termux。请参阅[平台支持页面](https://hermes-agent.nousresearch.com/docs/getting-started/platform-support)了解支持的平台。
>
> **Windows:** 在 PowerShell 中运行:
> ```powershell
diff --git a/agent/skill_utils.py b/agent/skill_utils.py
index 0c893ab465..ba646c1435 100644
--- a/agent/skill_utils.py
+++ b/agent/skill_utils.py
@@ -13,7 +13,7 @@ import sys
from pathlib import Path
from typing import Any, Dict, List, Optional, Set, Tuple
-from hermes_constants import get_config_path, get_skills_dir, is_termux
+from hermes_constants import get_config_path, get_skills_dir
logger = logging.getLogger(__name__)
@@ -74,7 +74,7 @@ def read_active_org_id(skills_dir: Path) -> Optional[str]:
marker = skills_dir / ORG_MIRROR_DIR_NAME / ORG_ACTIVE_MARKER
if not marker.exists():
return None
- val = marker.read_text(encoding="utf-8").strip()
+ val = marker.read_text(encoding="utf-8-sig").strip()
return val or None
except OSError:
return None
@@ -231,21 +231,11 @@ def skill_matches_platform_list(platforms: Any) -> bool:
if not isinstance(platforms, list):
platforms = [platforms]
current = sys.platform
- running_in_termux = is_termux()
for platform in platforms:
normalized = str(platform).lower().strip()
mapped = PLATFORM_MAP.get(normalized, normalized)
if current.startswith(mapped):
return True
- # Termux runs a Linux userland on Android. Accept linux-tagged
- # skills regardless of whether sys.platform is "linux" (pre-3.13
- # Termux) or "android" (Python 3.13+ Termux, and any other
- # Android runtime).
- if running_in_termux and mapped == "linux":
- return True
- # Explicit termux/android tags match a Termux session too.
- if running_in_termux and mapped in ("termux", "android"):
- return True
return False
@@ -260,14 +250,6 @@ def skill_matches_platform(frontmatter: Dict[str, Any]) -> bool:
If the field is absent or empty the skill is compatible with **all**
platforms (backward-compatible default).
-
- Termux note: on Termux/Android, ``sys.platform`` is ``"linux"`` on
- older Pythons but became ``"android"`` on Python 3.13+. Termux is a
- Linux userland riding on the Android kernel, so skills tagged
- ``linux`` are treated as compatible in Termux regardless of which
- ``sys.platform`` value Python reports. Individual Linux commands
- inside a skill may still misbehave (no systemd, BusyBox utils, no
- apt/dnf, etc.) but that is on the skill, not on platform gating.
"""
return skill_matches_platform_list(frontmatter.get("platforms"))
@@ -421,7 +403,7 @@ def _load_raw_config() -> Dict[str, Any]:
return cached
try:
- parsed = yaml_load(config_path.read_text(encoding="utf-8"))
+ parsed = yaml_load(config_path.read_text(encoding="utf-8-sig"))
except Exception as e:
logger.debug("Could not read skill config %s: %s", config_path, e)
return {}
@@ -1101,7 +1083,7 @@ def discover_all_skill_config_vars() -> List[Dict[str, Any]]:
continue
for skill_file in iter_skill_index_files(skills_dir, "SKILL.md"):
try:
- raw = skill_file.read_text(encoding="utf-8")
+ raw = skill_file.read_text(encoding="utf-8-sig")
frontmatter, _ = parse_frontmatter(raw)
except Exception:
continue
diff --git a/agent/system_prompt.py b/agent/system_prompt.py
index ebf1e8a410..8e68bbcec9 100644
--- a/agent/system_prompt.py
+++ b/agent/system_prompt.py
@@ -566,7 +566,7 @@ def build_system_prompt_parts(agent: Any, system_message: Optional[str] = None)
f"not on any model name returned by the API."
)
- # Environment hints (WSL, Termux, etc.) — tell the agent about the
+ # Environment hints (WSL, containers, etc.) — tell the agent about the
# execution environment so it can translate paths and adapt behavior.
# Stable for the lifetime of the process.
_env_hints = _r.build_environment_hints()
diff --git a/cli.py b/cli.py
index 7d5aec9f5b..2cd975717f 100644
--- a/cli.py
+++ b/cli.py
@@ -989,7 +989,6 @@ _single_query_finalize_attempted_session_ids: set[str | None] = set()
_handed_off_session_ids: set[str | None] = set()
# Weak reference to the active AIAgent for memory provider shutdown at exit
_active_agent_ref = None
-_deferred_agent_startup_done = False
# Set True once the TUI's prompt_toolkit app starts (which enables focus
# reporting + mouse tracking). Gates the on-exit terminal reset so non-TUI
# one-shot CLI runs — which also register _run_cleanup via atexit — don't emit
@@ -1003,59 +1002,6 @@ def _mark_tui_input_modes_active() -> None:
_tui_input_modes_active = True
-def _prepare_deferred_agent_startup() -> None:
- """Run Termux-deferred agent discovery before the first real agent turn."""
- global _deferred_agent_startup_done
- if _deferred_agent_startup_done:
- return
- if os.environ.get("HERMES_DEFER_AGENT_STARTUP") != "1":
- return
- _deferred_agent_startup_done = True
- _accept_hooks = os.environ.get("HERMES_ACCEPT_HOOKS", "").lower() in {
- "1",
- "true",
- "yes",
- "on",
- }
- try:
- from hermes_cli.plugins import discover_plugins
-
- discover_plugins()
- except Exception:
- logger.warning(
- "plugin discovery failed at deferred CLI startup",
- exc_info=True,
- )
- try:
- from hermes_cli.mcp_startup import start_background_mcp_discovery
-
- start_background_mcp_discovery(
- logger=logger,
- thread_name="termux-cli-mcp-discovery",
- )
- except Exception:
- logger.debug(
- "MCP tool discovery failed at deferred CLI startup",
- exc_info=True,
- )
- try:
- from agent.shell_hooks import register_from_config
- from hermes_cli.config import load_config
-
- _hooks_cfg = load_config()
- register_from_config(_hooks_cfg, accept_hooks=_accept_hooks)
-
- from agent.outbound_webhooks import (
- register_from_config as register_outbound_webhooks,
- )
-
- register_outbound_webhooks(_hooks_cfg)
- except Exception:
- logger.debug(
- "shell-hook registration failed at deferred CLI startup",
- exc_info=True,
- )
-
def _arm_exit_watchdog(timeout_s: float | None = None, *, from_signal: bool = False) -> None:
"""Guarantee the process actually exits once shutdown has begun.
@@ -3805,33 +3751,14 @@ _IMAGE_EXTENSIONS = frozenset({
})
-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",
- ]
- # Termux/Android roots are POSIX paths — join with literal forward
- # slashes so the hint stays correct even when this renders on Windows.
- for root in candidates:
- if os.path.isdir(root):
- return f"{root}/Pictures/{filename}"
- return f"~/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
+ ~/Pictures/My\ Photos/cat.png what is this?
+ "/home/me/DCIM/Camera/cat 1.png" summarize
"""
raw = str(raw or "").strip()
if not raw:
@@ -3945,7 +3872,7 @@ 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/...``.
+ commands.
Returns a dict on match::
@@ -4016,8 +3943,8 @@ def _detect_file_drop(user_input: str) -> "dict | None":
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.
+ Narrow terminals 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 ""
@@ -4755,13 +4682,7 @@ def _build_compact_banner() -> str:
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()
+ version_line = format_banner_version_label()
w = min(shutil.get_terminal_size().columns - 2, 88)
if w < 30:
@@ -6595,7 +6516,7 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin):
"""Return the live prompt_toolkit width, falling back to ``shutil``.
The TUI layout can be narrower than ``shutil.get_terminal_size()`` reports,
- especially on Termux/mobile shells, so prefer prompt_toolkit's width whenever
+ especially on narrow mobile shells, so prefer prompt_toolkit's width whenever
an app is active.
"""
try:
@@ -8556,20 +8477,17 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin):
except Exception:
logger.debug("banner snapshot save failed", exc_info=True)
- # Tool discovery is intentionally deferred on the Termux bare prompt
- # path; availability warnings are shown once tools are initialized.
# On the snapshot fast path (warm launch), the check walks every
# check_fn (~180ms) — run it in the background refresh thread instead
# and let its output land above the prompt (patch_stdout-safe).
- if os.environ.get("HERMES_DEFER_AGENT_STARTUP") != "1":
- if getattr(self, "_defer_tool_warnings", False):
- threading.Thread(
- target=self._show_tool_availability_warnings,
- name="tool-availability-warnings",
- daemon=True,
- ).start()
- else:
- self._show_tool_availability_warnings()
+ if getattr(self, "_defer_tool_warnings", False):
+ threading.Thread(
+ target=self._show_tool_availability_warnings,
+ name="tool-availability-warnings",
+ daemon=True,
+ ).start()
+ else:
+ self._show_tool_availability_warnings()
# Warn about low context lengths (common with local servers). Keep
# this tied to the runtime guard so guidance cannot drift again.
@@ -9184,13 +9102,9 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin):
def _show_status(self):
"""Show compact startup status line."""
- # Avoid pulling the full tool registry into the bare Termux prompt path.
- if os.environ.get("HERMES_DEFER_AGENT_STARTUP") == "1":
- tool_status = "tools deferred"
- else:
- tools = get_tool_definitions(enabled_toolsets=self.enabled_toolsets, quiet_mode=True)
- tool_count = len(tools) if tools else 0
- tool_status = f"{tool_count} tools"
+ tools = get_tool_definitions(enabled_toolsets=self.enabled_toolsets, quiet_mode=True)
+ tool_count = len(tools) if tools else 0
+ tool_status = f"{tool_count} tools"
# Format model name (shorten if needed)
model_short = self.model.split("/")[-1] if "/" in self.model else self.model
@@ -9477,10 +9391,7 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin):
_cprint(f"\n {_DIM}Tip: /help skills lists skill commands · /help
filters · Ctrl+P opens the command palette{_RST}")
_cprint(f" {_DIM}Multi-line: Ctrl+J, Alt+Enter, or \\\\+Enter for a new line{_RST}")
_cprint(f" {_DIM}Draft editor: Ctrl+G (Alt+G in VSCode/Cursor){_RST}")
- if _is_termux_environment():
- _cprint(f" {_DIM}Attach image: /image {_termux_example_image_path()} or start your prompt with a local image path{_RST}\n")
- else:
- _cprint(f" {_DIM}Paste image: Alt+V (or /paste){_RST}\n")
+ _cprint(f" {_DIM}Paste image: Alt+V (or /paste){_RST}\n")
def show_tools(self):
"""Display available tools with kawaii ASCII art."""
@@ -14619,19 +14530,6 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin):
reqs = check_voice_requirements()
if not reqs["audio_available"]:
- if _is_termux_environment():
- details = reqs.get("details", "")
- if "Termux:API Android app is not installed" in details:
- raise RuntimeError(
- "Termux:API command package detected, but the Android app is missing.\n"
- "Install/update the Termux:API Android app, then retry /voice on.\n"
- "Fallback: pkg install python-numpy portaudio && python -m pip install sounddevice"
- )
- raise RuntimeError(
- "Voice mode requires either Termux:API microphone access or Python audio libraries.\n"
- "Option 1: pkg install termux-api and install the Termux:API Android app\n"
- "Option 2: pkg install python-numpy portaudio && python -m pip install sounddevice"
- )
raise RuntimeError(
"Voice mode requires sounddevice and numpy.\n"
f"Install with: {sys.executable} -m pip install sounddevice numpy"
@@ -14732,8 +14630,6 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin):
_label = self._voice_record_key_label()
if getattr(self._voice_recorder, "supports_silence_autostop", True):
_recording_hint = f"auto-stops on silence | {_label} to stop & exit continuous"
- elif _is_termux_environment():
- _recording_hint = f"Termux:API capture | {_label} to stop"
else:
_recording_hint = f"{_label} to stop"
_cprint(f"\n{_ACCENT}● Recording...{_RST} {_DIM}({_recording_hint}){_RST}")
@@ -15203,12 +15099,7 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin):
for line in reqs["details"].split("\n"):
_cprint(f" {_DIM}{line}{_RST}")
if reqs["missing_packages"]:
- if _is_termux_environment():
- _cprint(f"\n {_BOLD}Option 1: pkg install termux-api{_RST}")
- _cprint(f" {_DIM}Then install/update the Termux:API Android app for microphone capture{_RST}")
- _cprint(f" {_BOLD}Option 2: pkg install python-numpy portaudio && python -m pip install sounddevice{_RST}")
- else:
- _cprint(f"\n {_BOLD}Install: {sys.executable} -m pip install {' '.join(reqs['missing_packages'])}{_RST}")
+ _cprint(f"\n {_BOLD}Install: {sys.executable} -m pip install {' '.join(reqs['missing_packages'])}{_RST}")
return
with self._voice_lock:
@@ -17602,6 +17493,19 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin):
if not self._claim_active_session("cli"):
return
+ # Post-update boot bootstrap: catches installs whose code changed
+ # outside `hermes update` (git pull by hand, sealed-tree swap).
+ # Two file reads when nothing changed; never raises. Deliberately
+ # NOT in the fast startup path / before arg dispatch — `hermes
+ # --version` stays import-light.
+ try:
+ from hermes_cli.boot_bootstrap import maybe_run_boot_bootstrap
+ from hermes_cli.main import PROJECT_ROOT as _boot_root
+
+ maybe_run_boot_bootstrap(_boot_root)
+ except Exception:
+ logger.debug("boot bootstrap skipped", exc_info=True)
+
# Detect light/dark terminal mode now (before pt grabs the tty).
# Caches the result so subsequent _hex_to_ansi / style calls
# don't risk re-querying mid-render.
@@ -17672,21 +17576,19 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin):
# (~0.6s, deferred until client construction). Python's import lock
# makes this safe: if the user submits before the warm finishes, the
# main thread simply blocks on the remaining import work instead of
- # redoing it. Skipped when agent startup is explicitly deferred
- # (Termux) — that path defers heavy work on purpose.
- if os.environ.get("HERMES_DEFER_AGENT_STARTUP") != "1":
- def _prewarm_agent_runtime() -> None:
- try:
- import run_agent # noqa: F401 (imports model_tools + tool registry)
- import openai # noqa: F401
- except Exception:
- logger.debug("agent runtime pre-import failed", exc_info=True)
+ # redoing it.
+ def _prewarm_agent_runtime() -> None:
+ try:
+ import run_agent # noqa: F401 (imports model_tools + tool registry)
+ import openai # noqa: F401
+ except Exception:
+ logger.debug("agent runtime pre-import failed", exc_info=True)
- threading.Thread(
- target=_prewarm_agent_runtime,
- name="agent-runtime-prewarm",
- daemon=True,
- ).start()
+ threading.Thread(
+ target=_prewarm_agent_runtime,
+ name="agent-runtime-prewarm",
+ daemon=True,
+ ).start()
# Redaction opt-out warning (#17691): ON by default, loud when off.
# The redactor snapshots its state at import time so any toggle now
@@ -17869,11 +17771,9 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin):
self._voice_last_tts_text = "" # most recently spoken TTS text (echo guard, #75780)
self._voice_barge_phase = None # "generation" or "playback" phase of the last barge trip
- if os.environ.get("HERMES_DEFER_AGENT_STARTUP") != "1":
- self._install_tool_callbacks()
+ self._install_tool_callbacks()
- if os.environ.get("HERMES_DEFER_AGENT_STARTUP") != "1":
- self._ensure_tirith_security()
+ self._ensure_tirith_security()
# Key bindings for the input area
kb = KeyBindings()
@@ -21185,7 +21085,7 @@ def main(
python cli.py --toolsets web,terminal # Use specific toolsets
python cli.py --skills hermes-agent-dev,github-auth
python cli.py -q "What is Python?" # Single query mode
- python cli.py -q "Describe this" --image ~/storage/shared/Pictures/cat.png
+ python cli.py -q "Describe this" --image ~/Pictures/cat.png
python cli.py --list-tools # List tools and exit
python cli.py --resume 20260225_143052_a1b2c3 # Resume session
python cli.py -w # Start in isolated git worktree
diff --git a/hermes_cli/_startup_fast.py b/hermes_cli/_startup_fast.py
index d74b421b9c..6edf3ebc68 100644
--- a/hermes_cli/_startup_fast.py
+++ b/hermes_cli/_startup_fast.py
@@ -7,15 +7,14 @@ no argparse). A guard test (``test_startup_fast_import_weight``) subprocess-
imports this module and fails if any heavy module sneaks into sys.modules.
Why this module exists (the bug class it kills): version-printing kept being
-reimplemented as ``*_fast()`` copies at the top of main.py (Termux first,
-then globally), each duplicating canonical logic — project-root resolution,
-container detection, profile detection. The copies drifted: eb4040242
-changed the canonical output and referenced ``PROJECT_ROOT`` inside the fast
-function, which doesn't exist yet on the fast path → the Termux fast path
-NameError'd on --version and nobody noticed. One implementation, imported
-by both the fast path and the module constants, makes that drift
-structurally impossible; the parity guard test would have caught eb4040242
-the day it landed.
+reimplemented as ``*_fast()`` copies at the top of main.py, each duplicating
+canonical logic — project-root resolution, container detection, profile
+detection. The copies drifted: eb4040242 changed the canonical output and
+referenced ``PROJECT_ROOT`` inside the fast function, which doesn't exist
+yet on the fast path → the fast path NameError'd on --version and nobody
+noticed. One implementation, imported by both the fast path and the module
+constants, makes that drift structurally impossible; the parity guard test
+would have caught eb4040242 the day it landed.
``hermes_cli/config.py``'s ``get_container_exec_info()`` reads the same
``.container-mode`` file; keep the file-format assumptions here and there in
@@ -31,8 +30,6 @@ import sys
__all__ = [
"project_root_str",
"ensure_project_root_on_path",
- "is_termux_env",
- "is_termux_fast_version_argv",
"is_global_fast_version_argv",
"is_container_startup_environment",
"active_profile_may_override_home",
@@ -62,20 +59,6 @@ def ensure_project_root_on_path() -> None:
sys.path.insert(0, project_root)
-def is_termux_env() -> bool:
- """Tiny Termux check for pre-import startup shortcuts."""
- prefix = os.environ.get("PREFIX", "")
- return bool(
- os.environ.get("TERMUX_VERSION")
- or "com.termux/files/usr" in prefix
- or prefix.startswith("/data/data/com.termux/")
- )
-
-
-def is_termux_fast_version_argv(argv: list[str]) -> bool:
- return argv in (["--version"], ["-V"])
-
-
def is_global_fast_version_argv(argv: list[str]) -> bool:
return argv in (["--version"], ["-V"])
@@ -85,7 +68,7 @@ def is_container_startup_environment() -> bool:
if os.path.exists("/.dockerenv") or os.path.exists("/run/.containerenv"):
return True
try:
- with open("/proc/1/cgroup", encoding="utf-8") as handle:
+ with open("/proc/1/cgroup", encoding="utf-8-sig") as handle:
cgroup = handle.read()
except OSError:
return False
@@ -97,7 +80,7 @@ def active_profile_may_override_home(hermes_root: str) -> bool:
active_profile = os.path.join(hermes_root, "active_profile")
try:
if os.path.exists(active_profile):
- with open(active_profile, encoding="utf-8") as handle:
+ with open(active_profile, encoding="utf-8-sig") as handle:
active = handle.read().strip()
return bool(active and active != "default")
except (OSError, UnicodeDecodeError):
@@ -149,7 +132,7 @@ def read_openai_version() -> str | None:
base = os.getcwd()
version_file = os.path.join(base, "openai", "_version.py")
try:
- with open(version_file, encoding="utf-8") as handle:
+ with open(version_file, encoding="utf-8-sig") as handle:
for line in handle:
stripped = line.strip()
if not stripped.startswith("__version__"):
@@ -173,7 +156,7 @@ def read_install_method() -> str | None:
"""
stamp = os.path.join(_resolved_home(), ".install_method")
try:
- with open(stamp, encoding="utf-8") as handle:
+ with open(stamp, encoding="utf-8-sig") as handle:
method = handle.read().strip().lower()
return method or None
except OSError:
@@ -256,19 +239,13 @@ def try_fast_version(argv: list[str] | None = None) -> bool:
Only ``--version``/``-V`` (the ``version`` subcommand was removed —
``--version`` now carries the full output incl. update status), and
never when container mode may need to route the command into the
- container. Termux keeps the HERMES_TERMUX_DISABLE_FAST_CLI escape hatch.
+ container.
"""
if argv is None:
argv = sys.argv[1:]
- is_termux = is_termux_env()
- if is_termux and os.environ.get("HERMES_TERMUX_DISABLE_FAST_CLI") == "1":
+ if not is_global_fast_version_argv(argv):
return False
- if is_termux:
- if not is_termux_fast_version_argv(argv):
- return False
- elif not is_global_fast_version_argv(argv):
- return False
- elif container_mode_may_be_active():
+ if container_mode_may_be_active():
return False
print_fast_version_info()
diff --git a/hermes_cli/banner.py b/hermes_cli/banner.py
index 898ba2a2b7..a1e9f0bb94 100644
--- a/hermes_cli/banner.py
+++ b/hermes_cli/banner.py
@@ -434,7 +434,7 @@ def check_for_updates() -> Optional[int]:
# (web_server.py); mirror that here so the banner/TUI surfaces agree.
try:
from hermes_cli.config import detect_install_method, get_project_root
- if detect_install_method(get_project_root()) in {"docker", "apt"}:
+ if detect_install_method(get_project_root()) in {"docker"}:
return None
except Exception:
pass
@@ -444,7 +444,7 @@ def check_for_updates() -> Optional[int]:
now = time.time()
try:
if cache_file.exists():
- cached = json.loads(cache_file.read_text(encoding="utf-8"))
+ cached = json.loads(cache_file.read_text(encoding="utf-8-sig"))
if (
now - cached.get("ts", 0) < _UPDATE_CHECK_CACHE_SECONDS
and cached.get("rev") == embedded_rev
@@ -531,8 +531,8 @@ def get_git_banner_state(repo_dir: Optional[Path] = None) -> Optional[dict]:
For source installs and dev images this runs ``git rev-parse`` against
the active checkout. When no checkout is available — the canonical case
is the published Docker image, which excludes ``.git`` from the build
- context — we fall back to the baked-in build SHA (see
- ``hermes_cli/build_info.py``) and return it as a frozen
+ context — we fall back to the install stamp's commit (see
+ ``hermes_cli/version_info.py``) and return it as a frozen
``upstream == local`` state with ``ahead=0``. A built image is by
definition pinned to one commit, so "ahead" is always zero and the
banner correctly shows ``· upstream `` with no carried-commits
@@ -553,31 +553,42 @@ def get_git_banner_state(repo_dir: Optional[Path] = None) -> Optional[dict]:
return state
+def _stamped_sha8() -> Optional[str]:
+ """Return the install stamp's commit (8 chars), or None.
+
+ Packaged builds (Docker/Nix) carry no ``.git``; their provenance comes
+ from the build-time install stamp read by ``hermes_cli.version_info``.
+ A ``git``-sourced result means there is no stamp — return None so the
+ caller doesn't fabricate a frozen state for a live checkout.
+ """
+ try:
+ from hermes_cli.version_info import get_version_info
+
+ info = get_version_info()
+ if info.commit and info.source != "git":
+ return info.commit[:8]
+ except Exception:
+ pass
+ return None
+
+
def _compute_git_banner_state(repo_dir: Optional[Path] = None) -> Optional[dict]:
repo_dir = repo_dir or _resolve_repo_dir()
if repo_dir is None:
- # No git checkout — try the baked build SHA (Docker image path).
- try:
- from hermes_cli.build_info import get_build_sha
- baked = get_build_sha(short=8)
- if baked:
- return {"upstream": baked, "local": baked, "ahead": 0}
- except Exception:
- pass
+ # No git checkout — try the install stamp (Docker/Nix image path).
+ baked = _stamped_sha8()
+ if baked:
+ return {"upstream": baked, "local": baked, "ahead": 0}
return None
upstream = _git_short_hash(repo_dir, "origin/main")
local = _git_short_hash(repo_dir, "HEAD")
if not upstream or not local:
# Live-git lookup failed (e.g. shallow clone without origin/main).
- # Fall back to the baked build SHA if available.
- try:
- from hermes_cli.build_info import get_build_sha
- baked = get_build_sha(short=8)
- if baked:
- return {"upstream": baked, "local": baked, "ahead": 0}
- except Exception:
- pass
+ # Fall back to the install stamp if available.
+ baked = _stamped_sha8()
+ if baked:
+ return {"upstream": baked, "local": baked, "ahead": 0}
return None
ahead = 0
@@ -852,7 +863,7 @@ def banner_snapshot_fingerprint() -> Optional[str]:
def load_banner_snapshot(enabled_toolsets: List[str] = None) -> Optional[Dict[str, Any]]:
"""Return the stored banner snapshot when its fingerprint is current."""
try:
- blob = json.loads(_banner_snapshot_path().read_text(encoding="utf-8"))
+ blob = json.loads(_banner_snapshot_path().read_text(encoding="utf-8-sig"))
except Exception:
return None
if not isinstance(blob, dict):
diff --git a/hermes_cli/cli_agent_setup_mixin.py b/hermes_cli/cli_agent_setup_mixin.py
index 98e8f410b3..205b53c9e5 100644
--- a/hermes_cli/cli_agent_setup_mixin.py
+++ b/hermes_cli/cli_agent_setup_mixin.py
@@ -344,7 +344,7 @@ class CLIAgentSetupMixin:
Returns:
bool: True if successful, False otherwise
"""
- from cli import AIAgent, ChatConsole, _DIM, _RST, _accent_hex, _cprint, _prepare_deferred_agent_startup, logger
+ from cli import AIAgent, ChatConsole, _DIM, _RST, _accent_hex, _cprint, logger
if self.agent is not None:
return True
@@ -353,7 +353,6 @@ class CLIAgentSetupMixin:
# self.system_prompt below. No-op when nothing was requested.
self.finalize_preloaded_skills()
- _prepare_deferred_agent_startup()
self._install_tool_callbacks()
self._ensure_tirith_security()
diff --git a/hermes_cli/config.py b/hermes_cli/config.py
index 640179b0b4..9c35978af2 100644
--- a/hermes_cli/config.py
+++ b/hermes_cli/config.py
@@ -141,12 +141,6 @@ def _warn_config_parse_failure(
f"Keeping the previously loaded config for this process — "
f"edits to config.yaml are being IGNORED until the YAML is fixed."
)
- elif fallback == "refuse-write":
- msg = (
- f"Failed to parse {config_path}: {exc}. "
- f"REFUSING to write config.yaml so the existing file is preserved. "
- f"Fix the YAML (hermes config edit) and retry."
- )
else:
msg = (
f"Failed to parse {config_path}: {exc}. "
@@ -412,7 +406,7 @@ def get_managed_system() -> Optional[str]:
# names the system that manages the install.
if managed_marker.exists():
try:
- marker = managed_marker.read_text(encoding="utf-8", errors="replace").strip().lower()
+ marker = managed_marker.read_text(encoding="utf-8-sig", errors="replace").strip().lower()
except OSError:
marker = ""
@@ -470,7 +464,7 @@ def _install_method_project_root(project_root: Optional[Path] = None) -> Path:
def detect_install_method(project_root: Optional[Path] = None) -> str:
- """Detect how Hermes was installed: 'apt', 'docker', 'nix', 'nixos',
+ """Detect how Hermes was installed: 'docker', 'nix', 'nixos',
'home-manager', 'git', or 'unknown'.
Resolution order:
@@ -515,18 +509,17 @@ def detect_install_method(project_root: Optional[Path] = None) -> str:
See issue #34397.
"""
root = _install_method_project_root(project_root)
- # "apt" is intentionally the Termux APT distribution identifier, not a
- # generic Debian/Ubuntu APT signal. If another APT-managed distribution is
- # added, give it a distinct install method or make update-command selection
- # platform-aware instead of silently reusing Termux's `pkg` command.
# "home-manager" is here because step 3 can return it. A stamp must name
# every method that this function returns. Without it, the stamp of a
# home-manager install gives "unknown".
- supported_methods = {"apt", "docker", "nix", "nixos", "home-manager", "git", "unknown"}
+ # A legacy "apt" (Termux) stamp is deliberately no longer in this set:
+ # it falls through to "unknown", which routes the user to the generic
+ # "hermes update" path.
+ supported_methods = {"docker", "nix", "nixos", "home-manager", "git", "unknown"}
# 1. Code-scoped stamp — authoritative, immune to shared $HERMES_HOME.
try:
- method = (root / ".install_method").read_text(encoding="utf-8").strip().lower()
+ method = (root / ".install_method").read_text(encoding="utf-8-sig").strip().lower()
if method in supported_methods:
return method
except OSError:
@@ -539,7 +532,7 @@ def detect_install_method(project_root: Optional[Path] = None) -> str:
try:
method = (
(get_hermes_home() / ".install_method")
- .read_text(encoding="utf-8")
+ .read_text(encoding="utf-8-sig")
.strip()
.lower()
)
@@ -571,7 +564,7 @@ def detect_install_method(project_root: Optional[Path] = None) -> str:
# detect git repo installs from worktrees
if git_path.is_file():
try:
- content = git_path.read_text(encoding="utf-8").strip()
+ content = git_path.read_text(encoding="utf-8-sig").strip()
if content.startswith("gitdir:"):
return "git"
except OSError:
@@ -626,10 +619,6 @@ def recommended_update_command_for_method(method: str) -> str:
return _NIX_UPDATE_MSG
if method == "docker":
return "docker pull nousresearch/hermes-agent:latest"
- if method == "apt":
- # By contract, the current "apt" install method is the Termux APT
- # distribution. It deliberately uses Termux's `pkg` frontend.
- return "pkg upgrade hermes-agent"
return "hermes update"
@@ -736,7 +725,7 @@ def get_container_exec_info() -> Optional[dict]:
try:
info = {}
- with open(container_mode_file, "r", encoding="utf-8") as f:
+ with open(container_mode_file, "r", encoding="utf-8-sig") as f:
for line in f:
line = line.strip()
if "=" in line and not line.startswith("#"):
@@ -886,7 +875,7 @@ def _is_container() -> bool:
return True
# LXC / cgroup-based detection
try:
- with open("/proc/1/cgroup", "r", encoding="utf-8") as f:
+ with open("/proc/1/cgroup", "r", encoding="utf-8-sig") as f:
cgroup_content = f.read()
if "docker" in cgroup_content or "lxc" in cgroup_content or "kubepods" in cgroup_content:
return True
@@ -924,7 +913,7 @@ def _ensure_default_soul_md(home: Path) -> None:
soul_path = home / "SOUL.md"
if soul_path.exists():
try:
- existing = soul_path.read_text(encoding="utf-8")
+ existing = soul_path.read_text(encoding="utf-8-sig")
except (OSError, UnicodeDecodeError):
return
if not is_legacy_template_soul(existing):
@@ -2024,7 +2013,7 @@ def _raw_config_has_explicit_version() -> bool:
if not config_path.exists():
return False
try:
- with open(config_path, encoding="utf-8") as f:
+ with open(config_path, encoding="utf-8-sig") as f:
raw = fast_safe_load(f) or {}
except Exception:
return False
@@ -2049,7 +2038,7 @@ def check_config_version() -> Tuple[int, int]:
return latest, latest
try:
- with open(config_path, encoding="utf-8") as f:
+ with open(config_path, encoding="utf-8-sig") as f:
config = fast_safe_load(f) or {}
except Exception as e:
# Invalid YAML needs a parse warning, not an automatic schema rewrite
@@ -3350,7 +3339,7 @@ def read_raw_config() -> Dict[str, Any]:
return copy.deepcopy(cached[2])
try:
- with open(config_path, encoding="utf-8") as f:
+ with open(config_path, encoding="utf-8-sig") as f:
data = fast_safe_load(f) or {}
except Exception as e:
_warn_config_parse_failure(config_path, e)
@@ -3404,7 +3393,7 @@ def read_user_config_raw(config_path: Optional[Path] = None) -> Dict[str, Any]:
if config_path is None:
config_path = get_config_path()
try:
- with open(config_path, encoding="utf-8") as f:
+ with open(config_path, encoding="utf-8-sig") as f:
data = fast_safe_load(f) or {}
except FileNotFoundError:
return {}
@@ -3440,7 +3429,7 @@ def read_raw_config_readonly() -> Dict[str, Any]:
return cached[2]
try:
- with open(config_path, encoding="utf-8") as f:
+ with open(config_path, encoding="utf-8-sig") as f:
data = fast_safe_load(f) or {}
except Exception as e:
_warn_config_parse_failure(config_path, e)
@@ -3456,33 +3445,14 @@ def read_raw_config_readonly() -> Dict[str, Any]:
return cached_copy
-def require_readable_config_before_write(
- config_path: Optional[Path] = None,
-) -> Dict[str, Any]:
- """Refuse to replace an existing config.yaml that cannot be read or parsed.
-
- Guards two collapse-to-empty failure modes that would otherwise let a
- read-then-write caller silently wipe user overrides:
-
- 1. **Unreadable** (permissions / broken mount) - byte open fails.
- 2. **Unparseable or non-mapping** - YAML load raises, or the root is a
- list/scalar. ``read_user_config_raw()`` / bare ``except`` loaders treat
- both as ``{}``, so a subsequent write would replace the recoverable
- file with only the caller's partial dict.
-
- Returns the loaded mapping (or ``{}`` for a missing / empty / null
- document) so mutation callers can skip a second parse. A valid empty
- mapping (``{}``) is allowed through so first-time installs and
- intentional empty configs still work. On parse failure this also
- snapshots a ``.corrupt.*.bak`` via :func:`_warn_config_parse_failure`
- before raising.
- """
+def require_readable_config_before_write(config_path: Optional[Path] = None) -> None:
+ """Refuse to replace an existing config.yaml that cannot be read."""
if config_path is None:
config_path = get_config_path()
try:
config_path.stat()
except FileNotFoundError:
- return {}
+ return
except OSError as exc:
raise RuntimeError(
f"Refusing to overwrite {config_path}: existing config.yaml cannot be accessed "
@@ -3498,47 +3468,6 @@ def require_readable_config_before_write(
f"({exc}). Fix the file permissions or move it aside first."
) from exc
- return _load_user_config_for_mutation(config_path)
-
-
-def _load_user_config_for_mutation(config_path: Path) -> Dict[str, Any]:
- """Load raw user config for a fail-closed mutation path.
-
- Fail closed on parse / non-mapping (no bare-except to ``{}`` collapse).
- Used by :func:`require_readable_config_before_write` and any caller that
- must re-validate after other work. Distinct from
- :func:`read_user_config_raw`, which collapses non-dict roots to ``{}``.
- """
- if not config_path.exists():
- return {}
- try:
- with open(config_path, encoding="utf-8") as f:
- loaded = fast_safe_load(f)
- except OSError as exc:
- raise RuntimeError(
- f"Refusing to overwrite {config_path}: existing config.yaml cannot be read "
- f"({exc}). Fix the file permissions or move it aside first."
- ) from exc
- except Exception as exc:
- _warn_config_parse_failure(config_path, exc, fallback="refuse-write")
- raise RuntimeError(
- f"Refusing to overwrite {config_path}: existing config.yaml is not valid YAML "
- f"({exc}). Fix the file or restore from a .corrupt.*.bak backup first."
- ) from exc
- if loaded is None:
- return {}
- if not isinstance(loaded, dict):
- exc = TypeError(
- f"top-level YAML must be a mapping, got {type(loaded).__name__}"
- )
- _warn_config_parse_failure(config_path, exc, fallback="refuse-write")
- raise RuntimeError(
- f"Refusing to overwrite {config_path}: top-level YAML must be a mapping, "
- f"got {type(loaded).__name__}. Fix the file or restore from a "
- f".corrupt.*.bak backup first."
- ) from exc
- return loaded
-
def atomic_config_write(config_path: Path, data: Any, **kwargs: Any) -> None:
"""Fail-closed atomic write for ``config.yaml``.
@@ -3548,13 +3477,12 @@ def atomic_config_write(config_path: Path, data: Any, **kwargs: Any) -> None:
:func:`require_readable_config_before_write` first, so a full-file
replacement can never silently clobber an existing ``config.yaml`` that
degraded to an empty dict on read (permission error, broken mount,
- transient I/O, unparseable YAML, or a non-mapping root). New-file
- creation still works when the path is absent.
+ transient I/O). New-file creation still works when the path is absent.
- Root cause this guards: ``read_user_config_raw()`` returns ``{}`` for an
- absent file / unreadable path edge cases and collapses non-dict roots.
- Callers that read then overwrite can't tell these apart, so a broken
- config would be replaced with only defaults or the single edited section. Routing every
+ Root cause this guards: ``read_raw_config()`` returns ``{}`` for BOTH an
+ absent file and an unreadable-but-present file. Callers that read then
+ overwrite can't tell the two apart, so an unreadable config would be
+ replaced with only defaults or the single edited section. Routing every
write through this helper enforces the invariant in one place rather than
relying on each of ~15 independent write sites to remember the guard.
@@ -3564,6 +3492,23 @@ def atomic_config_write(config_path: Path, data: Any, **kwargs: Any) -> None:
from utils import atomic_yaml_write
require_readable_config_before_write(config_path)
+ if config_path.exists():
+ try:
+ with open(config_path, encoding="utf-8-sig") as f:
+ loaded = fast_safe_load(f)
+ except Exception as exc:
+ _backup_corrupt_config(config_path)
+ raise RuntimeError(
+ f"Refusing to overwrite {config_path}: existing config.yaml is not valid YAML "
+ f"({exc}). Fix the file or restore from a .corrupt.*.bak backup first."
+ ) from exc
+ if loaded is not None and not isinstance(loaded, dict):
+ _backup_corrupt_config(config_path)
+ raise RuntimeError(
+ f"Refusing to overwrite {config_path}: top-level YAML must be a mapping, "
+ f"got {type(loaded).__name__}. Fix the file or restore from a "
+ f".corrupt.*.bak backup first."
+ )
atomic_yaml_write(config_path, data, **kwargs)
@@ -3834,7 +3779,7 @@ def _load_config_impl(*, want_deepcopy: bool) -> Dict[str, Any]:
if user_sig is not None:
try:
- with open(config_path, encoding="utf-8") as f:
+ with open(config_path, encoding="utf-8-sig") as f:
user_config = fast_safe_load(f) or {}
if "max_turns" in user_config:
@@ -5638,9 +5583,29 @@ def set_config_value(key: str, value: str, force: bool = False):
# Read the raw user config (not merged with defaults) to avoid
# dumping all default values back to the file
config_path = get_config_path()
- # Fail-closed parse via require_readable (unparseable / non-mapping
- # refuse-write); returns the mapping so we do not re-parse / collapse.
- user_config = require_readable_config_before_write(config_path)
+ require_readable_config_before_write(config_path)
+ user_config = {}
+ if config_path.exists():
+ try:
+ with open(config_path, encoding="utf-8-sig") as f:
+ user_config = fast_safe_load(f) or {}
+ except Exception as exc:
+ _backup_corrupt_config(config_path)
+ msg = (
+ f"Refusing to overwrite {config_path}: existing config.yaml is not valid YAML "
+ f"({exc}). Fix the file or restore from a .corrupt.*.bak backup first."
+ )
+ print(f"✗ {msg}", file=sys.stderr)
+ raise RuntimeError(msg) from exc
+ if not isinstance(user_config, dict):
+ _backup_corrupt_config(config_path)
+ msg = (
+ f"Refusing to overwrite {config_path}: top-level YAML must be a mapping, "
+ f"got {type(user_config).__name__}. Fix the file or restore from a "
+ f".corrupt.*.bak backup first."
+ )
+ print(f"✗ {msg}", file=sys.stderr)
+ raise RuntimeError(msg)
# Handle nested keys (e.g., "tts.provider") including numeric list
# indices (e.g., "custom_providers.0.api_key"). Delegates to
@@ -5884,9 +5849,29 @@ def unset_config_value(key: str):
return
config_path = get_config_path()
- # Fail-closed parse via require_readable (unparseable / non-mapping
- # refuse-write); returns the mapping so we do not re-parse / collapse.
- user_config = require_readable_config_before_write(config_path)
+ require_readable_config_before_write(config_path)
+ user_config = {}
+ if config_path.exists():
+ try:
+ with open(config_path, encoding="utf-8-sig") as f:
+ user_config = fast_safe_load(f) or {}
+ except Exception as exc:
+ _backup_corrupt_config(config_path)
+ msg = (
+ f"Refusing to overwrite {config_path}: existing config.yaml is not valid YAML "
+ f"({exc}). Fix the file or restore from a .corrupt.*.bak backup first."
+ )
+ print(f"✗ {msg}", file=sys.stderr)
+ raise RuntimeError(msg) from exc
+ if not isinstance(user_config, dict):
+ _backup_corrupt_config(config_path)
+ msg = (
+ f"Refusing to overwrite {config_path}: top-level YAML must be a mapping, "
+ f"got {type(user_config).__name__}. Fix the file or restore from a "
+ f".corrupt.*.bak backup first."
+ )
+ print(f"✗ {msg}", file=sys.stderr)
+ raise RuntimeError(msg)
removed = _unset_nested(user_config, key)
@@ -5949,8 +5934,6 @@ def config_command(args):
try:
set_config_value(key, value, force=force)
except RuntimeError as exc:
- # Fail-closed write guard (unparseable / non-mapping / unreadable
- # config.yaml). Surface a clean CLI error instead of a traceback.
print(f"✗ {exc}", file=sys.stderr)
sys.exit(1)
@@ -5967,7 +5950,6 @@ def config_command(args):
try:
unset_config_value(key)
except RuntimeError as exc:
- # Same fail-closed guard surface as `config set` above.
print(f"✗ {exc}", file=sys.stderr)
sys.exit(1)
@@ -6180,7 +6162,7 @@ def _inject_platform_plugin_env_vars() -> None:
if not manifest_path.exists():
continue
try:
- with open(manifest_path, "r", encoding="utf-8") as f:
+ with open(manifest_path, "r", encoding="utf-8-sig") as f:
manifest = fast_safe_load(f) or {}
except Exception:
continue
diff --git a/hermes_cli/doctor_live.py b/hermes_cli/doctor_live.py
index 5c990b2af1..2e630398d7 100644
--- a/hermes_cli/doctor_live.py
+++ b/hermes_cli/doctor_live.py
@@ -93,21 +93,16 @@ def _browser_available() -> bool:
pass
# agent-browser resolves lazily via npx on the default install (#43564),
# invisible to the PATH/node_modules probes above. Mirror the rung
- # hermes_cli.doctor uses so this probe can't diverge from it, including
- # the Termux carve-out (bare npx is too fragile to advertise as ready
- # there — see check_browser_requirements).
+ # hermes_cli.doctor uses so this probe can't diverge from it.
try:
from tools.browser_tool import (
_find_agent_browser,
_is_npx_agent_browser_sentinel,
- _requires_real_termux_browser_install,
)
browser_cmd = _find_agent_browser(validate=False)
except Exception:
return False
- if not _is_npx_agent_browser_sentinel(browser_cmd):
- return False
- return not _requires_real_termux_browser_install(browser_cmd)
+ return _is_npx_agent_browser_sentinel(browser_cmd)
def _launch_browser_probe(timeout: float) -> tuple:
diff --git a/hermes_cli/gateway.py b/hermes_cli/gateway.py
index b04a8f676e..26693a2629 100644
--- a/hermes_cli/gateway.py
+++ b/hermes_cli/gateway.py
@@ -564,7 +564,7 @@ def probe_gateway_loop_liveness(
path = get_loop_heartbeat_path(home)
mtime = path.stat().st_mtime
- payload = json.loads(path.read_text(encoding="utf-8"))
+ payload = json.loads(path.read_text(encoding="utf-8-sig"))
heartbeat_pid = int(payload.get("pid", 0))
except Exception:
return GATEWAY_LOOP_UNKNOWN
@@ -1490,7 +1490,7 @@ def _hermes_home_from_systemd_unit_file(system: bool = False) -> str | None:
if not unit_path.exists():
return None
try:
- text = unit_path.read_text(encoding="utf-8")
+ text = unit_path.read_text(encoding="utf-8-sig")
except OSError:
return None
for line in text.splitlines():
@@ -1941,12 +1941,6 @@ def _probe_launchd_service_running() -> bool:
def get_gateway_runtime_snapshot(system: bool = False) -> GatewayRuntimeSnapshot:
"""Return a unified view of gateway liveness for the current profile."""
gateway_pids = tuple(find_gateway_pids())
- if is_termux():
- return GatewayRuntimeSnapshot(
- manager="Termux / manual process",
- gateway_pids=gateway_pids,
- )
-
from hermes_constants import is_container
if is_linux() and is_container():
@@ -2409,7 +2403,7 @@ def is_linux() -> bool:
return sys.platform.startswith("linux")
-from hermes_constants import is_container, is_termux, is_wsl
+from hermes_constants import is_container, is_wsl
def _wsl_systemd_operational() -> bool:
@@ -2458,7 +2452,7 @@ def _container_systemd_operational() -> bool:
def supports_systemd_services() -> bool:
- if not is_linux() or is_termux():
+ if not is_linux():
return False
if shutil.which("systemctl") is None:
return False
@@ -3037,7 +3031,7 @@ def _find_legacy_hermes_units() -> list[tuple[str, Path, bool]]:
try:
if not unit_path.exists():
continue
- text = unit_path.read_text(encoding="utf-8", errors="ignore")
+ text = unit_path.read_text(encoding="utf-8-sig", errors="ignore")
except (OSError, PermissionError):
continue
if not any(marker in text for marker in _LEGACY_UNIT_EXECSTART_MARKERS):
@@ -3238,7 +3232,7 @@ def _read_systemd_user_from_unit(unit_path: Path) -> str | None:
if not unit_path.exists():
return None
- for line in unit_path.read_text(encoding="utf-8").splitlines():
+ for line in unit_path.read_text(encoding="utf-8-sig").splitlines():
if line.startswith("User="):
value = line.split("=", 1)[1].strip()
return value or None
@@ -3417,8 +3411,6 @@ def get_systemd_linger_status() -> tuple[bool | None, str]:
(False, "") when linger is disabled.
(None, detail) when the status could not be determined.
"""
- if is_termux():
- return None, "not supported in Termux"
if not is_linux():
return None, "not supported on this platform"
@@ -3607,11 +3599,13 @@ def get_python_path() -> str:
from hermes_constants import venv_python_path
except ImportError:
# Update-boundary: a gateway restarted mid-update can hold a
- # hermes_constants cached from before this symbol existed. See
- # _reload_hermes_constants() in hermes_cli/managed_uv.py.
- from hermes_cli.managed_uv import _reload_hermes_constants
+ # hermes_constants cached from before this symbol existed.
+ # Reload picks up the definitions actually on disk.
+ import importlib
- venv_python_path = _reload_hermes_constants().venv_python_path
+ import hermes_constants
+
+ venv_python_path = importlib.reload(hermes_constants).venv_python_path
venv_python = venv_python_path(venv, windows=is_windows())
if venv_python.exists():
@@ -3848,42 +3842,59 @@ def _append_node_dir_for_service(
) -> None:
"""Add the Node directory a generated service unit should use to *path_entries*.
- The Hermes-managed Node under ``$HERMES_HOME/node`` goes first when it
- exists. A bare ``shutil.which("node")`` cannot be trusted on its own here:
- a service unit is written once and then survives reboots, so resolving a
- system Node that happens to be ahead on the installing shell's PATH bakes
- the wrong interpreter in permanently — the exact failure the desktop
- backend spawn was fixed for. Managed dirs are profile-scoped, so each
- profile's unit still names its own Node.
+ The pm store's Node/npm dirs go first when installed. A bare
+ ``shutil.which("node")`` cannot be trusted on its own here: a service unit
+ is written once and then survives reboots, so resolving a system Node that
+ happens to be ahead on the installing shell's PATH bakes the wrong
+ interpreter in permanently — the exact failure the desktop backend spawn
+ was fixed for. The store is profile-scoped, so each profile's unit still
+ names its own Node.
*hermes_root* is the Hermes home the unit will run against. System units
installed via sudo MUST pass the **target user's** home: probing the
default (the calling user's — root's — tree) would bake root's Node into
- the target user's unit. The probe swallows OSError: an unreadable
- candidate dir (hardened home) means "skip the rung", not "crash the
- generator".
+ the target user's unit; the target user's installed-state file
+ (``/tools/facts.json``) is read directly for that case.
+ The probe swallows OSError: an unreadable candidate dir (hardened home)
+ means "skip the rung", not "crash the generator".
- PATH lookup remains the fallback rung for installs with no managed Node.
+ PATH lookup remains the fallback rung for installs with no pm-managed Node.
"""
- from hermes_constants import (
- hermes_managed_node_tree_present,
- iter_hermes_node_dirs,
- )
+ managed_dirs: list[str] = []
+ try:
+ if hermes_root is None:
+ import pm
- managed_node_present = hermes_managed_node_tree_present(hermes_root)
- for directory in iter_hermes_node_dirs(hermes_root) if managed_node_present else ():
- entry = str(directory)
+ env = pm.env_for("npm", base_env={"PATH": ""})
+ managed_dirs = [d for d in env.get("PATH", "").split(os.pathsep) if d]
+ else:
+ from pm.lock import Facts
+
+ store_root = Path(hermes_root) / "tools"
+ facts = Facts(store_root / "facts.json")
+ for name in ("npm", "node"):
+ value = facts.env_for(name, store_root).get("PATH") or []
+ for directory in value if isinstance(value, list) else [value]:
+ if directory and directory not in managed_dirs:
+ managed_dirs.append(str(directory))
+ except Exception:
+ managed_dirs = []
+
+ managed_appended = False
+ for entry in managed_dirs:
try:
- present = directory.is_dir()
+ present = Path(entry).is_dir()
except OSError:
present = False
- if present and entry not in path_entries:
- path_entries.append(entry)
+ if present:
+ managed_appended = True
+ if entry not in path_entries:
+ path_entries.append(entry)
# Ambient PATH lookup is a fallback, not an additional rung. Once the
# target Hermes home provides managed Node, consulting the invoker's PATH
# makes a system unit differ between sudo/root and its service user.
- if managed_node_present:
+ if managed_appended:
return
resolved_node = shutil.which("node")
@@ -4108,7 +4119,7 @@ def systemd_unit_is_current(system: bool = False) -> bool:
if not unit_path.exists():
return False
- installed = unit_path.read_text(encoding="utf-8")
+ installed = unit_path.read_text(encoding="utf-8-sig")
expected_user = _read_systemd_user_from_unit(unit_path) if system else None
expected = generate_systemd_unit(system=system, run_as_user=expected_user)
# Normalize out directives that older systemd versions silently drop
@@ -4249,7 +4260,7 @@ def _print_linger_enable_warning(username: str, detail: str | None = None) -> No
def _ensure_linger_enabled() -> None:
"""Enable linger when possible so the user gateway survives logout."""
- if is_termux() or not is_linux():
+ if not is_linux():
return
import getpass
@@ -5348,7 +5359,7 @@ def launchd_plist_is_current() -> bool:
if not plist_path.exists():
return False
- installed = plist_path.read_text(encoding="utf-8")
+ installed = plist_path.read_text(encoding="utf-8-sig")
expected = generate_launchd_plist()
return _normalize_launchd_plist_for_comparison(
installed
@@ -7999,14 +8010,6 @@ def gateway_setup():
print_info(
" To enable systemd: add systemd=true to /etc/wsl.conf, then 'wsl --shutdown'"
)
- elif is_termux():
- from hermes_constants import display_hermes_home as _dhh
-
- print_info(" Termux does not use systemd/launchd services.")
- print_info(" Run in foreground: hermes gateway run")
- print_info(
- f" Or start it manually in the background (best effort): nohup hermes gateway run >{_dhh()}/logs/gateway.log 2>&1 &"
- )
else:
print_info(" Service install not supported on this platform.")
print_info(" Run in foreground: hermes gateway run")
@@ -8288,10 +8291,6 @@ def _gateway_command_inner(args):
force = getattr(args, "force", False)
system = getattr(args, "system", False)
run_as_user = getattr(args, "run_as_user", None)
- if is_termux():
- print("Gateway service installation is not supported on Termux.")
- print("Run manually: hermes gateway")
- sys.exit(1)
if supports_systemd_services():
if is_wsl():
print_warning(
@@ -8412,12 +8411,6 @@ def _gateway_command_inner(args):
managed_error("uninstall gateway service")
return
system = getattr(args, "system", False)
- if is_termux():
- print(
- "Gateway service uninstall is not supported on Termux because there is no managed service to remove."
- )
- print("Stop manual runs with: hermes gateway stop")
- sys.exit(1)
if supports_systemd_services():
systemd_uninstall(system=system)
elif is_macos():
@@ -8465,12 +8458,6 @@ def _gateway_command_inner(args):
)
_wait_for_gateway_exit(timeout=10.0, force_after=5.0)
- if is_termux():
- print(
- "Gateway service start is not supported on Termux because there is no system service manager."
- )
- print("Run manually: hermes gateway")
- sys.exit(1)
if supports_systemd_services():
systemd_start(system=system)
elif is_macos():
@@ -8817,10 +8804,7 @@ def _gateway_command_inner(args):
for line in runtime_lines:
print(f" {line}")
print()
- if is_termux():
- print("Termux note:")
- print(" Android may stop background jobs when Termux is suspended")
- elif is_wsl():
+ if is_wsl():
print("WSL note:")
print(
" The gateway is running in foreground/manual mode (recommended for WSL)."
@@ -8848,11 +8832,7 @@ def _gateway_command_inner(args):
print()
print("To start:")
print(" hermes gateway run # Run in foreground")
- if is_termux():
- print(
- " nohup hermes gateway run > ~/.hermes/logs/gateway.log 2>&1 & # Best-effort background start"
- )
- elif is_wsl():
+ if is_wsl():
print(
" tmux new -s hermes 'hermes gateway run' # persistent via tmux"
)
diff --git a/hermes_cli/nous_subscription.py b/hermes_cli/nous_subscription.py
index bdd6f9532e..786477b3e6 100644
--- a/hermes_cli/nous_subscription.py
+++ b/hermes_cli/nous_subscription.py
@@ -184,15 +184,12 @@ def _has_agent_browser() -> bool:
# agent-browser is no longer a root package.json dependency (#43564) — it
# resolves lazily via npx for most installs, which a bare PATH +
# node_modules probe can't see. Mirror the local-CLI tail of
- # :func:`tools.browser_tool.check_browser_requirements` (same cascade, same
- # Termux carve-out) so the setup/status surfaces can't diverge from what
- # browser tools actually find at runtime; validate=False keeps this a cheap
- # existence check with no subprocess spawn.
+ # :func:`tools.browser_tool.check_browser_requirements` (same cascade) so
+ # the setup/status surfaces can't diverge from what browser tools actually
+ # find at runtime; validate=False keeps this a cheap existence check with
+ # no subprocess spawn.
try:
- from tools.browser_tool import (
- _find_agent_browser,
- _requires_real_termux_browser_install,
- )
+ from tools.browser_tool import _find_agent_browser
except Exception:
# If the runtime probe can't be imported, fall back to binary presence
# (prior behaviour) rather than crashing the setup/status surface.
@@ -224,13 +221,9 @@ def _has_agent_browser() -> bool:
return False
try:
- browser_cmd = _find_agent_browser(validate=False)
+ _find_agent_browser(validate=False)
except FileNotFoundError:
return False
- # On Termux, the bare npx fallback is too fragile to advertise as ready —
- # require a real install, matching check_browser_requirements.
- if _requires_real_termux_browser_install(browser_cmd):
- return False
return True
diff --git a/hermes_cli/psutil_android.py b/hermes_cli/psutil_android.py
deleted file mode 100644
index c029324542..0000000000
--- a/hermes_cli/psutil_android.py
+++ /dev/null
@@ -1,108 +0,0 @@
-"""Helpers for the temporary psutil-on-Android compatibility installer."""
-
-from __future__ import annotations
-
-import shutil
-import tarfile
-from pathlib import Path, PurePosixPath
-
-# Pin a version we know patches cleanly. Update when a newer psutil
-# changes the marker line shape and we need to follow upstream.
-PSUTIL_URL = (
- "https://files.pythonhosted.org/packages/aa/c6/"
- "d1ddf4abb55e93cebc4f2ed8b5d6dbad109ecb8d63748dd2b20ab5e57ebe/"
- "psutil-7.2.2.tar.gz"
-)
-
-MARKER = 'LINUX = sys.platform.startswith("linux")'
-REPLACEMENT = 'LINUX = sys.platform.startswith(("linux", "android"))'
-
-
-class PsutilAndroidInstallError(RuntimeError):
- """Raised when the pinned psutil sdist is missing or unsafe."""
-
-
-def _normalize_member_parts(member_name: str) -> tuple[str, ...]:
- path = PurePosixPath(member_name)
- parts = tuple(part for part in path.parts if part not in ("", "."))
- if path.is_absolute() or ".." in parts or not parts:
- raise PsutilAndroidInstallError(
- f"Unsafe archive member path: {member_name!r}"
- )
- return parts
-
-
-def _safe_extract_tar_gz(archive: Path, destination: Path) -> None:
- """Extract a tar.gz without allowing traversal or link members."""
- with tarfile.open(archive, "r:gz") as tf:
- for member in tf.getmembers():
- parts = _normalize_member_parts(member.name)
- target = destination.joinpath(*parts)
-
- if member.isdir():
- target.mkdir(parents=True, exist_ok=True)
- continue
-
- if not member.isfile():
- raise PsutilAndroidInstallError(
- f"Unsupported archive member type: {member.name}"
- )
-
- target.parent.mkdir(parents=True, exist_ok=True)
- extracted = tf.extractfile(member)
- if extracted is None:
- raise PsutilAndroidInstallError(
- f"Cannot read archive member: {member.name}"
- )
-
- with extracted, open(target, "wb") as dst:
- shutil.copyfileobj(extracted, dst)
-
- try:
- target.chmod(member.mode & 0o777)
- except OSError:
- pass
-
-
-def prepare_patched_psutil_sdist(archive: Path, destination: Path) -> Path:
- """Safely extract the pinned psutil sdist and patch it for Android."""
- _safe_extract_tar_gz(archive, destination)
-
- src_roots = sorted(
- (
- path for path in destination.iterdir()
- if path.is_dir() and path.name.startswith("psutil-")
- ),
- key=lambda path: path.name,
- )
- if not src_roots:
- raise PsutilAndroidInstallError(
- "psutil sdist did not contain a psutil-* directory"
- )
-
- src_root = src_roots[0]
- common_py = src_root / "psutil" / "_common.py"
- if not common_py.is_file():
- raise PsutilAndroidInstallError(
- f"psutil sdist did not contain {common_py.relative_to(src_root)!s}"
- )
- try:
- content = common_py.read_text(encoding="utf-8")
- except OSError as exc:
- raise PsutilAndroidInstallError(
- f"Failed to read {common_py.relative_to(src_root)!s}"
- ) from exc
- if MARKER not in content:
- raise PsutilAndroidInstallError(
- "psutil Android compatibility patch marker not found"
- )
- try:
- common_py.write_text(
- content.replace(MARKER, REPLACEMENT),
- encoding="utf-8",
- )
- except OSError as exc:
- raise PsutilAndroidInstallError(
- f"Failed to write {common_py.relative_to(src_root)!s}"
- ) from exc
- return src_root
diff --git a/hermes_cli/service_manager.py b/hermes_cli/service_manager.py
index 03ed06dfbb..e0943d58b0 100644
--- a/hermes_cli/service_manager.py
+++ b/hermes_cli/service_manager.py
@@ -92,7 +92,7 @@ def detect_service_manager() -> ServiceManagerKind:
"windows" — native Windows host
"launchd" — macOS host
"systemd" — Linux host with a working user/system bus
- "none" — anything else (Termux, sandbox shells, etc.)
+ "none" — anything else (sandbox shells, etc.)
This function does NOT replace ``supports_systemd_services()`` —
host call sites continue to use that. It exists for new backend-
@@ -151,7 +151,7 @@ def _s6_running() -> bool:
init, or an unrelated process named ``s6-svscan``).
"""
try:
- comm = Path("/proc/1/comm").read_text(encoding="utf-8").strip()
+ comm = Path("/proc/1/comm").read_text(encoding="utf-8-sig").strip()
except OSError:
return False
if comm != "s6-svscan":
@@ -376,7 +376,7 @@ def _write_gateway_desired_state(name: str, desired_state: str) -> None:
if not profile_dir.exists():
return
try:
- data = json.loads(state_file.read_text(encoding="utf-8")) if state_file.exists() else {}
+ data = json.loads(state_file.read_text(encoding="utf-8-sig")) if state_file.exists() else {}
if not isinstance(data, dict):
data = {}
except (OSError, json.JSONDecodeError):
diff --git a/hermes_cli/status.py b/hermes_cli/status.py
index 981bb3a387..0fc82becb6 100644
--- a/hermes_cli/status.py
+++ b/hermes_cli/status.py
@@ -109,9 +109,6 @@ def _effective_provider_label() -> str:
return provider_label(effective)
-from hermes_constants import is_termux as _is_termux
-
-
def _estop_status_line():
"""One-line pause banner for `hermes status`, or None when not paused.
@@ -572,16 +569,10 @@ def show_status(args):
print(f" PID(s): {_format_gateway_pids(snapshot.gateway_pids)}")
if snapshot.has_process_service_mismatch:
print(" Service: installed but not managing the current running gateway")
- elif _is_termux() and not snapshot.gateway_pids:
- print(" Start with: hermes gateway")
- print(" Note: Android may stop background jobs when Termux is suspended")
elif snapshot.service_installed and not snapshot.service_running:
print(" Service: installed but stopped")
except Exception:
- if _is_termux():
- print(f" Status: {color('unknown', Colors.DIM)}")
- print(" Manager: Termux / manual process")
- elif sys.platform.startswith('linux'):
+ if sys.platform.startswith('linux'):
print(f" Status: {color('unknown', Colors.DIM)}")
print(" Manager: systemd/manual")
elif sys.platform == 'darwin':
@@ -650,7 +641,7 @@ def show_status(args):
if sessions_file.exists():
import json
try:
- with open(sessions_file, encoding="utf-8") as f:
+ with open(sessions_file, encoding="utf-8-sig") as f:
data = json.load(f)
_entries = {
k: v for k, v in data.items()
diff --git a/hermes_cli/subcommands/gateway.py b/hermes_cli/subcommands/gateway.py
index d541b69554..2f52818c9f 100644
--- a/hermes_cli/subcommands/gateway.py
+++ b/hermes_cli/subcommands/gateway.py
@@ -45,7 +45,7 @@ def build_gateway_parser(
# gateway run (default)
gateway_run = gateway_subparsers.add_parser(
- "run", help="Run gateway in foreground (recommended for WSL, Docker, Termux)"
+ "run", help="Run gateway in foreground (recommended for WSL and Docker)"
)
gateway_run.add_argument(
"-v",
diff --git a/hermes_cli/uninstall.py b/hermes_cli/uninstall.py
index fbced3bab7..7a4ad719ee 100644
--- a/hermes_cli/uninstall.py
+++ b/hermes_cli/uninstall.py
@@ -30,6 +30,35 @@ def get_project_root() -> Path:
return Path(__file__).parent.parent.resolve()
+def code_removal_refusal() -> "str | None":
+ """Why the uninstaller must not remove this tree's code, or ``None``.
+
+ A git checkout (the install.sh / install.ps1 / `hermes desktop` layout)
+ is ours to remove. A sealed tree (no ``.git``) belongs to a steward —
+ the Nix store, the bundled desktop app, a Docker image — and only the
+ steward removes it. Returns the user-facing refusal text for sealed
+ trees; the text always points at ``hermes uninstall --data`` for the
+ user-data cleanup that IS allowed everywhere.
+ """
+ from hermes_cli.steward import sealed_steward, steward_uninstall_message
+
+ steward = sealed_steward(get_project_root())
+ if steward is None:
+ return None
+ return steward_uninstall_message(steward)
+
+
+def _refuse_if_steward_owned() -> None:
+ """Exit with the steward's instructions when code removal is forbidden."""
+ refusal = code_removal_refusal()
+ if refusal is None:
+ return
+ print()
+ print(refusal)
+ print()
+ raise SystemExit(1)
+
+
def find_shell_configs() -> list:
"""Find shell configuration files that might have PATH entries."""
home = Path.home()
@@ -57,7 +86,7 @@ def remove_path_from_shell_configs():
for config_path in configs:
try:
- content = config_path.read_text(encoding="utf-8")
+ content = config_path.read_text(encoding="utf-8-sig")
original_content = content
# Remove lines containing hermes-agent or hermes PATH entries
@@ -125,7 +154,7 @@ def remove_wrapper_script():
if wrapper.exists():
try:
# Check if it's our wrapper (contains hermes_cli reference)
- content = wrapper.read_text(encoding="utf-8")
+ content = wrapper.read_text(encoding="utf-8-sig")
if 'hermes_cli' in content or 'hermes-agent' in content:
wrapper.unlink()
removed.append(wrapper)
@@ -141,21 +170,17 @@ def _node_symlink_candidate_dirs() -> "list[Path]":
# Root FHS installs put links in /usr/local/bin.
if sys.platform == "linux":
dirs.append(Path("/usr/local/bin"))
- # Termux installs put links in $PREFIX/bin.
- prefix = os.environ.get("PREFIX", "")
- if prefix and "com.termux" in prefix:
- dirs.append(Path(prefix) / "bin")
return dirs
def remove_node_symlinks(hermes_home: Path) -> list:
"""Remove the node/npm/npx symlinks the installer placed on PATH.
- The POSIX installer (``scripts/install.sh`` / ``scripts/lib/node-bootstrap.sh``)
- symlinks node/npm/npx into the same directory as the ``hermes`` command:
+ Historical POSIX installs (``scripts/install.sh`` before pm owned the
+ Node runtime) symlinked node/npm/npx into the same directory as the
+ ``hermes`` command:
- ``/usr/local/bin/`` on root FHS installs (Linux, uid 0)
- - ``$PREFIX/bin/`` on Termux
- ``~/.local/bin/`` otherwise (the common non-root case)
We check all candidate directories so that uninstall works regardless of
@@ -202,12 +227,11 @@ def uninstall_gateway_service():
- macOS: launchd plists
- Windows: Scheduled Task + Startup-folder fallback, via ``gateway_windows``
- All platforms: standalone ``hermes gateway run`` processes
- - Termux/Android: skips systemd (no systemd on Android), still kills standalone processes
"""
import platform
stopped_something = False
- # 1. Kill any standalone gateway processes (all platforms, including Termux)
+ # 1. Kill any standalone gateway processes (all platforms)
try:
from hermes_cli.gateway import kill_gateway_processes, find_gateway_pids
pids = find_gateway_pids()
@@ -221,12 +245,6 @@ def uninstall_gateway_service():
system = platform.system()
- # Termux/Android has no systemd and no launchd — nothing left to do.
- prefix = os.getenv("PREFIX", "")
- is_termux = bool(os.getenv("TERMUX_VERSION") or "com.termux/files/usr" in prefix)
- if is_termux:
- return stopped_something
-
# 2. Linux: uninstall systemd services (both user and system scopes)
if system == "Linux":
try:
@@ -444,20 +462,55 @@ def remove_portable_tooling_windows(hermes_home: Path) -> list[Path]:
return removed
-def remove_windows_bin_launchers(*, windows: bool | None = None) -> list[Path]:
- """Delete the ``hermes`` launchers install.ps1 staged in the managed
- binary dir (the default Hermes root's ``bin``, next to the managed uv).
+def remove_legacy_runtime_trees(hermes_home: Path) -> list[Path]:
+ """Delete managed-runtime trees a PRE-SPLIT install left in HERMES_HOME.
- Every uninstall mode deletes the code checkout, so the launchers —
- which invoke ``\\venv\\Scripts`` — would otherwise dangle:
- ``hermes`` in a new terminal resolves to a launcher whose target is
- gone and errors, which reads worse than command-not-found. The managed
- uv (uv*.exe) in the same dir is left for keep-data reinstalls.
+ Runtime artifacts are install-scoped now, so the current locations go
+ away with ``rmtree(project_root)``. But a checkout OUTSIDE the home
+ (the common case: ``~/src/hermes-agent``) used to put its node/uv
+ under ``$HERMES_HOME`` — that tree survives removing the checkout and
+ survives "keep my data" uninstalls, because it is not data.
+
+ Only the exact managed layout is removed: ``node/`` (a tree the
+ installer owned wholesale) and ``bin/uv`` (the single binary, NOT the
+ whole ``bin/`` dir — a user's own scripts can live there). Profile
+ state is never touched.
+ """
+ removed: list[Path] = []
+
+ node_tree = hermes_home / "node"
+ if node_tree.is_dir():
+ try:
+ shutil.rmtree(node_tree, ignore_errors=False)
+ removed.append(node_tree)
+ except Exception as e:
+ log_warn(f"Could not remove {node_tree}: {e}")
+
+ for uv_name in ("uv", "uv.exe"):
+ uv_binary = hermes_home / "bin" / uv_name
+ if uv_binary.is_file():
+ try:
+ uv_binary.unlink()
+ removed.append(uv_binary)
+ except Exception as e:
+ log_warn(f"Could not remove {uv_binary}: {e}")
+
+ return removed
+
+
+def remove_windows_bin_launchers(*, windows: bool | None = None) -> list[Path]:
+ """Delete the managed binary dir (the default Hermes root's ``bin``).
+
+ The dir holds only hermes-owned launcher copies (the relocatable venv's
+ console scripts, staged onto PATH by first-run repair) — pm keeps uv in
+ its own store entry, so nothing shared lives here and the whole dir goes.
+ Every uninstall mode deletes the code checkout, so a surviving launcher
+ would dangle: ``hermes`` in a new terminal resolves and then errors on
+ its missing venv target, which reads worse than command-not-found.
A launcher that IS this process's own trampoline is mandatory-locked
- against deletion but not rename (same fact
- ``_install_repair._quarantine_running_hermes_exe`` relies on), so
- deletion falls back to renaming it aside with a non-executable suffix.
+ against deletion but not rename, so removal falls back to renaming it
+ aside with a non-executable suffix.
*windows* is an injectable platform verdict for tests (same pattern as
``_install_repair.ensure_windows_bin_launchers``).
@@ -467,32 +520,33 @@ def remove_windows_bin_launchers(*, windows: bool | None = None) -> list[Path]:
if not windows:
return []
try:
- # Lockstep launcher-name list — the same names install.ps1 and the
- # startup heal stage into this dir.
- from hermes_cli._install_repair import _WINDOWS_BIN_LAUNCHERS
from hermes_constants import get_default_hermes_root
bin_dir = get_default_hermes_root() / "bin"
except Exception as e:
log_warn(f"Could not locate the managed binary dir: {e}")
return []
+ if not bin_dir.is_dir():
+ return []
removed: list[Path] = []
- for name in _WINDOWS_BIN_LAUNCHERS:
- for suffix in (".exe", ".cmd"):
- launcher = bin_dir / f"{name}{suffix}"
- if not launcher.exists():
- continue
+ for launcher in sorted(bin_dir.iterdir()):
+ if not launcher.is_file():
+ continue
+ try:
+ launcher.unlink()
+ removed.append(launcher)
+ except OSError:
+ aside = launcher.with_name(f"{launcher.name}.uninstalled.{os.getpid()}")
try:
- launcher.unlink()
+ os.rename(launcher, aside)
removed.append(launcher)
- except OSError:
- aside = launcher.with_name(f"{launcher.name}.uninstalled.{os.getpid()}")
- try:
- os.rename(launcher, aside)
- removed.append(launcher)
- except OSError as e:
- log_warn(f"Could not remove {launcher}: {e}")
+ except OSError as e:
+ log_warn(f"Could not remove {launcher}: {e}")
+ try:
+ bin_dir.rmdir()
+ except OSError:
+ pass # leftovers (renamed-aside trampolines) keep the dir until next run
return removed
@@ -575,6 +629,85 @@ def _uninstall_profile(profile) -> None:
log_warn(f" Could not remove {profile_home}: {e}")
+def run_data_uninstall(args):
+ """Remove Hermes user data only — no code, on any install kind.
+
+ This is the one destructive action that is valid everywhere: source
+ checkouts, the bundled desktop app, Nix, Docker. It removes everything
+ under ``$HERMES_HOME`` EXCEPT the ``hermes-agent`` checkout (which is
+ code, owned by the code-removal modes / the steward), plus the desktop
+ app's Electron userData directory.
+ """
+ hermes_home = get_hermes_home()
+ agent_root = hermes_home / "hermes-agent"
+ skip_confirm = bool(getattr(args, "yes", False))
+
+ targets = []
+ if hermes_home.exists():
+ targets = sorted(
+ (p for p in hermes_home.iterdir() if p.name != "hermes-agent"),
+ key=lambda p: p.name,
+ )
+
+ from hermes_cli.gui_uninstall import desktop_userdata_dir
+
+ userdata = desktop_userdata_dir()
+
+ if not targets and not userdata.exists():
+ print("No Hermes user data found.")
+ print(f" Checked: {hermes_home}")
+ return
+
+ print()
+ print(color("This removes your Hermes data — config, chats, secrets, logs.", Colors.YELLOW, Colors.BOLD))
+ print(color("Installed code is not touched.", Colors.CYAN))
+ print()
+ print(color("Will remove:", Colors.YELLOW, Colors.BOLD))
+ for p in targets:
+ print(f" • {p}")
+ if userdata.exists():
+ print(f" • {userdata} (desktop app data)")
+ if agent_root.exists():
+ print()
+ print(color("Kept intact:", Colors.GREEN, Colors.BOLD))
+ print(f" • {agent_root}")
+ print()
+
+ if not skip_confirm:
+ try:
+ confirm = input(f"Type '{color('yes', Colors.YELLOW)}' to remove your Hermes data: ").strip().lower()
+ except (KeyboardInterrupt, EOFError):
+ print()
+ print("Cancelled.")
+ return
+ if confirm != "yes":
+ print()
+ print("Uninstall cancelled.")
+ return
+
+ print()
+ for p in targets:
+ try:
+ if p.is_symlink() or p.is_file():
+ p.unlink()
+ else:
+ shutil.rmtree(p)
+ log_success(f"Removed {p}")
+ except Exception as e:
+ log_warn(f"Could not remove {p}: {e}")
+
+ if userdata.exists():
+ try:
+ shutil.rmtree(userdata)
+ log_success(f"Removed {userdata}")
+ except Exception as e:
+ log_warn(f"Could not remove {userdata}: {e}")
+
+ print()
+ print(color("✓ Hermes data removed.", Colors.GREEN, Colors.BOLD))
+ print()
+
+
def run_gui_uninstall(args):
"""GUI-only uninstall: remove the Chat GUI, leave the agent + data intact.
@@ -589,6 +722,8 @@ def run_gui_uninstall(args):
uninstall_gui,
)
+ _refuse_if_steward_owned()
+
hermes_home = get_hermes_home()
summary = gui_install_summary(hermes_home)
skip_confirm = bool(getattr(args, "yes", False))
@@ -655,6 +790,8 @@ def run_uninstall(args):
- Full uninstall: removes code + ~/.hermes/ (configs, data, logs)
- Keep data: removes code but keeps ~/.hermes/ for future reinstall
"""
+ _refuse_if_steward_owned()
+
project_root = get_project_root()
hermes_home = get_hermes_home()
@@ -952,7 +1089,22 @@ def _perform_uninstall(
log_warn(f"Could not fully remove {project_root}: {e}")
log_info("You may need to manually remove it")
- # 4b. Remove Windows-only installer artifacts that are NOT user data:
+ # 4b. Remove managed-runtime trees a PRE-SPLIT install left in
+ # HERMES_HOME. Current installs keep these inside the checkout, so
+ # step 4 already removed them — but a checkout outside the home
+ # (~/src/hermes-agent) used to leave its node/uv behind, surviving
+ # both the checkout removal and a "keep my data" uninstall. They
+ # are install tooling, not data, so removing them is correct in
+ # either mode.
+ log_info("Removing managed runtime trees...")
+ removed_runtimes = remove_legacy_runtime_trees(hermes_home)
+ if removed_runtimes:
+ for path in removed_runtimes:
+ log_success(f"Removed {path}")
+ else:
+ log_info("No legacy runtime trees to remove")
+
+ # 4c. Remove Windows-only installer artifacts that are NOT user data:
# PortableGit, bundled Node, gateway-service dir. Installer put them
# under HERMES_HOME but they're install tooling, not config — safe to
# remove even in "keep data" mode. If we're doing a full uninstall
@@ -1025,11 +1177,12 @@ class _UninstallArgs:
self.gui = mode == "gui"
self.gui_summary = False
self.full = mode == "full"
+ self.data = mode == "data"
self.yes = True # the module entrypoint is always non-interactive
def main(argv=None) -> int:
- """Module entrypoint: ``python -m hermes_cli.uninstall --mode ``.
+ """Module entrypoint: ``python -m hermes_cli.uninstall --mode ``.
Exists so the desktop app can run the uninstall under a Python interpreter
OUTSIDE the venv being deleted. On Windows, ``lite``/``full`` rmtree the
@@ -1038,23 +1191,30 @@ def main(argv=None) -> int:
The desktop launches this with the system Python + ``PYTHONPATH=``
so ``import hermes_cli`` resolves from source while the venv is torn down.
+ ``data`` removes user data only (no code) and is the one mode allowed on
+ steward-owned installs (Nix, the bundled desktop app, Docker); the
+ code-removing modes hard-fail there with the steward's instructions.
+
This module imports only stdlib + ``hermes_constants`` + ``hermes_cli.colors``
- (and lazily ``hermes_cli.gui_uninstall``), so it runs fine under a bare
- system Python with no site-packages from the venv.
+ (and lazily ``hermes_cli.gui_uninstall`` / ``hermes_cli.steward``), so it
+ runs fine under a bare system Python with no site-packages from the venv.
"""
import argparse
parser = argparse.ArgumentParser(prog="python -m hermes_cli.uninstall")
parser.add_argument(
"--mode",
- choices=["gui", "lite", "full"],
+ choices=["gui", "lite", "full", "data"],
required=True,
- help="gui = Chat GUI only; lite = GUI + agent, keep data; full = everything",
+ help="gui = Chat GUI only; lite = GUI + agent, keep data; "
+ "full = everything; data = user data only, keep code",
)
ns = parser.parse_args(argv)
args = _UninstallArgs(mode=ns.mode)
- if args.gui:
+ if args.data:
+ run_data_uninstall(args)
+ elif args.gui:
run_gui_uninstall(args)
else:
run_uninstall(args)
diff --git a/hermes_cli/update_contract.py b/hermes_cli/update_contract.py
index cf6f4e2f75..51426cd9cc 100644
--- a/hermes_cli/update_contract.py
+++ b/hermes_cli/update_contract.py
@@ -8,7 +8,12 @@ endpoint). The decision layers:
written by the image build — see :mod:`hermes_cli.image_provenance`):
authoritative ground truth that this filesystem came from an immutable
image. Fail-closed: a present-but-malformed marker still refuses.
-2. **Filesystem heuristics** (``detect_install_method()``): the pre-existing
+2. **Install stamp** (``install-stamp.json`` beside the running code, read
+ via :mod:`hermes_cli.steward`): a sealed tree (no ``.git``) names its
+ steward in ``distribution`` — ``desktop-app``, ``docker``, ``nix``. The
+ steward replaces the tree wholesale, so in-place update refuses with
+ the steward's own instructions (restack's steward-refusal ladder).
+3. **Filesystem heuristics** (``detect_install_method()``): the pre-existing
docker/nix/apt detection, kept as the fallback for images built before
the marker existed and for package-managed installs that have no image
marker at all.
@@ -32,7 +37,7 @@ logger = logging.getLogger(__name__)
class UpdateRefusal:
"""Why an in-place update is refused, and what to run instead."""
- code: str # image-marker | image-marker-invalid | docker | nix | apt
+ code: str # image-marker | image-marker-invalid | docker | nix | apt | desktop-app |
message: str # full user-facing text (multi-line ok)
update_command: str # the one-line remediation command
@@ -85,7 +90,56 @@ def evaluate_update_admission(project_root: Path) -> Optional[UpdateRefusal]:
except Exception as exc:
logger.debug("Image provenance check failed (using heuristics): %s", exc)
- # Layer 2: pre-existing filesystem heuristics, verbatim semantics.
+ # Layer 2: install stamp / steward classification. A sealed tree (no
+ # ``.git``) belongs to a steward — the desktop app bundle, a Docker
+ # image, the Nix store — and only the steward updates it. This is the
+ # rung that covers ``desktop-app``, which the heuristics below never
+ # detect (the payload has no .install_method stamp and no .git).
+ try:
+ from hermes_cli.steward import (
+ STEWARD_DESKTOP,
+ STEWARD_DOCKER,
+ STEWARD_NIX,
+ sealed_steward,
+ steward_update_message,
+ )
+
+ steward = sealed_steward(project_root)
+ if steward is not None and steward != "unknown":
+ from hermes_cli.config import recommended_update_command_for_method
+
+ if steward == STEWARD_DOCKER:
+ from hermes_cli.config import format_docker_update_message
+
+ return UpdateRefusal(
+ code="docker",
+ message=format_docker_update_message(),
+ update_command=recommended_update_command_for_method("docker"),
+ )
+ if steward == STEWARD_NIX:
+ return UpdateRefusal(
+ code="nix",
+ message=steward_update_message(steward),
+ update_command=recommended_update_command_for_method("nix"),
+ )
+ # desktop-app and future package managers: there is no CLI
+ # remediation command — the steward's own instructions ARE the
+ # remediation (recommended_update_command_for_method would
+ # falsely answer "hermes update" for methods it doesn't know).
+ command = (
+ "Manage updates from within the desktop app"
+ if steward == STEWARD_DESKTOP
+ else f"update via {steward}"
+ )
+ return UpdateRefusal(
+ code=steward,
+ message=steward_update_message(steward),
+ update_command=command,
+ )
+ except Exception as exc:
+ logger.debug("Steward admission check failed: %s", exc)
+
+ # Layer 3: pre-existing filesystem heuristics, verbatim semantics.
try:
from hermes_cli.config import (
detect_install_method,
@@ -101,10 +155,10 @@ def evaluate_update_admission(project_root: Path) -> Optional[UpdateRefusal]:
message=format_docker_update_message(),
update_command=recommended_update_command_for_method("docker"),
)
- if is_nix_install_method(method) or method == "apt":
+ if is_nix_install_method(method):
command = recommended_update_command_for_method(method)
return UpdateRefusal(
- code=method if method == "apt" else "nix",
+ code="nix",
message=command,
update_command=command,
)
diff --git a/hermes_constants.py b/hermes_constants.py
index 3f28cd8d1a..88b0b52252 100644
--- a/hermes_constants.py
+++ b/hermes_constants.py
@@ -419,26 +419,6 @@ def get_hermes_dir(
return home / new_subpath
-def iter_hermes_node_dirs(home: Path | None = None) -> list[Path]:
- """Return Hermes-managed Node.js directories in preferred lookup order.
-
- Windows installs from ``scripts/install.ps1`` unpack portable Node directly
- into ``%LOCALAPPDATA%\\hermes\\node``. POSIX installs use
- ``$HERMES_HOME/node/bin``. Include both shapes on every platform so mixed
- or migrated installs still work.
- """
- root = home or get_hermes_home()
- dirs = [root / "node"]
- bin_dir = root / "node" / "bin"
- # NOTE: keep this ordering in sync with hermesManagedNodePathEntries() in
- # apps/desktop/electron/backend-env.ts — the Electron main process is Node
- # and cannot import this module, so the platform-ordering rule is mirrored
- # there (once; main.ts imports it rather than keeping its own copy).
- if sys.platform == "win32":
- return dirs + [bin_dir]
- return [bin_dir] + dirs
-
-
def _candidate_node_command_names(command: str) -> list[str]:
base = Path(command).name
if sys.platform != "win32" or "." in base:
@@ -454,10 +434,6 @@ def _candidate_node_command_names(command: str) -> list[str]:
return [f"{base}.cmd", f"{base}.exe", base]
-_HERMES_NODE_TARGET_MAJOR = int(os.environ.get("HERMES_NODE_TARGET_MAJOR", "22"))
-_managed_node_heal_attempted = False
-_NODE_BOOTSTRAP_SCRIPT = Path(__file__).resolve().parent / "scripts" / "lib" / "node-bootstrap.sh"
-
# Install tree root (this file lives at /hermes_constants.py).
# Used by secure_parent_dir() to skip chmod on the install dir — chmodding it
# 0700 breaks hermes-user traversal in Docker (UID 10000). See #25821, #93050.
@@ -467,12 +443,9 @@ _INSTALL_ROOT = Path(__file__).resolve().parent
def node_tool_runnable(path: str | None) -> bool:
"""Return True only when *path* is a Node/npm/npx binary that actually runs.
- Hermes-managed Node trees live under ``$HERMES_HOME/node`` (or a profile's
- ``HERMES_HOME``). A partial upgrade or interrupted install can leave
- ``bin/npm`` behind while ``lib/cli.js`` is missing — the wrapper exists but
- immediately throws ``MODULE_NOT_FOUND``. ``find_hermes_node_executable``
- used to trust file presence alone, so ``hermes update`` would pick that
- broken npm and fail the Node refresh / web UI build.
+ A partial upgrade or interrupted install can leave ``bin/npm`` behind
+ while ``lib/cli.js`` is missing — the wrapper exists but immediately
+ throws ``MODULE_NOT_FOUND``. Presence alone is therefore not trusted.
Probe with ``--version`` (same pattern as :func:`agent_browser_runnable`) so
broken managed wrappers are detected before use.
@@ -503,451 +476,6 @@ def node_tool_runnable(path: str | None) -> bool:
return result.returncode == 0
-def hermes_managed_node_tree_present(home: Path | None = None) -> bool:
- """Return True when any Hermes-managed node/npm/npx shim exists on disk."""
- names = set()
- for command in ("node", "npm", "npx"):
- names.update(_candidate_node_command_names(command))
- for directory in iter_hermes_node_dirs(home):
- for name in names:
- candidate = directory / name
- if candidate.is_file() and (
- sys.platform == "win32" or os.access(candidate, os.X_OK)
- ):
- return True
- return False
-
-
-def _path_under_any(path: str, roots: list[str]) -> bool:
- """Return True when *path* sits inside one of *roots* (same drive).
-
- Windows paths are case-insensitive and psutil / env vars can disagree on
- drive-letter casing, so compare through ``normcase`` (a no-op on POSIX).
- Each root is evaluated individually so disjoint roots both work.
- """
- path_norm = os.path.normcase(os.path.normpath(path))
- for root in roots:
- root_norm = os.path.normcase(os.path.normpath(root))
- try:
- if os.path.commonpath([path_norm, root_norm]) == root_norm:
- return True
- except ValueError:
- # Different drives on Windows — commonpath raises.
- continue
- return False
-
-
-def managed_node_tree_in_use(home: Path | None = None) -> bool:
- """Return True when any running process executes from the managed Node tree.
-
- Windows locks executables and loaded scripts against deletion or
- overwrite while a process runs them, so the updater must not rewrite
- ``%HERMES_HOME%\\node`` while the desktop app's Node processes hold it —
- ``PermissionError: [WinError 5]`` on ``npm.cmd`` is the classic symptom
- (#80926). Always ``False`` on POSIX, which has no equivalent lock
- semantics.
-
- The scan is a fast pre-check that avoids pointless re-downloads in
- long-lived processes; the rename-based swap in
- :func:`_heal_managed_node_windows` is the authoritative in-use guard.
- """
- if sys.platform != "win32":
- return False
- try:
- import psutil
- except Exception:
- return False
- dirs: list[str] = []
- for directory in iter_hermes_node_dirs(home):
- try:
- dirs.append(str(Path(directory).resolve()))
- except OSError:
- continue
- if not dirs:
- return False
- try:
- procs = psutil.process_iter(["exe", "cmdline"])
- except Exception:
- return False
- for proc in procs:
- try:
- info = proc.info
- except Exception:
- continue
- exe = info.get("exe")
- if exe:
- try:
- exe_path = str(Path(exe).resolve())
- except (OSError, ValueError):
- exe_path = str(exe)
- if _path_under_any(exe_path, dirs):
- return True
- for arg in info.get("cmdline") or []:
- if _path_under_any(arg, dirs):
- return True
- return False
-
-
-_managed_node_in_use_notice_printed = False
-
-
-def _print_managed_node_in_use_notice() -> None:
- """Print the managed-Node deferral notice once per process."""
- global _managed_node_in_use_notice_printed
- if _managed_node_in_use_notice_printed:
- return
- _managed_node_in_use_notice_printed = True
- print(
- "→ Hermes-managed Node.js is in use by a running app; deferring its "
- "upgrade until the app is closed (re-run `hermes update` afterwards).",
- flush=True,
- )
-
-
-def _heal_managed_node_windows(home: Path | None = None) -> bool | None:
- """Redownload the portable Node zip into ``%HERMES_HOME%\\node`` on Windows.
-
- Returns ``True`` on success, ``False`` on a genuine failure (offline,
- download error, bad archive), and ``None`` when the tree is in use and the
- heal is deferred — callers must not record the once-per-process attempt
- for ``None`` so a later call can retry once the tree is free.
-
- The replacement is staging-first: the new tree is fully downloaded and
- extracted to a sibling ``node.new-*`` directory, then the live tree is
- renamed aside (``node.old-*``) and the staged tree renamed into place.
- The live tree is never deleted before its replacement is ready, so an
- interrupted heal cannot gut the running installation. Windows allows
- renaming a tree whose executables are running (images are mapped with
- ``FILE_SHARE_DELETE`` — the same mechanism as the hermes.exe quarantine);
- when the OS refuses the rename, that refusal *is* the in-use signal and
- the heal defers instead of forcing the write and crashing with
- ``PermissionError: [WinError 5]`` on ``npm.cmd`` (#80926).
- """
- import re
- import tempfile
- import time
- import urllib.request
- import uuid
- import zipfile
-
- arch = (os.environ.get("PROCESSOR_ARCHITEW6432") or os.environ.get("PROCESSOR_ARCHITECTURE", "")).lower()
- if arch in ("amd64", "x86_64"):
- node_arch = "x64"
- elif arch == "arm64":
- node_arch = "arm64"
- elif arch in ("x86",):
- node_arch = "x86"
- else:
- return False
-
- home = home or get_hermes_home()
- target = home / "node"
-
- # Cheap pre-check: skip the download and staging work when the tree is
- # already visibly in use. The rename-based swap below is the
- # authoritative guard — this scan only avoids pointless re-downloads for
- # long-lived processes whose npm resolution retries.
- if managed_node_tree_in_use(home):
- _print_managed_node_in_use_notice()
- return None
-
- # Best-effort sweep of staging/backup litter from interrupted runs; a
- # locked file simply stays for the next attempt. Only dirs older than
- # 10 minutes are removed so a concurrent heal's in-flight swap (whose
- # staged/backup dirs are seconds old) is never disturbed.
- cutoff = time.time() - 600
- for stale in home.glob("node.old-*"):
- try:
- if stale.stat().st_mtime < cutoff:
- shutil.rmtree(stale, ignore_errors=True)
- except OSError:
- continue
- for stale in home.glob("node.new-*"):
- try:
- if stale.stat().st_mtime < cutoff:
- shutil.rmtree(stale, ignore_errors=True)
- except OSError:
- continue
-
- index_url = f"https://nodejs.org/dist/latest-v{_HERMES_NODE_TARGET_MAJOR}.x/"
- try:
- with urllib.request.urlopen(index_url, timeout=60) as response:
- index_html = response.read().decode("utf-8", errors="replace")
- except OSError:
- return False
-
- match = re.search(
- rf"node-v{_HERMES_NODE_TARGET_MAJOR}\.\d+\.\d+-win-{node_arch}\.zip",
- index_html,
- )
- if not match:
- return False
-
- zip_name = match.group(0)
- download_url = f"{index_url}{zip_name}"
- try:
- with urllib.request.urlopen(download_url, timeout=300) as response:
- zip_bytes = response.read()
- except OSError:
- return False
-
- token = uuid.uuid4().hex[:8]
- staged = home / f"node.new-{token}"
- backup = home / f"node.old-{token}"
- try:
- with tempfile.TemporaryDirectory() as tmp_dir:
- tmp_path = Path(tmp_dir)
- zip_path = tmp_path / zip_name
- zip_path.write_bytes(zip_bytes)
- extract_dir = tmp_path / "extract"
- extract_dir.mkdir()
- with zipfile.ZipFile(zip_path) as archive:
- archive.extractall(extract_dir)
- extracted = next(extract_dir.glob("node-v*"), None)
- if extracted is None or not extracted.is_dir():
- return False
- # Move the fully-extracted tree to a sibling staging dir so the
- # swap below is a same-volume rename.
- shutil.move(str(extracted), str(staged))
- except OSError:
- return False
-
- if target.exists():
- try:
- os.replace(str(target), str(backup))
- except OSError:
- # The OS refuses to move the live tree — a running process holds
- # it. Defer; the old tree is untouched and the next resolution
- # (e.g. the next update after the app is closed) retries.
- _print_managed_node_in_use_notice()
- shutil.rmtree(staged, ignore_errors=True)
- return None
- # A rename preserves the directory's mtime, so a backup renamed from
- # a long-lived tree would instantly look older than the litter-sweep
- # cutoff to a concurrent heal. Touch it (best-effort — a failure
- # must not abort the swap, which already succeeded) so the in-flight
- # backup is never swept mid-swap.
- try:
- os.utime(backup, None)
- except OSError:
- pass
- try:
- os.replace(str(staged), str(target))
- except OSError:
- # Roll the live tree back and report the failure.
- try:
- os.replace(str(backup), str(target))
- except OSError:
- pass
- shutil.rmtree(staged, ignore_errors=True)
- return False
- # The old tree is no longer canonical; locked files may keep it on
- # disk until the next heal attempt, which is safe.
- shutil.rmtree(backup, ignore_errors=True)
- else:
- try:
- os.replace(str(staged), str(target))
- except OSError:
- shutil.rmtree(staged, ignore_errors=True)
- return False
-
- return node_tool_runnable(str(target / "node.exe"))
-
-
-def _bootstrap_managed_node_posix() -> bool:
- """Install a fresh managed Node under ``$HERMES_HOME/node`` on POSIX.
-
- Shells out to ``_nb_install_bundled_node`` in ``scripts/lib/node-bootstrap.sh``
- (the same pinned-nodejs.org path ``install.sh`` uses), so the resulting
- tree matches what a normal install would have produced. Runs with
- ``HERMES_NODE_SKIP_LINKS=1`` so the user's own node/npm on PATH is not
- shadowed by ``~/.local/bin`` symlinks.
- """
- if not _NODE_BOOTSTRAP_SCRIPT.is_file():
- return False
-
- import subprocess
-
- try:
- result = subprocess.run(
- [
- "bash",
- "-c",
- f'source "{_NODE_BOOTSTRAP_SCRIPT}" && _nb_install_bundled_node',
- ],
- env={
- **os.environ,
- "HERMES_HOME": str(get_hermes_home()),
- # Private provisioning: do not symlink node/npm/npx into
- # ~/.local/bin — the user has their own toolchain on PATH and
- # this tree must not shadow it.
- "HERMES_NODE_SKIP_LINKS": "1",
- },
- capture_output=True,
- timeout=600,
- check=False,
- )
- except (OSError, subprocess.SubprocessError):
- return False
- return result.returncode == 0
-
-
-def bootstrap_hermes_managed_node() -> str | None:
- """Install a Hermes-managed Node tree and return its npm path.
-
- Used when the only Node/npm on the machine belongs to the user (system,
- nvm, brew, Nix) and cannot satisfy the repo's ``engines`` requirements —
- Hermes never modifies a toolchain it does not own, so instead it provisions
- its own tree under ``$HERMES_HOME/node`` (the same tree a fresh install
- creates) and works with that.
-
- Returns the managed npm executable path on success, ``None`` on failure.
- No-ops (returning the existing npm) when a healthy managed tree is already
- present.
- """
- existing = find_hermes_node_executable("npm")
- if existing:
- return existing
-
- if sys.platform == "win32":
- ok = _heal_managed_node_windows()
- else:
- ok = _bootstrap_managed_node_posix()
- if not ok:
- return None
-
- for directory in iter_hermes_node_dirs():
- for name in _candidate_node_command_names("npm"):
- candidate = directory / name
- if candidate.is_file() and (
- sys.platform == "win32" or os.access(candidate, os.X_OK)
- ):
- resolved = str(candidate)
- if node_tool_runnable(resolved):
- return resolved
- return None
-
-
-def heal_hermes_managed_node() -> bool:
- """Redownload Hermes-managed Node when the tree exists but is broken.
-
- Runs at most once per process. POSIX installs shell out to
- ``heal_managed_node`` in ``scripts/lib/node-bootstrap.sh``; Windows
- downloads the portable zip directly (same source as ``install.ps1``).
- A Windows deferral (the tree is in use by a running app) does NOT record
- the attempt, so a later call — or the next process — can heal once the
- tree is free (#80926).
- """
- global _managed_node_heal_attempted
- if _managed_node_heal_attempted:
- return False
- if not hermes_managed_node_tree_present():
- return False
-
- if sys.platform == "win32":
- result = _heal_managed_node_windows()
- if result is None:
- # In-use deferral: leave the attempt flag clear so a later call
- # in this process can heal after the app releases the tree.
- return False
- _managed_node_heal_attempted = True
- return bool(result)
-
- _managed_node_heal_attempted = True
-
- if not _NODE_BOOTSTRAP_SCRIPT.is_file():
- return False
-
- import subprocess
-
- try:
- result = subprocess.run(
- [
- "bash",
- "-c",
- f'source "{_NODE_BOOTSTRAP_SCRIPT}" && heal_managed_node',
- ],
- env={**os.environ, "HERMES_HOME": str(get_hermes_home())},
- capture_output=True,
- timeout=300,
- check=False,
- )
- except (OSError, subprocess.SubprocessError):
- return False
- return result.returncode == 0
-
-
-def _managed_node_tree_outdated(home: Path | None = None) -> bool:
- """Return True when the managed tree's node runs but is below the target major.
-
- An outdated managed Node (e.g. a 22 tree from an older install) heals the
- same way a broken one does: :func:`find_hermes_node_executable` triggers
- the once-per-process heal, which redownloads
- ``latest-v{_HERMES_NODE_TARGET_MAJOR}.x`` — so existing users are upgraded
- on next launch, not just on the next installer re-run. Mirrors
- ``_nb_managed_node_outdated`` in ``scripts/lib/node-bootstrap.sh``.
- """
- import subprocess
-
- for directory in iter_hermes_node_dirs(home):
- for name in _candidate_node_command_names("node"):
- candidate = directory / name
- if not candidate.is_file() or (
- sys.platform != "win32" and not os.access(candidate, os.X_OK)
- ):
- continue
- try:
- from hermes_cli._subprocess_compat import windows_hide_flags
-
- result = subprocess.run(
- [str(candidate), "--version"],
- capture_output=True,
- timeout=10,
- creationflags=windows_hide_flags(),
- )
- major = int(result.stdout.decode().strip().lstrip("v").split(".")[0])
- except (OSError, subprocess.TimeoutExpired, ValueError, IndexError):
- return False # broken, not outdated — the runnable probe handles it
- return major < _HERMES_NODE_TARGET_MAJOR
- return False
-
-
-def find_hermes_node_executable(command: str) -> str | None:
- """Return a Hermes-managed Node/npm executable path, healing broken trees.
-
- Outdated trees (node major below ``_HERMES_NODE_TARGET_MAJOR``) heal the
- same way broken ones do — the once-per-process heal redownloads the target
- major, upgrading existing users on next launch rather than next reinstall.
- When the heal fails (offline, download error), an outdated-but-runnable
- tree is still returned: old Node beats no Node.
- """
- names = _candidate_node_command_names(command)
-
- def _first_runnable() -> tuple[str | None, bool]:
- broken = False
- for directory in iter_hermes_node_dirs():
- for name in names:
- candidate = directory / name
- if candidate.is_file() and (
- sys.platform == "win32" or os.access(candidate, os.X_OK)
- ):
- resolved = str(candidate)
- if node_tool_runnable(resolved):
- return resolved, broken
- broken = True
- return None, broken
-
- resolved, broken_present = _first_runnable()
- needs_heal = broken_present or (
- resolved is not None and _managed_node_tree_outdated()
- )
- if needs_heal and heal_hermes_managed_node():
- healed, _ = _first_runnable()
- if healed:
- return healed
- return resolved
-
-
def find_node_executable_on_path(command: str) -> str | None:
"""Return a Node/npm executable from PATH with Windows shim ordering.
@@ -976,33 +504,77 @@ def find_node_executable_on_path(command: str) -> str | None:
return None
+def _pm_node_executable(command: str) -> str | None:
+ """The pm store's node/npm/npx binary for *command*, when installed.
+
+ node and npm are pm packages; npx ships inside npm's store entry, so it
+ resolves as a sibling of the npm binary. Returns ``None`` when pm has not
+ installed the package (the caller falls back to PATH).
+ """
+ base = Path(str(command)).name.lower()
+ for suffix in (".cmd", ".exe", ".ps1"):
+ if base.endswith(suffix):
+ base = base[: -len(suffix)]
+ package_name = {"node": "node", "npm": "npm", "npx": "npm"}.get(base)
+ if package_name is None:
+ return None
+ try:
+ import pm
+
+ if not pm.is_installed(package_name):
+ return None
+ from pm.ensure import _facts, _store
+ from pm.registry import get_package
+ from pm.store import current_target
+
+ fact = _facts().get(package_name)
+ if fact is None:
+ return None
+ binary = get_package(package_name).binary(
+ _store().entry(fact["entry"]), current_target()
+ )
+ if binary is None or not binary.is_file():
+ return None
+ if base == "npx":
+ for name in _candidate_node_command_names("npx"):
+ candidate = binary.parent / name
+ if candidate.is_file():
+ return str(candidate)
+ return None
+ return str(binary)
+ except Exception:
+ pass
+ return None
+
+
def find_node_executable(command: str) -> str | None:
- """Resolve a Node.js command, preferring healthy Hermes-managed installs.
+ """Resolve a Node.js command, preferring the pm store's managed install.
This is for Hermes-owned subprocesses that should not be broken by a bad,
- missing, or elevation-triggering system Node/npm on PATH. When a managed
- tree exists but cannot be healed, returns ``None`` instead of falling back
- to system npm on PATH.
+ missing, or elevation-triggering system Node/npm on PATH: the pm store's
+ pinned node/npm wins whenever it is installed, and PATH (with Windows
+ shim ordering) is the fallback for installs that bring their own Node.
"""
- managed = find_hermes_node_executable(command)
- if managed:
- return managed
- if hermes_managed_node_tree_present():
- return None
+ pm_managed = _pm_node_executable(command)
+ if pm_managed:
+ return pm_managed
return find_node_executable_on_path(command)
def with_hermes_node_path(env: dict[str, str] | None = None) -> dict[str, str]:
- """Return *env* with Hermes-managed Node directories prepended to PATH."""
- merged = dict(os.environ if env is None else env)
- existing = merged.get("PATH", "")
- parts = [p for p in existing.split(os.pathsep) if p]
- managed = [str(path) for path in iter_hermes_node_dirs() if path.is_dir()]
- for entry in reversed(managed):
- if entry not in parts:
- parts.insert(0, entry)
- merged["PATH"] = os.pathsep.join(parts)
- return merged
+ """Return *env* with the pm store's Node/npm directories prepended to PATH.
+
+ Composes through :func:`pm.env_for`, which contributes nothing when the
+ packages are not installed — so on installs that use their own system
+ Node the environment passes through unchanged.
+ """
+ base = dict(os.environ if env is None else env)
+ try:
+ import pm
+
+ return pm.env_for("npm", base_env=base)
+ except Exception:
+ return base
def agent_browser_runnable(path: str | None) -> bool:
@@ -1513,16 +1085,6 @@ def resolve_reasoning_config(cfg: dict | None, model: str = "") -> dict | None:
return result
-def is_termux() -> bool:
- """Return True when running inside a Termux (Android) environment.
-
- Checks ``TERMUX_VERSION`` (set by Termux) or the Termux-specific
- ``PREFIX`` path. Import-safe — no heavy deps.
- """
- prefix = os.getenv("PREFIX", "")
- return bool(os.getenv("TERMUX_VERSION") or "com.termux/files/usr" in prefix)
-
-
_wsl_detected: bool | None = None
diff --git a/scripts/audit-old-updater-imports.py b/scripts/audit-old-updater-imports.py
new file mode 100644
index 0000000000..d4d4735d67
--- /dev/null
+++ b/scripts/audit-old-updater-imports.py
@@ -0,0 +1,869 @@
+#!/usr/bin/env python3
+"""What an OLD `hermes update` can still import from a NEW tree.
+
+`hermes update` swaps the checkout under its own feet. The process keeps
+running the code it started with, but the files underneath it are the
+ones we just pulled. Anything it loads from disk after that point is a
+contract with every released updater in the wild: delete one of those
+names and the users on that release get a traceback halfway through an
+update, on a tree that is already half-new.
+
+`managed_uv._reload_hermes_constants` is the scar tissue proving this is
+real: an updater hit ``cannot import name 'venv_python_path' from
+'hermes_constants'`` while the file on disk plainly contained the name.
+
+WHY THIS OVER-APPROXIMATES, ON PURPOSE
+--------------------------------------
+An earlier version of this script tried to find the exact swap statement
+(the ``git merge --ff-only``) and count only what runs after it. That was
+wrong twice over. It was fragile — ``ast.unparse`` normalises quotes, so
+matching source text for ``"merge", "--ff-only"`` silently matched
+nothing and the whole git path reported no swap at all. And it was wrong
+in the DANGEROUS direction: every miss SHRINKS the frozen set, and a
+symbol wrongly dropped from the set is a bricked update for whoever
+reaches that branch.
+
+So the rule is deliberately blunt: everything reachable from the update
+entrypoints counts. A false positive costs one kept symbol. A false
+negative costs somebody's install, mid-update, on a half-new tree.
+
+A dynamic trace (driving real updates and watching imports) has the
+opposite bias and is the wrong tool here for the same reason: one run
+takes one path. It never enters the diverged-history reset, the Windows
+rollback, or the ZIP fallback, so it reports a SMALLER
+surface than reality.
+
+THE DYNAMIC PATTERNS THAT MATTER (and why they are not missed)
+--------------------------------------------------------------
+Plain import analysis misses three things this flow really does, all of
+which are resolved here because they are all spelled with literals:
+
+* ``importlib.reload(m)`` — RE-EXECUTES the new file in the old process.
+ This is the most dangerous load in the whole flow and it looks like
+ nothing to an import walker. ``_UPDATE_RUNTIME_RELOAD_MODULES`` and
+ ``_reload_config_modules`` reload ``hermes_constants``,
+ ``hermes_cli.config`` and friends by name. Treated as a whole-module
+ requirement.
+* ``getattr(module, "name")`` — a symbol requirement with no import
+ statement. ``managed_uv._windows_runtime_holders`` looks up
+ ``_detect_venv_python_processes`` on ``hermes_cli.main`` this way, and
+ silently refuses the update when it is absent.
+* ``importlib.import_module(x)`` with a non-literal argument — cannot be
+ resolved statically. Reported as UNRESOLVED rather than ignored.
+
+Usage:
+ python scripts/audit-old-updater-imports.py # report
+ python scripts/audit-old-updater-imports.py --json
+ python scripts/audit-old-updater-imports.py --check # CI gate
+ python scripts/audit-old-updater-imports.py --explain hermes_constants
+"""
+
+from __future__ import annotations
+
+import argparse
+import ast
+import json
+import subprocess
+from dataclasses import dataclass, field
+from pathlib import Path
+
+REPO_ROOT = Path(__file__).resolve().parents[1]
+
+# The update flow has lived at both of these paths.
+UPDATE_MODULE_CANDIDATES = (
+ "hermes_cli/update_cmd.py",
+ "hermes_cli/subcommands/update.py",
+)
+
+# Helpers the update flow calls into after the tree moves. Every function
+# in these is treated as post-swap.
+POST_SWAP_HELPER_MODULES = (
+ "hermes_cli/post_update.py",
+ "hermes_cli/managed_uv.py",
+ "hermes_cli/update_lock.py",
+ # pm era: `hermes update` drives the store through the pm package. A pm
+ # module imported BEFORE the swap keeps running as old code afterwards,
+ # so its lazy loads resolve against the NEW tree -- same failure shape
+ # as managed_uv. Files absent at a given revision are simply skipped.
+ "pm/__init__.py",
+ "pm/cli.py",
+ "pm/ensure.py",
+ "pm/extras.py",
+ "pm/lock.py",
+ "pm/package.py",
+ "pm/packages.py",
+ "pm/paths.py",
+ "pm/registry.py",
+ "pm/store.py",
+)
+
+# Only OUR packages matter: a third-party import is pinned by the
+# dependency resolver, not by this repo's file layout.
+FIRST_PARTY_ROOTS = frozenset(
+ {
+ "agent",
+ "gateway",
+ "hermes_cli",
+ "hermes_constants",
+ "hermes_state",
+ "installation",
+ "plugins",
+ "pm",
+ "tools",
+ "utils",
+ }
+)
+
+# Where an update begins. Everything reachable from here can run while
+# the tree is being replaced.
+UPDATE_ENTRYPOINTS = (
+ "cmd_update",
+ "_cmd_update_impl",
+ "_update_via_zip",
+ "_run_update_phase_inline",
+)
+
+_AnyFunc = ast.FunctionDef | ast.AsyncFunctionDef
+
+
+def _first_party(module: str) -> bool:
+ return module.split(".")[0] in FIRST_PARTY_ROOTS
+
+
+@dataclass(frozen=True)
+class Requirement:
+ """One name the updater needs to find in the NEW tree."""
+
+ module: str
+ symbol: str | None
+ kind: str # import | reload | getattr
+ function: str
+ source_file: str
+ guarded: bool = False
+ """True when the load sits in a ``try`` that catches its failure.
+
+ A guarded requirement cannot brick an update — the old code has a
+ fallback arm — so it is reported as informational, not frozen.
+ """
+
+ def key(self) -> tuple[str, str]:
+ return (self.module, self.symbol or "")
+
+
+@dataclass
+class Analysis:
+ path: str
+ requirements: list[Requirement] = field(default_factory=list)
+ unresolved: list[str] = field(default_factory=list)
+ reachable: set[str] = field(default_factory=set)
+
+
+def _function_table(tree: ast.AST) -> dict[str, _AnyFunc]:
+ table: dict[str, _AnyFunc] = {}
+ for node in ast.walk(tree):
+ if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
+ table.setdefault(node.name, node)
+ return table
+
+
+def _called_names(node: ast.AST) -> set[str]:
+ """Function names a piece of code can call.
+
+ Covers the three shapes this codebase uses: ``foo()``,
+ ``module.foo()``, and ``_m().foo()`` — update_cmd's lazy
+ ``hermes_cli.main`` handle, which re-exports these same helpers.
+ Attribute calls that are not ours simply find no match in the
+ module's own function table.
+ """
+ names: set[str] = set()
+ for child in ast.walk(node):
+ if isinstance(child, ast.Call):
+ func = child.func
+ if isinstance(func, ast.Name):
+ names.add(func.id)
+ elif isinstance(func, ast.Attribute):
+ names.add(func.attr)
+ return names
+
+
+def _string_constants(tree: ast.AST) -> dict[str, list[str]]:
+ """Module-level ``NAME = (...)`` / ``NAME = [...]`` string collections.
+
+ ``_UPDATE_RUNTIME_RELOAD_MODULES`` is exactly this shape, and its
+ contents are module names that get reloaded — i.e. re-executed from
+ the new tree.
+ """
+ out: dict[str, list[str]] = {}
+ for node in getattr(tree, "body", []):
+ if not isinstance(node, ast.Assign) or len(node.targets) != 1:
+ continue
+ target = node.targets[0]
+ if not isinstance(target, ast.Name):
+ continue
+ if not isinstance(node.value, (ast.Tuple, ast.List, ast.Set)):
+ continue
+ values = [
+ el.value
+ for el in node.value.elts
+ if isinstance(el, ast.Constant) and isinstance(el.value, str)
+ ]
+ if values:
+ out[target.id] = values
+ return out
+
+
+def _guarded_spans(func: _AnyFunc) -> list[tuple[int, int]]:
+ """Line spans of ``try`` bodies whose handlers catch an import failure.
+
+ ``except Exception``, ``except ImportError`` and bare ``except``
+ all swallow a missing name; a load inside such a body has a fallback
+ arm in the OLD code and cannot brick the update by itself.
+ """
+ spans: list[tuple[int, int]] = []
+ for node in ast.walk(func):
+ if not isinstance(node, ast.Try):
+ continue
+ catches = False
+ for handler in node.handlers:
+ if handler.type is None:
+ catches = True
+ elif isinstance(handler.type, ast.Name) and handler.type.id in (
+ "Exception",
+ "BaseException",
+ "ImportError",
+ "ModuleNotFoundError",
+ "AttributeError",
+ ):
+ catches = True
+ elif isinstance(handler.type, ast.Tuple):
+ for el in handler.type.elts:
+ if isinstance(el, ast.Name) and el.id in (
+ "Exception",
+ "ImportError",
+ "ModuleNotFoundError",
+ "AttributeError",
+ ):
+ catches = True
+ if catches and node.body:
+ first = node.body[0].lineno
+ last = max(
+ getattr(stmt, "end_lineno", stmt.lineno) for stmt in node.body
+ )
+ spans.append((first, last))
+ return spans
+
+
+def _requirements_in(
+ func: _AnyFunc,
+ source_file: str,
+ constants: dict[str, list[str]],
+) -> tuple[list[Requirement], list[str]]:
+ """Every name *func* needs from the new tree, plus what we could not read."""
+ reqs: list[Requirement] = []
+ unresolved: list[str] = []
+ guarded_spans = _guarded_spans(func)
+
+ def _is_guarded(node: ast.AST) -> bool:
+ line = getattr(node, "lineno", None)
+ if line is None:
+ return False
+ return any(first <= line <= last for first, last in guarded_spans)
+
+ def add(
+ module: str, symbol: str | None, kind: str, node: ast.AST
+ ) -> None:
+ if _first_party(module):
+ reqs.append(
+ Requirement(
+ module,
+ symbol,
+ kind,
+ func.name,
+ source_file,
+ guarded=_is_guarded(node),
+ )
+ )
+
+ for child in ast.walk(func):
+ # ── plain lazy imports ─────────────────────────────────────────
+ if isinstance(child, ast.ImportFrom):
+ if not child.level and child.module:
+ for alias in child.names:
+ add(child.module, alias.name, "import", child)
+ elif isinstance(child, ast.Import):
+ for alias in child.names:
+ add(alias.name, None, "import", child)
+
+ # ── dynamic loads ──────────────────────────────────────────────
+ elif isinstance(child, ast.Call):
+ fname = (
+ child.func.attr
+ if isinstance(child.func, ast.Attribute)
+ else child.func.id
+ if isinstance(child.func, ast.Name)
+ else ""
+ )
+
+ if fname in ("reload", "import_module") and child.args:
+ arg = child.args[0]
+ if isinstance(arg, ast.Constant) and isinstance(arg.value, str):
+ # importlib.reload("x") is not legal, but
+ # import_module("x") is — same requirement either way.
+ add(arg.value, None, "reload", child)
+ elif isinstance(arg, ast.Name) and arg.id in constants:
+ for module in constants[arg.id]:
+ add(module, None, "reload", child)
+ else:
+ # A reload of a loop variable: find the collection the
+ # loop walks. `for m in (...)` / `for m in CONST`.
+ resolved = False
+ for loop in ast.walk(func):
+ if not isinstance(loop, ast.For):
+ continue
+ if not (
+ isinstance(loop.target, ast.Name)
+ and isinstance(arg, ast.Name)
+ ):
+ continue
+ names: list[str] = []
+ if isinstance(loop.iter, (ast.Tuple, ast.List)):
+ names = [
+ el.value
+ for el in loop.iter.elts
+ if isinstance(el, ast.Constant)
+ and isinstance(el.value, str)
+ ]
+ elif isinstance(loop.iter, ast.Name):
+ names = constants.get(loop.iter.id, [])
+ for module in names:
+ add(module, None, "reload", child)
+ resolved = True
+ if not resolved:
+ try:
+ text = ast.unparse(child)
+ except Exception: # noqa: BLE001
+ text = f"{fname}(...)"
+ unresolved.append(f"{source_file}:{func.name}: {text[:90]}")
+
+ elif fname == "getattr" and len(child.args) >= 2:
+ holder, attr = child.args[0], child.args[1]
+ if isinstance(attr, ast.Constant) and isinstance(attr.value, str):
+ module = _module_of(holder)
+ if module:
+ add(module, attr.value, "getattr", child)
+
+ return reqs, unresolved
+
+
+def _module_of(node: ast.AST) -> str | None:
+ """Best-effort: which module a getattr target refers to.
+
+ Handles the one real shape — ``sys.modules.get("hermes_cli.main")``
+ stashed in a local and then getattr'd (managed_uv does exactly this).
+ """
+ if isinstance(node, ast.Call):
+ if isinstance(node.func, ast.Attribute) and node.func.attr == "get":
+ if node.args and isinstance(node.args[0], ast.Constant):
+ value = node.args[0].value
+ if isinstance(value, str):
+ return value
+ return None
+
+
+def _resolve_sys_modules_locals(func: _AnyFunc) -> dict[str, str]:
+ """Locals bound to ``sys.modules.get("")`` inside *func*."""
+ bound: dict[str, str] = {}
+ for node in ast.walk(func):
+ if isinstance(node, ast.Assign) and len(node.targets) == 1:
+ target = node.targets[0]
+ if isinstance(target, ast.Name):
+ module = _module_of(node.value)
+ if module:
+ bound[target.id] = module
+ return bound
+
+
+def _getattr_on_bound_locals(
+ func: _AnyFunc, source_file: str
+) -> list[Requirement]:
+ """``m = sys.modules.get("x")`` … ``getattr(m, "y")`` → x.y required."""
+ bound = _resolve_sys_modules_locals(func)
+ if not bound:
+ return []
+ out: list[Requirement] = []
+ for node in ast.walk(func):
+ if not isinstance(node, ast.Call):
+ continue
+ fname = node.func.id if isinstance(node.func, ast.Name) else ""
+ if fname != "getattr" or len(node.args) < 2:
+ continue
+ holder, attr = node.args[0], node.args[1]
+ if not (isinstance(holder, ast.Name) and holder.id in bound):
+ continue
+ if isinstance(attr, ast.Constant) and isinstance(attr.value, str):
+ module = bound[holder.id]
+ if _first_party(module):
+ out.append(
+ Requirement(module, attr.value, "getattr", func.name, source_file)
+ )
+ return out
+
+
+def analyse(source: str, source_file: str, *, entrypoints: bool) -> Analysis | None:
+ """Requirements of one version of one file.
+
+ *entrypoints* selects the reachability seed: an update module starts
+ from ``UPDATE_ENTRYPOINTS``; a post-swap helper module is entered
+ wholesale, so every function in it counts.
+ """
+ try:
+ tree = ast.parse(source)
+ except SyntaxError:
+ return None
+
+ result = Analysis(path=source_file)
+ functions = _function_table(tree)
+ constants = _string_constants(tree)
+
+ if entrypoints:
+ seen: set[str] = set()
+ stack = [name for name in UPDATE_ENTRYPOINTS if name in functions]
+ if not stack:
+ return result # not an update module at this revision
+ while stack:
+ name = stack.pop()
+ if name in seen:
+ continue
+ seen.add(name)
+ for callee in _called_names(functions[name]):
+ if callee in functions and callee not in seen:
+ stack.append(callee)
+ reachable = seen
+ else:
+ reachable = set(functions)
+
+ result.reachable = reachable
+
+ for name in sorted(reachable):
+ func = functions[name]
+ reqs, unresolved = _requirements_in(func, source_file, constants)
+ result.requirements.extend(reqs)
+ result.requirements.extend(_getattr_on_bound_locals(func, source_file))
+ result.unresolved.extend(unresolved)
+
+ return result
+
+
+# ─── history walking ────────────────────────────────────────────────────
+
+
+def _all_audited_paths() -> tuple[str, ...]:
+ return UPDATE_MODULE_CANDIDATES + POST_SWAP_HELPER_MODULES
+
+
+def shipped_commits() -> list[str]:
+ """Commits a user could actually be running, newest first.
+
+ Restricted to ``origin/main``: the update channel is ``main`` or
+ ``stable`` (``installation/tree.py``) and both track this branch.
+ Unmerged topic branches are not something anyone updates from, and
+ including them would freeze names that never shipped.
+
+ Only commits that touched the audited files are listed; every other
+ commit leaves them byte-identical to its parent.
+ """
+ proc = subprocess.run(
+ ["git", "log", "origin/main", "--format=%H", "--", *_all_audited_paths()],
+ cwd=REPO_ROOT,
+ capture_output=True,
+ text=True,
+ check=True,
+ )
+ return [line for line in proc.stdout.split() if line]
+
+
+def _batch_read_blobs(refs: list[str]) -> dict[str, str]:
+ """Read many ``:`` revisions in ONE git process.
+
+ A ``git show`` per pair is thousands of spawns; ``cat-file --batch``
+ streams them through a single pipe. Bytes both ways, because payloads
+ are located by the byte offset in each header and text decoding would
+ shift every offset at the first multi-byte character.
+ """
+ if not refs:
+ return {}
+
+ proc = subprocess.run(
+ ["git", "cat-file", "--batch"],
+ cwd=REPO_ROOT,
+ input=("\n".join(refs) + "\n").encode(),
+ capture_output=True,
+ check=True,
+ )
+
+ out = proc.stdout
+ contents: dict[str, str] = {}
+ pos = 0
+ for ref in refs:
+ newline = out.find(b"\n", pos)
+ if newline == -1:
+ break
+ header = out[pos:newline].decode("utf-8", "replace")
+ pos = newline + 1
+ if header.endswith(" missing"):
+ continue
+ try:
+ size = int(header.rsplit(" ", 1)[1])
+ except (IndexError, ValueError):
+ continue
+ contents[ref] = out[pos : pos + size].decode("utf-8", "replace")
+ pos += size + 1
+ return contents
+
+
+@dataclass
+class Surface:
+ required: dict[tuple[str, str], set[str]] = field(default_factory=dict)
+ kinds: dict[tuple[str, str], set[str]] = field(default_factory=dict)
+ sites: dict[tuple[str, str], set[str]] = field(default_factory=dict)
+ guarded_only: set[tuple[str, str]] = field(default_factory=set)
+ """Pairs whose every load site sits under a swallowing ``try``.
+
+ These cannot brick an update (the old code has a fallback arm), so
+ they are informational: reported, but absent from the frozen surface
+ and never fatal in ``--check``.
+ """
+ unresolved: set[str] = field(default_factory=set)
+ stats: dict = field(default_factory=dict)
+
+
+def audit_history() -> Surface:
+ commits = shipped_commits()
+ paths = _all_audited_paths()
+ refs = [f"{c}:{p}" for c in commits for p in paths]
+ blobs = _batch_read_blobs(refs)
+
+ surface = Surface()
+ memo: dict[int, Analysis | None] = {}
+ distinct = 0
+ bare_pairs: set[tuple[str, str]] = set()
+
+ for ref, source in blobs.items():
+ commit, _, path = ref.partition(":")
+ fingerprint = hash(source)
+ if fingerprint not in memo:
+ memo[fingerprint] = analyse(
+ source, path, entrypoints=path in UPDATE_MODULE_CANDIDATES
+ )
+ distinct += 1
+ analysis = memo[fingerprint]
+ if analysis is None:
+ continue
+ for req in analysis.requirements:
+ surface.required.setdefault(req.key(), set()).add(commit[:12])
+ surface.kinds.setdefault(req.key(), set()).add(req.kind)
+ surface.sites.setdefault(req.key(), set()).add(
+ f"{req.source_file}:{req.function}"
+ )
+ if not req.guarded:
+ bare_pairs.add(req.key())
+ surface.unresolved.update(analysis.unresolved)
+
+ surface.guarded_only = set(surface.required) - bare_pairs
+ surface.stats = {
+ "commits": len(commits),
+ "revisions_read": len(blobs),
+ "distinct_file_versions": distinct,
+ }
+ return surface
+
+
+def audit_tree() -> Surface:
+ """The surface of the update flow as it exists in THIS working tree.
+
+ The pm rewrite replaced the installation/* + managed_uv update stack
+ wholesale, so the shipped-history walk over origin/main freezes names
+ (installation.*, hermes_cli.managed_uv.*) that this branch deliberately
+ deleted along with the updaters that loaded them. On this lineage the
+ honest contract is the CURRENT update flow: freeze what today's updater
+ lazy-loads mid-swap, so a FUTURE rename of any of those names turns the
+ test red before it bricks a live update. Once this branch is the shipped
+ channel, every release cut from it re-enters the surface by regenerating
+ against the tree that shipped it.
+ """
+ surface = Surface()
+ bare_pairs: set[tuple[str, str]] = set()
+ analyzed: list[str] = []
+
+ for path in _all_audited_paths():
+ file = REPO_ROOT / path
+ if not file.is_file():
+ continue
+ source = file.read_text(encoding="utf-8-sig", errors="replace")
+ analysis = analyse(
+ source, path, entrypoints=path in UPDATE_MODULE_CANDIDATES
+ )
+ if analysis is None or (
+ path in UPDATE_MODULE_CANDIDATES and not analysis.reachable
+ ):
+ continue
+ analyzed.append(path)
+ for req in analysis.requirements:
+ surface.required.setdefault(req.key(), set()).add("worktree")
+ surface.kinds.setdefault(req.key(), set()).add(req.kind)
+ surface.sites.setdefault(req.key(), set()).add(
+ f"{req.source_file}:{req.function}"
+ )
+ if not req.guarded:
+ bare_pairs.add(req.key())
+ surface.unresolved.update(analysis.unresolved)
+
+ surface.guarded_only = set(surface.required) - bare_pairs
+ surface.stats = {"mode": "tree", "files_analyzed": sorted(analyzed)}
+ return surface
+
+
+# ─── resolution against the working tree ────────────────────────────────
+
+
+def resolve_in_tree(module: str, symbol: str | None, root: Path) -> tuple[bool, str]:
+ """Does *module* (and *symbol*) exist in the tree at *root*?
+
+ Static resolution against the FILES, deliberately: importing would
+ RUN the module, and the question is what an updater finds on disk,
+ not what this interpreter can execute.
+ """
+ rel = Path(module.replace(".", "/"))
+ for candidate in (root / f"{rel}.py", root / rel / "__init__.py"):
+ if candidate.is_file():
+ path = candidate
+ break
+ else:
+ return False, f"module {module} not found"
+
+ if symbol is None:
+ return True, ""
+
+ # `from hermes_cli import gateway_windows` names a SUBMODULE, not an
+ # attribute of the package body.
+ submodule = root / rel / symbol
+ if submodule.with_suffix(".py").is_file() or (submodule / "__init__.py").is_file():
+ return True, ""
+
+ try:
+ tree = ast.parse(path.read_text(encoding="utf-8-sig"))
+ except (OSError, SyntaxError) as exc:
+ return False, f"{module}: unreadable ({exc})"
+
+ for node in ast.walk(tree):
+ if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
+ if node.name == symbol:
+ return True, ""
+ elif isinstance(node, ast.Assign):
+ for target in node.targets:
+ if isinstance(target, ast.Name) and target.id == symbol:
+ return True, ""
+ elif isinstance(node, ast.AnnAssign):
+ if isinstance(node.target, ast.Name) and node.target.id == symbol:
+ return True, ""
+ elif isinstance(node, (ast.Import, ast.ImportFrom)):
+ # A re-export counts.
+ for alias in node.names:
+ if (alias.asname or alias.name.split(".")[0]) == symbol:
+ return True, ""
+
+ return False, f"{module}.{symbol} not found"
+
+
+def main(argv: list[str] | None = None) -> int:
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument("--json", action="store_true", help="Emit JSON.")
+ parser.add_argument(
+ "--check",
+ action="store_true",
+ help="Exit nonzero when this tree is missing something an old "
+ "updater needs.",
+ )
+ parser.add_argument(
+ "--explain", metavar="MODULE", help="Show every requirement on MODULE."
+ )
+ parser.add_argument(
+ "--history",
+ action="store_true",
+ help="Walk every shipped revision on origin/main instead of the "
+ "working tree. The pm rewrite retired that lineage's updaters, so "
+ "the default is the working-tree surface; the walk remains for "
+ "archaeology.",
+ )
+ parser.add_argument(
+ "--freeze",
+ metavar="PATH",
+ help="Write the surface as JSON (the file the enforcing test reads). "
+ "The default audits the working tree; --history re-walks shipped "
+ "revisions on origin/main (needs a full clone).",
+ )
+ ns = parser.parse_args(argv)
+
+ surface = audit_history() if ns.history else audit_tree()
+
+ if ns.freeze:
+ payload = {
+ "_comment": (
+ "Generated by scripts/audit-old-updater-imports.py --freeze. "
+ "Names an already-running `hermes update` loads from the NEW "
+ "tree after the checkout swap. Deleting a bare name bricks "
+ "every release that loads it, mid-update, on a half-new "
+ "tree. Regenerate after changing the update flow; never "
+ "hand-trim."
+ ),
+ "stats": surface.stats,
+ "bare": sorted(
+ f"{m}::{s}"
+ for (m, s) in surface.required
+ if (m, s) not in surface.guarded_only
+ ),
+ "guarded_only": sorted(
+ f"{m}::{s}" for (m, s) in surface.guarded_only
+ ),
+ }
+ Path(ns.freeze).write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8")
+ print(
+ f"froze {len(payload['bare'])} bare + "
+ f"{len(payload['guarded_only'])} guarded pairs -> {ns.freeze}"
+ )
+ return 0
+
+ missing = []
+ soft_missing = []
+ for (module, symbol), commits in sorted(surface.required.items()):
+ ok, why = resolve_in_tree(module, symbol or None, REPO_ROOT)
+ if not ok:
+ if (module, symbol) in surface.guarded_only:
+ soft_missing.append((module, symbol, sorted(commits), why))
+ else:
+ missing.append((module, symbol, sorted(commits), why))
+
+ if ns.explain:
+ print(f"Requirements on {ns.explain!r}:")
+ for (module, symbol), commits in sorted(surface.required.items()):
+ if module != ns.explain:
+ continue
+ kinds = "/".join(sorted(surface.kinds[(module, symbol)]))
+ where = ", ".join(sorted(surface.sites[(module, symbol)])[:3])
+ print(
+ f" {module}.{symbol or ''} [{kinds}]"
+ f" {len(commits)} commits {where}"
+ )
+ return 0
+
+ if ns.json:
+ print(
+ json.dumps(
+ {
+ "stats": surface.stats,
+ "required": [
+ {
+ "module": m,
+ "symbol": s or None,
+ "kinds": sorted(surface.kinds[(m, s)]),
+ "commits": sorted(c),
+ "sites": sorted(surface.sites[(m, s)]),
+ "guarded_only": (m, s) in surface.guarded_only,
+ }
+ for (m, s), c in sorted(surface.required.items())
+ ],
+ "unresolved_dynamic": sorted(surface.unresolved),
+ "missing": [
+ {"module": m, "symbol": s or None, "commits": c, "why": w}
+ for m, s, c, w in missing
+ ],
+ "soft_missing": [
+ {"module": m, "symbol": s or None, "commits": c, "why": w}
+ for m, s, c, w in soft_missing
+ ],
+ },
+ indent=2,
+ )
+ )
+ return 1 if (missing and ns.check) else 0
+
+ st = surface.stats
+ if st.get("mode") == "tree":
+ print(
+ f"Audited the update flow in this working tree: "
+ f"{len(st['files_analyzed'])} files "
+ f"({', '.join(st['files_analyzed'])})."
+ )
+ else:
+ print(
+ f"Walked every shipped commit that touched the update flow: "
+ f"{st['commits']} commits, {st['revisions_read']} file revisions, "
+ f"{st['distinct_file_versions']} distinct versions."
+ )
+ print()
+ by_kind: dict[str, int] = {}
+ for kinds in surface.kinds.values():
+ for kind in kinds:
+ by_kind[kind] = by_kind.get(kind, 0) + 1
+ kinds_summary = ", ".join(f"{v} {k}" for k, v in sorted(by_kind.items()))
+ hard = {k for k in surface.required if k not in surface.guarded_only}
+ print(
+ f"FROZEN COMPAT SURFACE — {len(hard)} module/symbol pairs an old "
+ f"updater can load BARE from the NEW tree ({kinds_summary} overall; "
+ f"{len(surface.guarded_only)} more guarded-only, listed after):"
+ )
+ by_module: dict[str, list[str]] = {}
+ for module, symbol in hard:
+ by_module.setdefault(module, []).append(symbol or "")
+ for module in sorted(by_module):
+ print(f" {module}: {', '.join(sorted(by_module[module]))}")
+
+ if surface.guarded_only:
+ print()
+ print(
+ f"GUARDED-ONLY — {len(surface.guarded_only)} pairs loaded solely "
+ f"under a swallowing try (deleting one degrades a fallback arm, "
+ f"not the update):"
+ )
+ by_module = {}
+ for module, symbol in surface.guarded_only:
+ by_module.setdefault(module, []).append(symbol or "")
+ for module in sorted(by_module):
+ print(f" {module}: {', '.join(sorted(by_module[module]))}")
+
+ if surface.unresolved:
+ print()
+ print(
+ f"! {len(surface.unresolved)} dynamic load(s) this script cannot "
+ f"resolve — audit by hand before deleting anything they may reach:"
+ )
+ for item in sorted(surface.unresolved):
+ print(f" {item}")
+
+ print()
+ if missing:
+ print(f"X {len(missing)} name(s) an old updater needs are GONE:")
+ for module, symbol, commits, why in missing:
+ kinds = "/".join(sorted(surface.kinds[(module, symbol or "")]))
+ where = ", ".join(sorted(surface.sites[(module, symbol or "")])[:2])
+ shown = ", ".join(commits[:3])
+ more = f" +{len(commits) - 3}" if len(commits) > 3 else ""
+ print(f" [{kinds}] {why}\n from {where} [{shown}{more}]")
+ else:
+ print("OK: every name an old updater needs still exists in this tree")
+
+ if soft_missing:
+ print()
+ print(
+ f"o {len(soft_missing)} guarded-only name(s) gone — fallback arms "
+ f"now taken, worth knowing but not fatal:"
+ )
+ for module, symbol, commits, why in soft_missing:
+ where = ", ".join(sorted(surface.sites[(module, symbol or "")])[:2])
+ print(f" {why} (from {where})")
+
+ return 1 if (missing and ns.check) else 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/tests/agent/test_skill_utils.py b/tests/agent/test_skill_utils.py
index 26211ba604..646357c412 100644
--- a/tests/agent/test_skill_utils.py
+++ b/tests/agent/test_skill_utils.py
@@ -214,55 +214,27 @@ def test_skill_support_path_uses_explicit_discovery_root_not_cwd(tmp_path, monke
assert is_excluded_skill_path(relative, root=discovery_root) is True
-# ── skill_matches_platform on Termux ──────────────────────────────────────
+# ── skill_matches_platform─────────────────────────────────────────────────
-class TestSkillMatchesPlatformTermux:
- """Termux is Linux userland on Android. Skills tagged platforms:[linux]
- must load there regardless of whether Python reports sys.platform as
- "linux" (pre-3.13) or "android" (3.13+). Reported by user @LikiusInik
- in May 2026 — only 3 built-in skills appeared on Termux because every
- github/productivity/mlops skill is tagged platforms:[linux,macos,windows]
- and sys.platform=="android" did not start with "linux".
- """
-
+class TestSkillMatchesPlatform:
def test_no_platforms_field_matches_everywhere(self):
# Backward-compat default — skills without a platforms tag load
- # on any OS, Termux included.
- with patch("agent.skill_utils.sys.platform", "android"), patch(
- "agent.skill_utils.is_termux", return_value=True
- ):
+ # on any OS.
+ with patch("agent.skill_utils.sys.platform", "linux"):
assert skill_matches_platform({}) is True
assert skill_matches_platform({"name": "foo"}) is True
-
-
-
-
-
-
- def test_non_termux_android_does_not_widen(self):
- # If we're somehow on a plain Android Python (not Termux), don't
- # silently load Linux skills — Termux is the supported environment.
+ def test_linux_skill_matches_only_linux(self):
fm = {"platforms": ["linux"]}
- with patch("agent.skill_utils.sys.platform", "android"), patch(
- "agent.skill_utils.is_termux", return_value=False
- ):
+ with patch("agent.skill_utils.sys.platform", "linux"):
+ assert skill_matches_platform(fm) is True
+ assert skill_matches_platform_list(fm["platforms"]) is True
+ with patch("agent.skill_utils.sys.platform", "darwin"):
assert skill_matches_platform(fm) is False
assert skill_matches_platform_list(fm["platforms"]) is False
- def test_linux_skill_on_real_linux_unaffected(self):
- # The non-Termux Linux path must not change.
- fm = {"platforms": ["linux"]}
- with patch("agent.skill_utils.sys.platform", "linux"), patch(
- "agent.skill_utils.is_termux", return_value=False
- ):
- assert skill_matches_platform(fm) is True
- assert skill_matches_platform_list(fm["platforms"]) is True
-
-
-class TestNormalizeSkillLookupName:
def test_relative_path_unchanged(self, tmp_path, monkeypatch):
from agent.skill_utils import normalize_skill_lookup_name
@@ -337,11 +309,10 @@ class TestParseFrontmatterBOM:
import sys
expected = sys.platform == "darwin"
- with patch("agent.skill_utils.is_termux", return_value=False):
- plain_fm, _ = parse_frontmatter(self.SKILL)
- bom_fm, _ = parse_frontmatter("\ufeff" + self.SKILL)
- assert skill_matches_platform(plain_fm) is expected
- assert skill_matches_platform(bom_fm) is expected
+ plain_fm, _ = parse_frontmatter(self.SKILL)
+ bom_fm, _ = parse_frontmatter("\ufeff" + self.SKILL)
+ assert skill_matches_platform(plain_fm) is expected
+ assert skill_matches_platform(bom_fm) is expected
def test_real_file_read_path(self, tmp_path):
diff --git a/tests/cli/test_cli_image_command.py b/tests/cli/test_cli_image_command.py
index 573efbe77e..80e0006887 100644
--- a/tests/cli/test_cli_image_command.py
+++ b/tests/cli/test_cli_image_command.py
@@ -5,7 +5,6 @@ from cli import (
HermesCLI,
_collect_query_images,
_format_image_attachment_badges,
- _termux_example_image_path,
)
@@ -68,16 +67,6 @@ class TestCollectQueryImages:
assert images == [img]
-class TestTermuxImageHints:
- def test_termux_example_image_path_prefers_real_shared_storage_root(self, monkeypatch):
- existing = {"/sdcard", "/storage/emulated/0"}
- monkeypatch.setattr("cli.os.path.isdir", lambda path: path in existing)
-
- hint = _termux_example_image_path()
-
- assert hint == "/sdcard/Pictures/cat.png"
-
-
class TestImageBadgeFormatting:
def test_compact_badges_use_filename_on_narrow_terminals(self, tmp_path):
img = _make_image(tmp_path / "Screenshot 2026-04-09 at 11.22.33 AM.png")
diff --git a/tests/cli/test_resume_quiet_stderr.py b/tests/cli/test_resume_quiet_stderr.py
index df82ce4dda..8adb12baeb 100644
--- a/tests/cli/test_resume_quiet_stderr.py
+++ b/tests/cli/test_resume_quiet_stderr.py
@@ -11,7 +11,7 @@ Interactive mode (tool_progress_mode == "full") still uses ChatConsole.
"""
from datetime import datetime
-from unittest.mock import MagicMock, patch
+from unittest.mock import MagicMock
from cli import HermesCLI
@@ -47,8 +47,7 @@ class TestResumeQuietStderr:
db.get_session.return_value = None
cli = _make_cli(quiet=True, db=db)
- with patch("cli._prepare_deferred_agent_startup"):
- result = cli._init_agent()
+ result = cli._init_agent()
captured = capsys.readouterr()
assert result is False
@@ -63,8 +62,7 @@ class TestResumeQuietStderr:
db.get_session.return_value = None
cli = _make_cli(quiet=False, db=db)
- with patch("cli._prepare_deferred_agent_startup"):
- result = cli._init_agent()
+ result = cli._init_agent()
captured = capsys.readouterr()
assert result is False
@@ -84,14 +82,13 @@ class TestResumeQuietStderr:
cli = _make_cli(quiet=True, db=db)
# Stop _init_agent right after the resume banner: prevent it from
# constructing a real AIAgent (the next code path).
- with patch("cli._prepare_deferred_agent_startup"):
- try:
- cli._init_agent()
- except Exception:
- # The post-resume agent-init machinery may fail in this
- # stubbed context (no API key, no real config) — we only
- # care about the printed banner that comes earlier.
- pass
+ try:
+ cli._init_agent()
+ except Exception:
+ # The post-resume agent-init machinery may fail in this
+ # stubbed context (no API key, no real config) — we only
+ # care about the printed banner that comes earlier.
+ pass
captured = capsys.readouterr()
# Banner on stderr — stdout stays clean for automation.
@@ -108,11 +105,10 @@ class TestResumeQuietStderr:
db._conn = MagicMock()
cli = _make_cli(quiet=True, db=db)
- with patch("cli._prepare_deferred_agent_startup"):
- try:
- cli._init_agent()
- except Exception:
- pass
+ try:
+ cli._init_agent()
+ except Exception:
+ pass
captured = capsys.readouterr()
assert "has no messages" not in captured.out
diff --git a/tests/hermes_cli/test_cli_startup_model_cost_guard.py b/tests/hermes_cli/test_cli_startup_model_cost_guard.py
index 6ad2347efb..9b38985f6a 100644
--- a/tests/hermes_cli/test_cli_startup_model_cost_guard.py
+++ b/tests/hermes_cli/test_cli_startup_model_cost_guard.py
@@ -48,7 +48,6 @@ def main_mod(monkeypatch):
monkeypatch.setattr(mod, "_has_any_provider_configured", lambda: True)
monkeypatch.setattr(mod, "_sync_bundled_skills_for_startup", lambda: None)
- monkeypatch.setattr(mod, "_termux_should_prefetch_update_check", lambda: False)
monkeypatch.setattr(mod, "_pin_kanban_board_env", lambda: None)
monkeypatch.setattr(mod, "_resolve_session_by_name_or_id", lambda val: val)
monkeypatch.setattr(mod, "_oneshot_cleanup_done", False)
diff --git a/tests/hermes_cli/test_cmd_update_apt.py b/tests/hermes_cli/test_cmd_update_apt.py
deleted file mode 100644
index d9e8a3ea58..0000000000
--- a/tests/hermes_cli/test_cmd_update_apt.py
+++ /dev/null
@@ -1,47 +0,0 @@
-"""APT-managed Hermes installs must never fall through to the git updater."""
-
-from __future__ import annotations
-
-from types import SimpleNamespace
-from unittest.mock import patch
-
-import pytest
-
-from hermes_cli.main import _cmd_update_check, cmd_update
-
-
-def test_apt_stamp_is_detected_and_recommends_pkg_upgrade(tmp_path):
- from hermes_cli.config import detect_install_method, recommended_update_command_for_method
-
- (tmp_path / ".install_method").write_text("apt\n", encoding="utf-8")
- assert detect_install_method(project_root=tmp_path) == "apt"
- assert recommended_update_command_for_method("apt") == "pkg upgrade hermes-agent"
-
-
-@patch("hermes_cli.config.is_managed", return_value=False)
-@patch("hermes_cli.config.detect_install_method", return_value="apt")
-@patch("subprocess.run")
-def test_cmd_update_apt_prints_pkg_guidance_without_git(
- mock_run, _mock_method, _mock_managed, capsys
-):
- with pytest.raises(SystemExit) as excinfo:
- cmd_update(SimpleNamespace(check=False))
-
- # exit 2 = refused-by-contract (#91277 Phase 3), distinct from exit-1 errors
- assert excinfo.value.code == 2
- assert "pkg upgrade hermes-agent" in capsys.readouterr().out
- assert mock_run.call_args_list == []
-
-
-@patch("hermes_cli.config.detect_install_method", return_value="apt")
-@patch("subprocess.run")
-def test_cmd_update_check_apt_prints_pkg_guidance_without_git(
- mock_run, _mock_method, capsys
-):
- with pytest.raises(SystemExit) as excinfo:
- _cmd_update_check()
-
- # exit 2 = refused-by-contract (#91277 Phase 3)
- assert excinfo.value.code == 2
- assert "pkg upgrade hermes-agent" in capsys.readouterr().out
- assert mock_run.call_args_list == []
diff --git a/tests/hermes_cli/test_default_interface_resolution.py b/tests/hermes_cli/test_default_interface_resolution.py
index 431e84c3f3..c614420f1d 100644
--- a/tests/hermes_cli/test_default_interface_resolution.py
+++ b/tests/hermes_cli/test_default_interface_resolution.py
@@ -20,9 +20,9 @@ violation" on every attempt).
These tests pin that precedence at every layer that makes the decision:
* ``_resolve_use_tui(args)`` — the canonical args-aware resolver used by
- ``cmd_chat`` and the Termux fast-TUI path.
+ ``cmd_chat`` and the fast-TUI path.
* ``_wants_tui_early(argv)`` — the dependency-free early resolver used by
- mouse-residue suppression and the Termux fast paths, before argparse and
+ mouse-residue suppression and the fast paths, before argparse and
``hermes_cli.config`` are importable.
* the argument parser — both ``--cli`` and ``--tui`` parse at the top
level and under the ``chat`` subcommand and are relaunch-inherited.
diff --git a/tests/hermes_cli/test_doctor.py b/tests/hermes_cli/test_doctor.py
index 4ffa3eb480..211448a475 100644
--- a/tests/hermes_cli/test_doctor.py
+++ b/tests/hermes_cli/test_doctor.py
@@ -18,14 +18,6 @@ from hermes_cli.doctor import _has_provider_env_config
class TestDoctorPlatformHints:
- def test_termux_package_hint(self, monkeypatch):
- monkeypatch.setenv("TERMUX_VERSION", "0.118.3")
- monkeypatch.setenv("PREFIX", "/data/data/com.termux/files/usr")
- assert doctor._is_termux() is True
- assert doctor._python_install_cmd() == "python -m pip install"
- assert doctor._system_package_install_cmd("ripgrep") == "pkg install ripgrep"
-
-
def test_sqlite_upgrade_hint_recreates_docker_containers(self, monkeypatch):
monkeypatch.setattr(doctor, "detect_install_method", lambda _root: "docker")
@@ -40,11 +32,13 @@ class TestDoctorPlatformHints:
assert "run `hermes update`" in hint
- def test_sqlite_upgrade_hint_uses_pkg_for_apt_managed_install(self):
+ def test_sqlite_upgrade_hint_apt_stamp_falls_back_to_generic_update(self):
+ # The Termux 'apt' lane was removed; a legacy stamp now routes to the
+ # generic `hermes update` path.
hint = doctor._sqlite_upgrade_hint("apt")
- assert "run `pkg upgrade hermes-agent`" in hint
- assert "hermes update" not in hint
+ assert "run `hermes update`" in hint
+ assert "pkg upgrade" not in hint
def test_sqlite_upgrade_hint_preserves_nix_guidance_as_prose(self):
guidance = doctor.recommended_update_command_for_method("nix")
@@ -153,8 +147,10 @@ class TestDoctorEnvFileEncoding:
def gbk_like_read_text(self, encoding=None, errors=None, **kwargs):
# Simulate a GBK locale: refuse to decode this specific UTF-8
- # .env unless the caller pins encoding="utf-8".
- if self == env_path and encoding != "utf-8":
+ # .env unless the caller pins a UTF-8 codec. utf-8-sig is the
+ # sanctioned read encoding (Windows tools BOM-prefix files it
+ # touches); it decodes BOM-less UTF-8 identically.
+ if self == env_path and encoding not in ("utf-8", "utf-8-sig"):
raise UnicodeDecodeError(
"gbk", b"\x94", 0, 1, "illegal multibyte sequence"
)
@@ -341,37 +337,6 @@ class TestDoctorMemoryProviderSection:
assert "Built-in memory active" not in out
-def test_run_doctor_termux_treats_docker_and_browser_warnings_as_expected(monkeypatch, tmp_path):
- helper = TestDoctorMemoryProviderSection()
- monkeypatch.setenv("TERMUX_VERSION", "0.118.3")
- monkeypatch.setenv("PREFIX", "/data/data/com.termux/files/usr")
-
- real_which = doctor_mod.shutil.which
-
- def fake_which(cmd):
- if cmd in {"docker", "node", "npm"}:
- return None
- return real_which(cmd)
-
- monkeypatch.setattr(doctor_mod.shutil, "which", fake_which)
-
- out = helper._run_doctor_and_capture(monkeypatch, tmp_path, provider="")
-
- assert "Docker backend is not available inside Termux" in out
- assert "Node.js not found (browser tools are optional in the tested Termux path)" in out
- assert "Install Node.js on Termux with: pkg install nodejs" in out
- assert "Termux browser setup:" in out
- assert "1) pkg install nodejs" in out
- assert "2) npm install -g agent-browser" in out
- assert "3) agent-browser install" in out
- assert "Termux compatibility fallbacks:" in out
- assert "use .[termux-all] for broad compatibility" in out
- assert "Matrix E2EE extra is excluded on Termux" in out
- assert "Local faster-whisper extra is excluded on Termux" in out
- assert "STT fallback: use Groq Whisper (set GROQ_API_KEY) or OpenAI Whisper (set VOICE_TOOLS_OPENAI_KEY)." in out
- assert "docker not found (optional)" not in out
-
-
def test_run_doctor_accepts_named_provider_from_providers_section(monkeypatch, tmp_path):
home = tmp_path / ".hermes"
home.mkdir(parents=True, exist_ok=True)
@@ -697,52 +662,8 @@ def test_run_doctor_accepts_kimi_coding_cn_provider(monkeypatch, tmp_path):
assert "model.provider 'kimi-coding-cn' is not a recognised provider" not in out
-def test_run_doctor_termux_does_not_mark_browser_available_without_agent_browser(monkeypatch, tmp_path):
- home = tmp_path / ".hermes"
- home.mkdir(parents=True, exist_ok=True)
- (home / "config.yaml").write_text("memory: {}\n", encoding="utf-8")
- project = tmp_path / "project"
- project.mkdir(exist_ok=True)
-
- monkeypatch.setenv("TERMUX_VERSION", "0.118.3")
- monkeypatch.setenv("PREFIX", "/data/data/com.termux/files/usr")
- monkeypatch.setattr(doctor_mod, "HERMES_HOME", home)
- monkeypatch.setattr(doctor_mod, "PROJECT_ROOT", project)
- monkeypatch.setattr(doctor_mod, "_DHH", str(home))
- monkeypatch.setattr(doctor_mod.shutil, "which", lambda cmd: "/data/data/com.termux/files/usr/bin/node" if cmd in {"node", "npm"} else None)
-
- fake_model_tools = types.SimpleNamespace(
- check_tool_availability=lambda *a, **kw: (["terminal"], [{"name": "browser", "env_vars": [], "tools": ["browser_navigate"]}]),
- TOOLSET_REQUIREMENTS={
- "terminal": {"name": "terminal"},
- "browser": {"name": "browser"},
- },
- )
- monkeypatch.setitem(sys.modules, "model_tools", fake_model_tools)
-
- try:
- from hermes_cli import auth as _auth_mod
- monkeypatch.setattr(_auth_mod, "get_nous_auth_status_local", lambda: {})
- monkeypatch.setattr(_auth_mod, "get_codex_auth_status", lambda: {})
- monkeypatch.setattr(_auth_mod, "get_xai_oauth_auth_status", lambda: {})
- except Exception:
- pass
-
- import io, contextlib
- buf = io.StringIO()
- with contextlib.redirect_stdout(buf):
- doctor_mod.run_doctor(Namespace(fix=False))
- out = buf.getvalue()
-
- assert "✓ browser" not in out
- assert "browser" in out
- assert "system dependency not met" in out
- assert "agent-browser is not installed (expected in the tested Termux path)" in out
- assert "npm install -g agent-browser && agent-browser install" in out
-
-
def _doctor_env_for_agent_browser(monkeypatch, tmp_path):
- """Shared non-Termux fixture setup for the agent-browser npx-resolution
+ """Shared fixture setup for the agent-browser npx-resolution
branch in run_doctor (hermes_cli/doctor.py ~1557-1605)."""
home = tmp_path / ".hermes"
home.mkdir(parents=True, exist_ok=True)
@@ -750,7 +671,6 @@ def _doctor_env_for_agent_browser(monkeypatch, tmp_path):
project = tmp_path / "project"
project.mkdir(exist_ok=True)
- monkeypatch.delenv("TERMUX_VERSION", raising=False)
monkeypatch.setenv("PREFIX", "/usr")
monkeypatch.setattr(doctor_mod, "HERMES_HOME", home)
monkeypatch.setattr(doctor_mod, "PROJECT_ROOT", project)
diff --git a/tests/hermes_cli/test_doctor_command_install.py b/tests/hermes_cli/test_doctor_command_install.py
index c6b2da7d15..1f3248dee3 100644
--- a/tests/hermes_cli/test_doctor_command_install.py
+++ b/tests/hermes_cli/test_doctor_command_install.py
@@ -130,22 +130,3 @@ class TestDoctorCommandInstallation:
assert "Command Installation" in out
assert "Venv entry point not found" in out
-
-
- @pytest.mark.skipif(sys.platform == "win32", reason="Symlink check is Unix-only")
- def test_termux_uses_prefix_bin(self, monkeypatch, tmp_path):
- """On Termux, the command link dir is $PREFIX/bin."""
- prefix_dir = tmp_path / "termux_prefix"
- prefix_bin = prefix_dir / "bin"
- prefix_bin.mkdir(parents=True)
-
- home, project, hermes_bin = _setup_doctor_env(monkeypatch, tmp_path)
-
- monkeypatch.setenv("TERMUX_VERSION", "0.118.3")
- monkeypatch.setenv("PREFIX", str(prefix_dir))
- monkeypatch.setattr(Path, "home", lambda: tmp_path)
-
- out = _run_doctor(fix=False)
- assert "Command Installation" in out
- assert "$PREFIX/bin" in out
-
diff --git a/tests/hermes_cli/test_doctor_live.py b/tests/hermes_cli/test_doctor_live.py
index 87b3ba536b..64a1dd9fa7 100644
--- a/tests/hermes_cli/test_doctor_live.py
+++ b/tests/hermes_cli/test_doctor_live.py
@@ -219,18 +219,6 @@ class TestBrowserAvailableNpxRung:
assert _real_browser_available() is False
- def test_false_on_termux_local_bare_npx(self, monkeypatch, tmp_path):
- """On Termux in local mode the bare npx fallback is too fragile to
- advertise as ready — must not diverge from dep_ensure/nous_subscription's
- same carve-out."""
- self._block_path_and_node_modules_checks(monkeypatch, tmp_path)
- import tools.browser_tool as bt
-
- monkeypatch.setattr(bt, "_find_agent_browser", lambda **_kw: "npx agent-browser")
- monkeypatch.setattr(bt, "_requires_real_termux_browser_install", lambda cmd: True)
-
- assert _real_browser_available() is False
-
class TestFailureIsolation:
def test_one_probe_raising_does_not_stop_others(self, monkeypatch):
diff --git a/tests/hermes_cli/test_gateway.py b/tests/hermes_cli/test_gateway.py
index 02ff059ccc..905dda1d74 100644
--- a/tests/hermes_cli/test_gateway.py
+++ b/tests/hermes_cli/test_gateway.py
@@ -291,7 +291,6 @@ def test_s6_runtime_snapshot_reports_supervised_service(monkeypatch, tmp_path):
class TestSystemdLingerStatus:
def test_reports_enabled(self, monkeypatch):
monkeypatch.setattr(gateway, "is_linux", lambda: True)
- monkeypatch.setattr(gateway, "is_termux", lambda: False)
monkeypatch.setenv("USER", "alice")
monkeypatch.setattr(
gateway.subprocess,
@@ -303,16 +302,9 @@ class TestSystemdLingerStatus:
assert gateway.get_systemd_linger_status() == (True, "")
- def test_reports_termux_as_not_supported(self, monkeypatch):
- monkeypatch.setattr(gateway, "is_termux", lambda: True)
-
- assert gateway.get_systemd_linger_status() == (None, "not supported in Termux")
-
-
class TestContainerSystemdSupport:
def test_supports_systemd_services_in_container_with_user_manager(self, monkeypatch):
monkeypatch.setattr(gateway, "is_linux", lambda: True)
- monkeypatch.setattr(gateway, "is_termux", lambda: False)
monkeypatch.setattr(gateway, "is_wsl", lambda: False)
monkeypatch.setattr(gateway, "is_container", lambda: True)
monkeypatch.setattr("shutil.which", lambda name: "/usr/bin/systemctl")
diff --git a/tests/hermes_cli/test_gateway_linger.py b/tests/hermes_cli/test_gateway_linger.py
index ac0efcfcd8..27f9283f54 100644
--- a/tests/hermes_cli/test_gateway_linger.py
+++ b/tests/hermes_cli/test_gateway_linger.py
@@ -8,7 +8,6 @@ import hermes_cli.gateway as gateway
class TestEnsureLingerEnabled:
def test_linger_already_enabled_via_file(self, monkeypatch, capsys):
monkeypatch.setattr(gateway, "is_linux", lambda: True)
- monkeypatch.setattr(gateway, "is_termux", lambda: False)
monkeypatch.setattr("getpass.getuser", lambda: "testuser")
monkeypatch.setattr(gateway, "Path", lambda _path: SimpleNamespace(exists=lambda: True))
@@ -24,7 +23,6 @@ class TestEnsureLingerEnabled:
def test_loginctl_success_enables_linger(self, monkeypatch, capsys):
monkeypatch.setattr(gateway, "is_linux", lambda: True)
- monkeypatch.setattr(gateway, "is_termux", lambda: False)
monkeypatch.setattr("getpass.getuser", lambda: "testuser")
monkeypatch.setattr(gateway, "Path", lambda _path: SimpleNamespace(exists=lambda: False))
monkeypatch.setattr(gateway, "get_systemd_linger_status", lambda: (False, ""))
@@ -48,7 +46,6 @@ class TestEnsureLingerEnabled:
def test_loginctl_failure_shows_manual_guidance(self, monkeypatch, capsys):
monkeypatch.setattr(gateway, "is_linux", lambda: True)
- monkeypatch.setattr(gateway, "is_termux", lambda: False)
monkeypatch.setattr("getpass.getuser", lambda: "testuser")
monkeypatch.setattr(gateway, "Path", lambda _path: SimpleNamespace(exists=lambda: False))
monkeypatch.setattr(gateway, "get_systemd_linger_status", lambda: (False, ""))
diff --git a/tests/hermes_cli/test_gateway_service.py b/tests/hermes_cli/test_gateway_service.py
index b346425847..6c707f325b 100644
--- a/tests/hermes_cli/test_gateway_service.py
+++ b/tests/hermes_cli/test_gateway_service.py
@@ -359,7 +359,6 @@ class TestGatewayStopCleanup:
unit_path.write_text("unit\n", encoding="utf-8")
monkeypatch.setattr(gateway_cli, "supports_systemd_services", lambda: True)
- monkeypatch.setattr(gateway_cli, "is_termux", lambda: False)
monkeypatch.setattr(gateway_cli, "get_systemd_unit_path", lambda system=False: unit_path)
service_calls = []
@@ -757,14 +756,12 @@ class TestLaunchdDomainDetection:
class TestGatewayServiceDetection:
def test_supports_systemd_services_requires_systemctl_binary(self, monkeypatch):
monkeypatch.setattr(gateway_cli, "is_linux", lambda: True)
- monkeypatch.setattr(gateway_cli, "is_termux", lambda: False)
monkeypatch.setattr(gateway_cli.shutil, "which", lambda name: None)
assert gateway_cli.supports_systemd_services() is False
def test_supports_systemd_services_returns_true_when_systemctl_present(self, monkeypatch):
monkeypatch.setattr(gateway_cli, "is_linux", lambda: True)
- monkeypatch.setattr(gateway_cli, "is_termux", lambda: False)
monkeypatch.setattr(gateway_cli, "is_wsl", lambda: False)
monkeypatch.setattr(gateway_cli.shutil, "which", lambda name: "/usr/bin/systemctl")
@@ -775,7 +772,6 @@ class TestGatewayServiceDetection:
system_unit = SimpleNamespace(exists=lambda: True)
monkeypatch.setattr(gateway_cli, "supports_systemd_services", lambda: True)
- monkeypatch.setattr(gateway_cli, "is_termux", lambda: False)
monkeypatch.setattr(gateway_cli, "is_macos", lambda: False)
monkeypatch.setattr(
gateway_cli,
@@ -1190,14 +1186,46 @@ class TestDetectVenvDir:
assert result is None
+def _seed_pm_node_facts(hermes_root):
+ """Write a pm installed-state file recording node/npm store entries.
+
+ _append_node_dir_for_service() resolves the managed Node through the pm
+ store (facts.json) rather than a fixed ``node/`` tree, so tests seed the
+ state the way a real install records it.
+ """
+ store_root = hermes_root / "tools"
+ node_dir = store_root / "node-v22.0.0"
+ npm_dir = store_root / "npm-9.0.0" / "bin"
+ node_dir.mkdir(parents=True)
+ npm_dir.mkdir(parents=True)
+ facts = {
+ "schema": 1,
+ "packages": {
+ "node": {
+ "entry": "node-v22.0.0",
+ "version": "22.0.0",
+ "env": {"PATH": ["{{store}}/node-v22.0.0"]},
+ },
+ "npm": {
+ "entry": "npm-9.0.0",
+ "version": "9.0.0",
+ "env": {"PATH": ["{{store}}/npm-9.0.0/bin"]},
+ },
+ },
+ }
+ import json as _json
+
+ (store_root / "facts.json").write_text(_json.dumps(facts), encoding="utf-8")
+ return [str(npm_dir), str(node_dir)]
+
+
class TestSystemUnitHermesHome:
"""HERMES_HOME in system units must reference the target user, not root."""
- def test_empty_managed_node_dir_uses_only_ambient_fallback(
+ def test_no_pm_node_facts_uses_only_ambient_fallback(
self, monkeypatch, tmp_path
):
- managed_bin = tmp_path / ".hermes" / "node" / "bin"
- managed_bin.mkdir(parents=True)
+ (tmp_path / ".hermes" / "tools").mkdir(parents=True)
monkeypatch.setattr(
gateway_cli.shutil, "which", lambda name: "/opt/external-node/bin/node"
)
@@ -1207,20 +1235,21 @@ class TestSystemUnitHermesHome:
assert entries == ["/opt/external-node/bin"]
- def test_non_executable_managed_node_uses_only_ambient_fallback(
+ def test_stale_pm_facts_without_dirs_use_only_ambient_fallback(
self, monkeypatch, tmp_path
):
- managed_bin = tmp_path / ".hermes" / "node" / "bin"
- managed_bin.mkdir(parents=True)
- node = managed_bin / "node"
- node.write_text("#!/bin/sh\n")
- node.chmod(0o644)
+ """Recorded entries whose store dirs are gone contribute nothing."""
+ import shutil as _shutil
+
+ hermes_root = tmp_path / ".hermes"
+ for entry in _seed_pm_node_facts(hermes_root):
+ _shutil.rmtree(entry)
monkeypatch.setattr(
gateway_cli.shutil, "which", lambda name: "/opt/external-node/bin/node"
)
entries: list[str] = []
- gateway_cli._append_node_dir_for_service(entries, tmp_path / ".hermes")
+ gateway_cli._append_node_dir_for_service(entries, hermes_root)
assert entries == ["/opt/external-node/bin"]
@@ -1232,11 +1261,7 @@ class TestSystemUnitHermesHome:
target_hermes = target_home / ".hermes"
root_home = tmp_path / "root"
root_hermes = root_home / ".hermes"
- managed_bin = target_hermes / "node" / "bin"
- managed_bin.mkdir(parents=True)
- node = managed_bin / "node"
- node.write_text("#!/bin/sh\n")
- node.chmod(0o755)
+ managed_dirs = _seed_pm_node_facts(target_hermes)
root_hermes.mkdir(parents=True)
monkeypatch.setattr(Path, "home", staticmethod(lambda: root_home))
@@ -1256,20 +1281,14 @@ class TestSystemUnitHermesHome:
user_unit = gateway_cli.generate_systemd_unit(system=True, run_as_user="alice")
assert root_unit == user_unit
- assert str(managed_bin) in root_unit
+ for managed_dir in managed_dirs:
+ assert managed_dir in root_unit
assert "/root/bin" not in root_unit
def test_node_path_lookup_remains_fallback_without_managed_node(
self, monkeypatch, tmp_path
):
- """External Node installs still work when the managed tree is absent."""
- monkeypatch.setattr(
- "hermes_constants.iter_hermes_node_dirs", lambda root=None: []
- )
- monkeypatch.setattr(
- "hermes_constants.hermes_managed_node_tree_present",
- lambda root=None: False,
- )
+ """External Node installs still work when pm has no node installed."""
monkeypatch.setattr(
gateway_cli.shutil, "which", lambda name: "/opt/external-node/bin/node"
)
@@ -1790,7 +1809,6 @@ class TestDockerAwareGateway:
import pytest
monkeypatch.setattr(gateway_cli, "is_managed", lambda: False)
- monkeypatch.setattr(gateway_cli, "is_termux", lambda: False)
monkeypatch.setattr(gateway_cli, "supports_systemd_services", lambda: False)
monkeypatch.setattr(gateway_cli, "is_macos", lambda: False)
monkeypatch.setattr(gateway_cli, "is_wsl", lambda: False)
@@ -2292,7 +2310,6 @@ class TestGatewayCommandCatchesSystemScopeError:
)
monkeypatch.setattr(gateway_cli.os, "geteuid", lambda: 1000)
monkeypatch.setattr(gateway_cli, "supports_systemd_services", lambda: True)
- monkeypatch.setattr(gateway_cli, "is_termux", lambda: False)
monkeypatch.setattr(gateway_cli, "kill_gateway_processes", lambda **kw: 0)
args = SimpleNamespace(gateway_command="start", system=True, all=False)
diff --git a/tests/hermes_cli/test_gateway_wsl.py b/tests/hermes_cli/test_gateway_wsl.py
index 6e7ff37932..0d00ee36c9 100644
--- a/tests/hermes_cli/test_gateway_wsl.py
+++ b/tests/hermes_cli/test_gateway_wsl.py
@@ -66,7 +66,6 @@ class TestSupportsSystemdServicesWSL:
Linux-gated: ``supports_systemd_services()`` short-circuits on
``is_linux()``, so off Linux this asserted nothing about systemd.
"""
- monkeypatch.setattr(gateway, "is_termux", lambda: False)
monkeypatch.setattr(
gateway.shutil, "which", lambda _name: "/usr/bin/systemctl"
)
@@ -74,20 +73,6 @@ class TestSupportsSystemdServicesWSL:
monkeypatch.setattr(gateway, "_wsl_systemd_operational", lambda: True)
assert gateway.supports_systemd_services() is True
- @pytest.mark.linux_only
- def test_termux_still_excluded(self, monkeypatch):
- """Termux → False regardless of WSL status.
-
- Linux-gated: off Linux the ``not is_linux()`` arm returns False first,
- so the Termux exclusion itself would never be exercised.
- """
- monkeypatch.setattr(gateway, "is_termux", lambda: True)
- assert gateway.supports_systemd_services() is False
-
-
-# =============================================================================
-# WSL messaging in gateway commands
-# =============================================================================
class TestGatewayCommandWSLMessages:
"""Test that WSL users see appropriate guidance."""
@@ -102,7 +87,6 @@ class TestGatewayCommandWSLMessages:
real Windows host the unstubbed version would have run
``gateway_windows.install()`` against the user's real Startup folder.
"""
- monkeypatch.setattr(gateway, "is_termux", lambda: False)
monkeypatch.setattr(gateway, "is_wsl", lambda: True)
monkeypatch.setattr(gateway, "supports_systemd_services", lambda: False)
monkeypatch.setattr(gateway, "is_managed", lambda: False)
@@ -130,7 +114,6 @@ class TestGatewayCommandWSLMessages:
printed only after the macOS/Windows service branches decline.
"""
monkeypatch.setattr(gateway, "supports_systemd_services", lambda: False)
- monkeypatch.setattr(gateway, "is_termux", lambda: False)
monkeypatch.setattr(gateway, "is_wsl", lambda: True)
monkeypatch.setattr(gateway, "find_gateway_pids", lambda: [12345])
monkeypatch.setattr(gateway, "_runtime_health_lines", lambda: [])
diff --git a/tests/hermes_cli/test_nous_subscription.py b/tests/hermes_cli/test_nous_subscription.py
index c9ffaa931a..0609558c6c 100644
--- a/tests/hermes_cli/test_nous_subscription.py
+++ b/tests/hermes_cli/test_nous_subscription.py
@@ -461,34 +461,12 @@ def test_has_agent_browser_true_for_npx_only_resolution(monkeypatch):
return "npx agent-browser"
monkeypatch.setattr(browser_tool, "_find_agent_browser", fake_find_agent_browser)
- monkeypatch.setattr(
- browser_tool, "_requires_real_termux_browser_install", lambda cmd: False
- )
assert ns._has_agent_browser() is True
# A readiness probe must resolve without spawning the daemon.
assert calls and all(call["validate"] is False for call in calls)
-def test_has_agent_browser_false_for_termux_local_bare_npx(monkeypatch):
- """On Termux in local mode the bare npx fallback is not a usable install."""
- _block_legacy_agent_browser_checks(monkeypatch)
- import tools.browser_tool as browser_tool
-
- monkeypatch.setattr(
- browser_tool,
- "_find_agent_browser",
- lambda *, validate=True: "npx agent-browser",
- )
- monkeypatch.setattr(
- browser_tool,
- "_requires_real_termux_browser_install",
- lambda cmd: cmd.strip() == "npx agent-browser",
- )
-
- assert ns._has_agent_browser() is False
-
-
def test_has_agent_browser_false_when_nothing_resolvable(monkeypatch):
_block_legacy_agent_browser_checks(monkeypatch)
import tools.browser_tool as browser_tool
diff --git a/tests/hermes_cli/test_pip_install_detection.py b/tests/hermes_cli/test_pip_install_detection.py
index bc3f3d9dae..2e982efd3c 100644
--- a/tests/hermes_cli/test_pip_install_detection.py
+++ b/tests/hermes_cli/test_pip_install_detection.py
@@ -34,6 +34,18 @@ def test_code_scoped_stamp_wins_over_home_stamp(tmp_path):
+def test_legacy_apt_stamp_resolves_to_unknown(tmp_path):
+ """The removed Termux 'apt' lane: a legacy stamp falls through to 'unknown'.
+
+ 'unknown' routes the user to the generic "hermes update" path — the same
+ treatment as any other unidentifiable install — instead of refusing.
+ """
+ (tmp_path / ".install_method").write_text("apt\n", encoding="utf-8")
+ with patch("hermes_cli.config.get_managed_system", return_value=None):
+ from hermes_cli.config import detect_install_method
+ assert detect_install_method(project_root=tmp_path) == "unknown"
+
+
def test_stamp_install_method_writes_code_scoped(tmp_path):
"""stamp_install_method writes next to the code, not into $HERMES_HOME."""
code = tmp_path / "code"
diff --git a/tests/hermes_cli/test_safe_mode.py b/tests/hermes_cli/test_safe_mode.py
index 07affbd43c..ec7d3e2242 100644
--- a/tests/hermes_cli/test_safe_mode.py
+++ b/tests/hermes_cli/test_safe_mode.py
@@ -43,7 +43,6 @@ def test_cmd_chat_safe_mode_sets_env_before_startup(monkeypatch):
monkeypatch.setattr(main_mod, "_has_any_provider_configured", fake_has_provider)
monkeypatch.setattr(main_mod, "_pin_kanban_board_env", lambda: None)
monkeypatch.setattr(main_mod, "_sync_bundled_skills_for_startup", lambda: None)
- monkeypatch.setattr(main_mod, "_termux_should_prefetch_update_check", lambda: False)
setattr(fake_cli, "main", fake_main)
monkeypatch.setitem(sys.modules, "cli", fake_cli)
diff --git a/tests/hermes_cli/test_setup_hermes_script.py b/tests/hermes_cli/test_setup_hermes_script.py
index a4eb5ccb7d..d2e630f790 100644
--- a/tests/hermes_cli/test_setup_hermes_script.py
+++ b/tests/hermes_cli/test_setup_hermes_script.py
@@ -9,12 +9,3 @@ SETUP_SCRIPT = REPO_ROOT / "setup-hermes.sh"
def test_setup_hermes_script_is_valid_shell():
result = subprocess.run(["bash", "-n", str(SETUP_SCRIPT)], capture_output=True, text=True)
assert result.returncode == 0, result.stderr
-
-
-def test_setup_hermes_script_has_termux_path():
- content = SETUP_SCRIPT.read_text(encoding="utf-8")
-
- assert "is_termux()" in content
- assert ".[termux]" in content
- assert "constraints-termux.txt" in content
- assert "$PREFIX/bin" in content
diff --git a/tests/hermes_cli/test_startup_fast_guards.py b/tests/hermes_cli/test_startup_fast_guards.py
index 4628b8fabd..09eb9624fe 100644
--- a/tests/hermes_cli/test_startup_fast_guards.py
+++ b/tests/hermes_cli/test_startup_fast_guards.py
@@ -9,11 +9,11 @@ Two invariants, each of which has been broken before:
still works, just 40x slower).
2. OUTPUT PARITY / LIVENESS: the fast path must actually produce version
- output and exit 0 in a real subprocess, on and off Termux. This is the
- test that would have caught eb4040242, which changed the canonical
- version output to reference the PROJECT_ROOT module constant inside the
- fast function — a name that doesn't exist yet at the fast exit point —
- NameError-ing the Termux fast path in production for weeks.
+ output and exit 0 in a real subprocess. This is the test that would have
+ caught eb4040242, which changed the canonical version output to reference
+ the PROJECT_ROOT module constant inside the fast function — a name that
+ doesn't exist yet at the fast exit point — NameError-ing the fast path in
+ production for weeks.
"""
import json
@@ -75,25 +75,14 @@ def _run_version(env_overrides: dict) -> subprocess.CompletedProcess:
)
-def test_fast_version_parity_off_termux(tmp_path):
+def test_fast_version_parity(tmp_path):
home = tmp_path / ".hermes"
home.mkdir()
- result = _run_version({"HERMES_HOME": str(home), "TERMUX_VERSION": ""})
+ result = _run_version({"HERMES_HOME": str(home)})
assert result.returncode == 0, result.stderr
out = result.stdout
for field in ("Hermes Agent v", "Install directory:", "Python:", "OpenAI SDK:"):
assert field in out, f"fast --version output missing {field!r}:\n{out}"
-
-
-def test_fast_version_parity_on_termux(tmp_path):
- """The historical Termux path — the one eb4040242 broke."""
- home = tmp_path / ".hermes"
- home.mkdir()
- result = _run_version(
- {"HERMES_HOME": str(home), "TERMUX_VERSION": "0.118"}
- )
- assert result.returncode == 0, result.stderr
- assert "Hermes Agent v" in result.stdout
assert "Traceback" not in result.stderr
@@ -101,6 +90,6 @@ def test_fast_version_reports_install_method_stamp(tmp_path):
home = tmp_path / ".hermes"
home.mkdir()
(home / ".install_method").write_text("git\n", encoding="utf-8")
- result = _run_version({"HERMES_HOME": str(home), "TERMUX_VERSION": ""})
+ result = _run_version({"HERMES_HOME": str(home)})
assert result.returncode == 0, result.stderr
assert "Install method: git" in result.stdout
diff --git a/tests/hermes_cli/test_status.py b/tests/hermes_cli/test_status.py
index 750ff60021..21125184f6 100644
--- a/tests/hermes_cli/test_status.py
+++ b/tests/hermes_cli/test_status.py
@@ -15,35 +15,6 @@ def test_show_status_all_does_not_print_tavily_key_value(monkeypatch, capsys, tm
assert sentinel not in output
-def test_show_status_termux_gateway_section_skips_systemctl(monkeypatch, capsys, tmp_path):
- from hermes_cli import status as status_mod
- import hermes_cli.auth as auth_mod
- import hermes_cli.gateway as gateway_mod
-
- monkeypatch.setenv("TERMUX_VERSION", "0.118.3")
- monkeypatch.setenv("PREFIX", "/data/data/com.termux/files/usr")
- monkeypatch.setattr(status_mod, "get_env_path", lambda: tmp_path / ".env", raising=False)
- monkeypatch.setattr(status_mod, "get_hermes_home", lambda: tmp_path, raising=False)
- monkeypatch.setattr(status_mod, "load_config", lambda: {"model": "gpt-5.4"}, raising=False)
- monkeypatch.setattr(status_mod, "resolve_requested_provider", lambda requested=None: "openai-codex", raising=False)
- monkeypatch.setattr(status_mod, "resolve_provider", lambda requested=None, **kwargs: "openai-codex", raising=False)
- monkeypatch.setattr(status_mod, "provider_label", lambda provider: "OpenAI Codex", raising=False)
- monkeypatch.setattr(auth_mod, "get_nous_auth_status_local", lambda: {}, raising=False)
- monkeypatch.setattr(auth_mod, "get_codex_auth_status", lambda: {}, raising=False)
- monkeypatch.setattr(auth_mod, "get_xai_oauth_auth_status", lambda: {}, raising=False)
- monkeypatch.setattr(gateway_mod, "find_gateway_pids", lambda exclude_pids=None: [], raising=False)
-
- def _unexpected_systemctl(*args, **kwargs):
- raise AssertionError("systemctl should not be called in the Termux status view")
-
- monkeypatch.setattr(status_mod.subprocess, "run", _unexpected_systemctl)
-
- status_mod.show_status(SimpleNamespace(all=False, deep=False))
-
- output = capsys.readouterr().out
- assert "Manager: Termux / manual process" in output
- assert "Start with: hermes gateway" in output
- assert "systemd (user)" not in output
def test_show_status_reports_vercel_backend_contract(monkeypatch, capsys, tmp_path):
from hermes_cli import status as status_mod
import hermes_cli.auth as auth_mod
diff --git a/tests/hermes_cli/test_tui_npm_install.py b/tests/hermes_cli/test_tui_npm_install.py
index 49a3346722..484c138ee0 100644
--- a/tests/hermes_cli/test_tui_npm_install.py
+++ b/tests/hermes_cli/test_tui_npm_install.py
@@ -226,9 +226,9 @@ def test_workspace_closure_returns_none_when_start_absent(main_mod) -> None:
def test_workspace_closure_includes_dev_deps_of_selected_child_workspace(main_mod) -> None:
- """On Termux the install also scopes to ui-tui's child packages/* workspaces,
- so each selected child's devDependencies join the closure — a dev dep unique
- to a child is NOT dropped (regression for the child-scope false-negative)."""
+ """The closure includes each explicitly-selected workspace's devDependencies,
+ so a dev dep unique to a selected child is NOT dropped (regression for the
+ child-scope false-negative)."""
packages = {
"ui-tui": {"dependencies": {"@hermes/ink": "*"}},
"node_modules/@hermes/ink": {
@@ -238,47 +238,14 @@ def test_workspace_closure_includes_dev_deps_of_selected_child_workspace(main_mo
"ui-tui/packages/hermes-ink": {"devDependencies": {"child-dev-only": "1"}},
"node_modules/child-dev-only": {},
}
- # Only ui-tui selected (desktop): the child's dev dep is not installed.
+ # Only ui-tui selected: the child's dev dep is not installed.
desktop = main_mod._npm_lock_workspace_closure(packages, {"ui-tui"})
assert "node_modules/child-dev-only" not in desktop
- # ui-tui + child selected (Termux): the child's dev dep is in the closure.
- termux = main_mod._npm_lock_workspace_closure(
+ # ui-tui + child selected: the child's dev dep is in the closure.
+ with_child = main_mod._npm_lock_workspace_closure(
packages, {"ui-tui", "ui-tui/packages/hermes-ink"}
)
- assert "node_modules/child-dev-only" in termux
-
-
-def test_termux_install_catches_missing_child_workspace_dev_dep(
- tmp_path: Path, main_mod, monkeypatch
-) -> None:
- """On Termux the launch install selects ui-tui/packages/* too, installing
- each child's devDependencies. A child dev dep missing from the hidden lock
- must trigger a reinstall — off Termux (child not selected) it must not."""
- ws_lock = (
- '{"packages":{'
- '"ui-tui":{"dependencies":{"@hermes/ink":"*"}},'
- '"node_modules/@hermes/ink":{"link":true,"resolved":"ui-tui/packages/hermes-ink"},'
- '"ui-tui/packages/hermes-ink":{"devDependencies":{"child-dev-only":"1.0.0"}},'
- '"node_modules/child-dev-only":{"version":"1.0.0"}'
- "}}"
- )
- hidden_lock = (
- '{"packages":{'
- '"ui-tui":{"dependencies":{"@hermes/ink":"*"}},'
- '"node_modules/@hermes/ink":{"link":true,"resolved":"ui-tui/packages/hermes-ink"},'
- '"ui-tui/packages/hermes-ink":{"devDependencies":{"child-dev-only":"1.0.0"}}'
- "}}"
- )
- tui_dir = _write_ws(tmp_path, ws_lock, hidden_lock)
- child = tui_dir / "packages" / "hermes-ink"
- child.mkdir(parents=True, exist_ok=True)
- (child / "package.json").write_text('{"name":"@hermes/ink"}')
-
- monkeypatch.setattr(main_mod, "_is_termux_startup_environment", lambda: False)
- assert main_mod._tui_need_npm_install(tui_dir) is False
-
- monkeypatch.setattr(main_mod, "_is_termux_startup_environment", lambda: True)
- assert main_mod._tui_need_npm_install(tui_dir) is True
+ assert "node_modules/child-dev-only" in with_child
def test_no_install_prebuilt_bundle_mode(tmp_path: Path, main_mod) -> None:
@@ -287,115 +254,6 @@ def test_no_install_prebuilt_bundle_mode(tmp_path: Path, main_mod) -> None:
assert main_mod._tui_need_npm_install(tmp_path) is False
-def test_need_rebuild_when_tui_bundle_missing(tmp_path: Path, main_mod) -> None:
- (tmp_path / "src").mkdir()
- (tmp_path / "src" / "entry.tsx").write_text("console.log('src')")
-
- assert main_mod._tui_need_rebuild(tmp_path) is True
-
-
-def test_no_rebuild_when_tui_bundle_newer_than_inputs(tmp_path: Path, main_mod) -> None:
- _touch_tui_entry(tmp_path)
- src = tmp_path / "src"
- src.mkdir()
- (src / "entry.tsx").write_text("console.log('src')")
- os.utime(src / "entry.tsx", (100, 100))
- os.utime(tmp_path / "dist" / "entry.js", (200, 200))
-
- assert main_mod._tui_need_rebuild(tmp_path) is False
-
-
-def test_rebuild_when_tui_source_newer_than_bundle(tmp_path: Path, main_mod) -> None:
- _touch_tui_entry(tmp_path)
- src = tmp_path / "src"
- src.mkdir()
- (src / "entry.tsx").write_text("console.log('src')")
- os.utime(tmp_path / "dist" / "entry.js", (100, 100))
- os.utime(src / "entry.tsx", (200, 200))
-
- assert main_mod._tui_need_rebuild(tmp_path) is True
-
-
-def test_make_tui_argv_skips_build_only_on_termux_when_fresh(
- tmp_path: Path, main_mod, monkeypatch
-) -> None:
- _touch_tui_entry(tmp_path)
- monkeypatch.setenv("TERMUX_VERSION", "1")
- monkeypatch.setattr(main_mod, "_tui_need_npm_install", lambda _root: False)
- monkeypatch.setattr(main_mod, "_tui_need_rebuild", lambda _root: False)
- monkeypatch.setattr(main_mod.shutil, "which", lambda name: f"/bin/{name}")
-
- def fail_run(*_args, **_kwargs):
- raise AssertionError("fresh Termux TUI launch must not rebuild")
-
- monkeypatch.setattr(main_mod.subprocess, "run", fail_run)
-
- argv, cwd = main_mod._make_tui_argv(tmp_path, tui_dev=False)
-
- assert argv == ["/bin/node", "--expose-gc", str(tmp_path / "dist" / "entry.js")]
- assert cwd == tmp_path
-
-
-def test_make_tui_argv_skips_install_on_termux_when_bundle_fresh(
- tmp_path: Path, main_mod, monkeypatch
-) -> None:
- _touch_tui_entry(tmp_path)
- monkeypatch.setenv("TERMUX_VERSION", "1")
- monkeypatch.setattr(main_mod, "_tui_need_npm_install", lambda _root: True)
- monkeypatch.setattr(main_mod, "_tui_need_rebuild", lambda _root: False)
- monkeypatch.setattr(main_mod.shutil, "which", lambda name: f"/bin/{name}")
-
- def fail_run(*_args, **_kwargs):
- raise AssertionError("fresh Termux TUI launch must not run npm")
-
- monkeypatch.setattr(main_mod.subprocess, "run", fail_run)
-
- argv, cwd = main_mod._make_tui_argv(tmp_path, tui_dev=False)
-
- assert argv == ["/bin/node", "--expose-gc", str(tmp_path / "dist" / "entry.js")]
- assert cwd == tmp_path
-
-
-def test_make_tui_argv_scopes_npm_install_on_termux_workspace(
- tmp_path: Path, main_mod, monkeypatch
-) -> None:
- tui_dir = tmp_path / "ui-tui"
- tui_dir.mkdir()
- (tui_dir / "package.json").write_text("{}")
- ink_dir = tui_dir / "packages" / "hermes-ink"
- ink_dir.mkdir(parents=True)
- (ink_dir / "package.json").write_text("{}")
- (tmp_path / "package-lock.json").write_text("{}")
-
- monkeypatch.setenv("TERMUX_VERSION", "1")
- monkeypatch.setattr(main_mod, "_tui_need_npm_install", lambda _root: True)
- monkeypatch.setattr(main_mod, "_tui_need_rebuild", lambda _root: True)
- monkeypatch.setattr(main_mod.shutil, "which", lambda name: f"/bin/{name}")
- calls = []
-
- def fake_run(*args, **kwargs):
- calls.append((args, kwargs))
- return types.SimpleNamespace(returncode=0, stdout="", stderr="")
-
- monkeypatch.setattr(main_mod.subprocess, "run", fake_run)
-
- main_mod._make_tui_argv(tui_dir, tui_dev=False)
-
- install_cmd = calls[0][0][0]
- assert install_cmd[:7] == [
- "/bin/npm",
- "install",
- "--workspace",
- "ui-tui",
- "--workspace",
- "ui-tui/packages/hermes-ink",
- "--include-workspace-root=false",
- ]
- assert calls[0][1]["cwd"] == str(tmp_path)
- _assert_utf8_replace_capture(calls[0][1])
- _assert_utf8_replace_capture(calls[1][1])
-
-
def test_make_tui_argv_keeps_desktop_workspace_install_behaviour(
tmp_path: Path, main_mod, monkeypatch
) -> None:
@@ -404,7 +262,6 @@ def test_make_tui_argv_keeps_desktop_workspace_install_behaviour(
(tui_dir / "package.json").write_text("{}")
(tmp_path / "package-lock.json").write_text("{}")
- monkeypatch.delenv("TERMUX_VERSION", raising=False)
monkeypatch.setenv("PREFIX", "/usr")
monkeypatch.setattr(main_mod, "_tui_need_npm_install", lambda _root: True)
monkeypatch.setattr(main_mod.shutil, "which", lambda name: f"/bin/{name}")
@@ -447,7 +304,6 @@ def test_make_tui_argv_npm_install_forces_include_dev(
(tui_dir / "package.json").write_text("{}")
(tmp_path / "package-lock.json").write_text("{}")
- monkeypatch.delenv("TERMUX_VERSION", raising=False)
monkeypatch.setenv("PREFIX", "/usr")
monkeypatch.setenv("NODE_ENV", "production")
monkeypatch.setattr(main_mod, "_tui_need_npm_install", lambda _root: True)
@@ -471,10 +327,8 @@ def test_make_tui_argv_keeps_desktop_always_build_behaviour(
tmp_path: Path, main_mod, monkeypatch
) -> None:
_touch_tui_entry(tmp_path)
- monkeypatch.delenv("TERMUX_VERSION", raising=False)
monkeypatch.setenv("PREFIX", "/usr")
monkeypatch.setattr(main_mod, "_tui_need_npm_install", lambda _root: False)
- monkeypatch.setattr(main_mod, "_tui_need_rebuild", lambda _root: False)
monkeypatch.setattr(main_mod.shutil, "which", lambda name: f"/bin/{name}")
calls = []
@@ -750,7 +604,6 @@ def test_make_tui_argv_omits_workspace_and_scrubs_esbuild_override(
# Parent also has lockfile (but _workspace_root prefers tui_dir's own)
(tmp_path / "package-lock.json").write_text("{}")
- monkeypatch.delenv("TERMUX_VERSION", raising=False)
monkeypatch.setenv("PREFIX", "/usr")
monkeypatch.setenv("ESBUILD_BINARY_PATH", "/opt/esbuild-0.28.2")
monkeypatch.setattr(main_mod, "_tui_need_npm_install", lambda _root: True)
diff --git a/tests/hermes_cli/test_tui_resume_flow.py b/tests/hermes_cli/test_tui_resume_flow.py
index 33ff8f8700..7909393744 100644
--- a/tests/hermes_cli/test_tui_resume_flow.py
+++ b/tests/hermes_cli/test_tui_resume_flow.py
@@ -51,23 +51,6 @@ def main_mod(monkeypatch):
-def test_termux_skips_bundled_skill_sync_when_stamp_fresh(monkeypatch, tmp_path, main_mod):
- calls = []
-
- monkeypatch.setenv("TERMUX_VERSION", "1")
- monkeypatch.setattr(main_mod, "get_hermes_home", lambda: tmp_path)
- monkeypatch.setattr(main_mod, "_termux_bundled_skills_fingerprint", lambda: "fp1")
- main_mod._mark_termux_bundled_skills_synced()
- monkeypatch.setitem(
- sys.modules,
- "tools.skills_sync",
- types.SimpleNamespace(sync_skills=lambda quiet: calls.append(quiet)),
- )
-
- assert main_mod._sync_bundled_skills_for_startup() is False
- assert calls == []
-
-
diff --git a/tests/hermes_cli/test_update_contract.py b/tests/hermes_cli/test_update_contract.py
index 9f6adce2b5..8e6a328b08 100644
--- a/tests/hermes_cli/test_update_contract.py
+++ b/tests/hermes_cli/test_update_contract.py
@@ -135,17 +135,16 @@ def test_admission_git_checkout_no_marker_is_admitted(tmp_path, monkeypatch):
assert evaluate_update_admission(tmp_path) is None
-def test_admission_apt_and_nix_refuse(tmp_path, monkeypatch):
+def test_admission_nix_refuses(tmp_path, monkeypatch):
import hermes_cli.image_provenance as ip
monkeypatch.setattr(ip, "IMAGE_PROVENANCE_PATH", tmp_path / "absent.json")
- for method, code in (("apt", "apt"), ("nix", "nix")):
- def _detect(*a, _m=method, **k):
- return _m
+ def _detect(*a, **k):
+ return "nix"
- monkeypatch.setattr("hermes_cli.config.detect_install_method", _detect)
- refusal = evaluate_update_admission(tmp_path)
- assert refusal is not None and refusal.code == code
+ monkeypatch.setattr("hermes_cli.config.detect_install_method", _detect)
+ refusal = evaluate_update_admission(tmp_path)
+ assert refusal is not None and refusal.code == "nix"
# ---------------------------------------------------------------------------
diff --git a/tests/hermes_cli/test_update_interrupted_recovery.py b/tests/hermes_cli/test_update_interrupted_recovery.py
index aab18efacc..a5392f656e 100644
--- a/tests/hermes_cli/test_update_interrupted_recovery.py
+++ b/tests/hermes_cli/test_update_interrupted_recovery.py
@@ -39,8 +39,7 @@ def _stub_install_env(monkeypatch, m, seen):
returncode = 0
monkeypatch.setattr(m.subprocess, "run", lambda *a, **k: R())
- monkeypatch.setattr(m, "_is_termux_env", lambda *a, **k: False)
- monkeypatch.setattr("hermes_cli.managed_uv.ensure_uv", lambda: None)
+ monkeypatch.setattr("pm.uv", lambda **kw: (None, {}))
# The install executor moved to hermes_cli._install_repair (shared between
# the pre-import early pass and this late recovery path) — stub WHERE it
# is executed, not the legacy main.py wrapper it replaced.
@@ -54,10 +53,8 @@ def _stub_install_env(monkeypatch, m, seen):
def test_recovery_self_lock_does_not_clear_core_marker_via_import_probes(
tmp_path, monkeypatch
):
- # ``.update-incomplete`` is the generic core-install marker. Healthy
- # lazy-refresh import probes alone must NOT clear it and skip full
- # reinstall — a missing dep outside the 7-probe set would look healthy
- # (#58004 review blocker).
+ # ``.update-incomplete`` is the generic core-install marker: recovery
+ # must run the full reinstall (#58004 review blocker).
monkeypatch.setattr(m, "PROJECT_ROOT", tmp_path)
(tmp_path / "pyproject.toml").write_text("[project]\nname='x'\n")
m._write_update_incomplete_marker()
@@ -70,14 +67,6 @@ def test_recovery_self_lock_does_not_clear_core_marker_via_import_probes(
monkeypatch.setattr(m, "_is_windows", lambda: True)
monkeypatch.setattr(m, "_venv_scripts_dir", lambda: scripts_dir)
monkeypatch.setattr(m, "_hermes_exe_shims", lambda d: [shim])
- monkeypatch.setattr(
- m,
- "_default_venv_install_target",
- lambda: (["uv", "pip"], {"VIRTUAL_ENV": str(tmp_path / "venv")}),
- )
- monkeypatch.setattr(
- m, "_repair_venv_via_import_probes", lambda *a, **k: "healthy"
- )
class FakeProc:
def __init__(self, exe_path):
diff --git a/tests/hermes_cli/test_web_server.py b/tests/hermes_cli/test_web_server.py
index d6feee8ab6..d019ff262e 100644
--- a/tests/hermes_cli/test_web_server.py
+++ b/tests/hermes_cli/test_web_server.py
@@ -825,28 +825,24 @@ class TestWebServerEndpoints:
- def test_post_memory_provider_setup_routes_pip_through_lazy_deps(self, monkeypatch):
- """NS-605: dashboard pip installs must use the environment-aware
- lazy_deps pipeline (durable-target redirect on immutable hosted
- images), never a direct `pip install --python sys.executable`."""
+ def test_post_memory_provider_setup_routes_pip_through_pm(self, monkeypatch):
+ """NS-605 lineage: dashboard pip installs must route through pm
+ (venv sync of the owning extra), never a direct
+ `pip install --python sys.executable`."""
import subprocess as _subprocess
import hermes_cli.web_server as web_server
- from tools import lazy_deps as ld
+ import pm
- # honcho declares pip_dependencies: [honcho-ai]; force it missing.
- monkeypatch.setattr(web_server, "_dependency_importable", lambda dep: False)
+ # honcho declares extra: honcho; force it missing.
+ monkeypatch.setattr(web_server, "_extra_available", lambda extra: False)
installed = []
- def fake_install_specs(specs, *, timeout=300):
- installed.append(tuple(specs))
- return ld.InstallSpecsResult(
- ok=True, command="uv pip install --target /opt/data/lazy-packages honcho-ai",
- stdout="ok", stderr="",
- )
-
- monkeypatch.setattr(ld, "install_specs", fake_install_specs)
+ monkeypatch.setattr(
+ pm, "sync_venv",
+ lambda extras=None, explicit=False: installed.append(tuple(extras or ())),
+ )
# Any direct pip/uv subprocess from the memory-provider pip path is
# a regression; external-dep checks may still run subprocess, so only
@@ -866,8 +862,8 @@ class TestWebServerEndpoints:
data = resp.json()
pip_rows = [row for row in data["results"] if row["kind"] == "pip"]
assert pip_rows and pip_rows[0]["status"] == "installed"
- assert "--target /opt/data/lazy-packages" in pip_rows[0]["command"]
- assert installed == [("honcho-ai",)]
+ assert pip_rows[0]["command"] == "hermes pm install"
+ assert installed == [("honcho",)]
@@ -1202,45 +1198,22 @@ class TestWebServerEndpoints:
assert status_data["pid"] is None
assert any("docker pull nousresearch/hermes-agent:latest" in line for line in status_data["lines"])
- def test_update_hermes_returns_apt_guidance_without_spawning(self, monkeypatch):
+ def test_update_check_legacy_apt_stamp_resolves_to_unknown(self, monkeypatch):
+ # The Termux 'apt' install-method lane was removed. A legacy
+ # .install_method stamp of 'apt' now resolves to 'unknown' (see
+ # detect_install_method) and gets the generic "hermes update"
+ # guidance instead of Termux-specific refusal.
import hermes_cli.web_server as web_server
- spawned = False
-
- def fail_spawn(*_args, **_kwargs):
- nonlocal spawned
- spawned = True
- raise AssertionError("APT-managed update guard should not spawn hermes update")
-
monkeypatch.setattr(web_server, "_dashboard_local_update_managed_externally", lambda: False)
- # The shared admission gate (#91277 Phase 3) resolves the install
- # method through hermes_cli.config directly, so patch it there (the
- # web_server module alias only feeds the /update/check endpoint).
- monkeypatch.setattr(
- "hermes_cli.config.detect_install_method", lambda *_a, **_k: "apt"
- )
- monkeypatch.setattr(web_server, "detect_install_method", lambda _root: "apt")
- monkeypatch.setattr(web_server, "_spawn_hermes_action", fail_spawn)
- web_server._ACTION_PROCS.pop("hermes-update", None)
- web_server._ACTION_RESULTS.pop("hermes-update", None)
-
- resp = self.client.post("/api/hermes/update")
-
- assert resp.status_code == 200
- data = resp.json()
- assert data["ok"] is False
- assert data["pid"] is None
- assert data["error"] == "apt_update_required"
- assert data["update_command"] == "pkg upgrade hermes-agent"
- assert spawned is False
+ monkeypatch.setattr(web_server, "detect_install_method", lambda _root: "unknown")
check = self.client.get("/api/hermes/update/check")
assert check.status_code == 200
check_data = check.json()
- assert check_data["install_method"] == "apt"
- assert check_data["can_apply"] is False
- assert check_data["update_command"] == "pkg upgrade hermes-agent"
- assert "Termux APT" in check_data["message"]
+ assert check_data["install_method"] == "unknown"
+ assert check_data["update_command"] == "hermes update"
+ assert "Termux" not in (check_data["message"] or "")
def test_update_status_recovers_completed_result_after_dashboard_restart(self, monkeypatch, tmp_path):
import hermes_cli.web_server as web_server
diff --git a/tests/hermes_cli/test_web_ui_build.py b/tests/hermes_cli/test_web_ui_build.py
index e1b05d183b..9cc7506adc 100644
--- a/tests/hermes_cli/test_web_ui_build.py
+++ b/tests/hermes_cli/test_web_ui_build.py
@@ -139,7 +139,6 @@ class TestBuildWebUISkipsWhenFresh:
web_dir, _ = _make_web_dir(tmp_path)
(web_dir / "package-lock.json").write_text("{}", encoding="utf-8")
(tmp_path / "package-lock.json").write_text("{}", encoding="utf-8")
- monkeypatch.delenv("TERMUX_VERSION", raising=False)
monkeypatch.setenv("PREFIX", "/usr")
monkeypatch.setenv("ESBUILD_BINARY_PATH", "/opt/esbuild-0.28.2")
@@ -173,7 +172,6 @@ class TestBuildWebUISkipsWhenFresh:
(tmp_path / "package-lock.json").write_text("{}", encoding="utf-8")
(tmp_path / "ui-tui").mkdir()
(tmp_path / "ui-tui" / "package.json").write_text("{}", encoding="utf-8")
- monkeypatch.delenv("TERMUX_VERSION", raising=False)
monkeypatch.setenv("PREFIX", "/usr")
install_cp = __import__("subprocess").CompletedProcess([], 0, stdout="", stderr="")
@@ -196,7 +194,6 @@ class TestBuildWebUISkipsWhenFresh:
fails hard on a --workspace that doesn't exist."""
web_dir, _ = _make_web_dir(tmp_path)
(tmp_path / "package-lock.json").write_text("{}", encoding="utf-8")
- monkeypatch.delenv("TERMUX_VERSION", raising=False)
monkeypatch.setenv("PREFIX", "/usr")
install_cp = __import__("subprocess").CompletedProcess([], 0, stdout="", stderr="")
diff --git a/tests/tools/test_browser_chromium_check.py b/tests/tools/test_browser_chromium_check.py
index 11941890dd..03cc17a369 100644
--- a/tests/tools/test_browser_chromium_check.py
+++ b/tests/tools/test_browser_chromium_check.py
@@ -70,7 +70,6 @@ class TestCheckBrowserRequirementsChromium:
def test_local_mode_with_chromium_returns_true(self, monkeypatch, tmp_path):
monkeypatch.setattr(bt, "_is_camofox_mode", lambda: False)
monkeypatch.setattr(bt, "_find_agent_browser", lambda **_kw: "/usr/local/bin/agent-browser")
- monkeypatch.setattr(bt, "_requires_real_termux_browser_install", lambda _: False)
monkeypatch.setattr(bt, "_get_cloud_provider", lambda: None)
monkeypatch.setenv("PLAYWRIGHT_BROWSERS_PATH", str(tmp_path))
(tmp_path / "chromium-1208").mkdir()
diff --git a/tests/tools/test_browser_homebrew_paths.py b/tests/tools/test_browser_homebrew_paths.py
index 1467d2cbaa..dd60264b58 100644
--- a/tests/tools/test_browser_homebrew_paths.py
+++ b/tests/tools/test_browser_homebrew_paths.py
@@ -36,10 +36,6 @@ def _clear_browser_caches():
class TestSanePath:
"""Verify _SANE_PATH includes fallback directories used by browser_tool."""
- def test_includes_termux_bin(self):
- assert "/data/data/com.termux/files/usr/bin" in _SANE_PATH.split(os.pathsep)
-
-
def test_includes_standard_dirs(self):
path_parts = _SANE_PATH.split(os.pathsep)
assert "/usr/local/bin" in path_parts
@@ -271,29 +267,6 @@ class TestBrowserRequirements:
assert check_browser_requirements() is True
- def test_termux_requires_real_agent_browser_install_not_npx_fallback(self, monkeypatch):
- monkeypatch.setenv("TERMUX_VERSION", "0.118.3")
- monkeypatch.setenv("PREFIX", "/data/data/com.termux/files/usr")
- monkeypatch.setattr("tools.browser_tool._is_camofox_mode", lambda: False)
- monkeypatch.setattr("tools.browser_tool._get_cloud_provider", lambda: None)
- monkeypatch.setattr("tools.browser_tool._find_agent_browser", lambda **_kw: "npx agent-browser")
-
- assert check_browser_requirements() is False
-
-
-class TestRunBrowserCommandTermuxFallback:
- def test_termux_local_mode_rejects_bare_npx_fallback(self, monkeypatch):
- monkeypatch.setenv("TERMUX_VERSION", "0.118.3")
- monkeypatch.setenv("PREFIX", "/data/data/com.termux/files/usr")
- monkeypatch.setattr("tools.browser_tool._find_agent_browser", lambda **_kw: "npx agent-browser")
- monkeypatch.setattr("tools.browser_tool._get_cloud_provider", lambda: None)
-
- result = _run_browser_command("task-1", "navigate", ["https://example.com"])
-
- assert result["success"] is False
- assert "bare npx fallback" in result["error"]
- assert "agent-browser install" in result["error"]
-
class TestRunBrowserCommandPathConstruction:
"""Verify _run_browser_command() includes Homebrew node dirs in subprocess PATH."""
@@ -406,55 +379,6 @@ class TestRunBrowserCommandPathConstruction:
]
assert captured_cmd[5:9] == ["--session", "test-session", "--json", "navigate"]
- def test_subprocess_path_includes_termux_fallback_dirs(self, tmp_path):
- """Termux fallback dirs should survive browser PATH rebuilding."""
- captured_env = {}
-
- mock_proc = MagicMock()
- mock_proc.returncode = 0
- mock_proc.wait.return_value = 0
-
- def capture_popen(cmd, **kwargs):
- captured_env.update(kwargs.get("env", {}))
- return mock_proc
-
- fake_session = {
- "session_name": "test-session",
- "session_id": "test-id",
- "cdp_url": None,
- }
-
- fake_json = json.dumps({"success": True})
- real_isdir = os.path.isdir
-
- def selective_isdir(path):
- if path in {
- "/data/data/com.termux/files/usr/bin",
- "/data/data/com.termux/files/usr/sbin",
- }:
- return True
- if path.startswith(str(tmp_path)):
- return True
- return real_isdir(path)
-
- with patch("tools.browser_tool._find_agent_browser", return_value="/usr/local/bin/agent-browser"), \
- patch("tools.browser_tool._chromium_installed", return_value=True), \
- patch("tools.browser_tool._get_session_info", return_value=fake_session), \
- patch("tools.browser_tool._socket_safe_tmpdir", return_value=str(tmp_path)), \
- patch("tools.browser_tool._discover_homebrew_node_dirs", return_value=[]), \
- patch("os.path.isdir", side_effect=selective_isdir), \
- patch("subprocess.Popen", side_effect=capture_popen), \
- patch("os.open", return_value=99), \
- patch("os.close"), \
- patch("tools.interrupt.is_interrupted", return_value=False), \
- patch.dict(os.environ, {"PATH": "/usr/bin:/bin", "HOME": "/home/test"}, clear=True):
- with patch("builtins.open", mock_open(read_data=fake_json)):
- _run_browser_command("test-task", "navigate", ["https://example.com"])
-
- result_path = captured_env.get("PATH", "")
- assert "/data/data/com.termux/files/usr/bin" in result_path
- assert "/data/data/com.termux/files/usr/sbin" in result_path
-
class TestRunChromeFallbackCommandNpxResolution:
"""_run_chrome_fallback_command builds its own npx cmd prefix independently
diff --git a/tests/tools/test_browser_lightpanda.py b/tests/tools/test_browser_lightpanda.py
index b13c1da2bf..46c3943fb4 100644
--- a/tests/tools/test_browser_lightpanda.py
+++ b/tests/tools/test_browser_lightpanda.py
@@ -157,7 +157,6 @@ class TestLightpandaRequirements:
with patch("tools.browser_tool._is_camofox_mode", return_value=False), \
patch("tools.browser_tool._get_cdp_override", return_value=""), \
patch("tools.browser_tool._find_agent_browser", return_value="/usr/bin/agent-browser"), \
- patch("tools.browser_tool._requires_real_termux_browser_install", return_value=False), \
patch("tools.browser_tool._get_cloud_provider", return_value=None), \
patch("tools.browser_tool._get_browser_engine", return_value="lightpanda"), \
patch("tools.browser_tool._chromium_installed", return_value=False):
@@ -169,7 +168,6 @@ class TestLightpandaRequirements:
with patch("tools.browser_tool._is_camofox_mode", return_value=False), \
patch("tools.browser_tool._get_cdp_override", return_value=""), \
patch("tools.browser_tool._find_agent_browser", return_value="/usr/bin/agent-browser"), \
- patch("tools.browser_tool._requires_real_termux_browser_install", return_value=False), \
patch("tools.browser_tool._get_cloud_provider", return_value=None), \
patch("tools.browser_tool._get_browser_engine", return_value="auto"), \
patch("tools.browser_tool._chromium_installed", return_value=False):
diff --git a/tests/tools/test_browser_open_timeout.py b/tests/tools/test_browser_open_timeout.py
index 2263383ed7..288b2015db 100644
--- a/tests/tools/test_browser_open_timeout.py
+++ b/tests/tools/test_browser_open_timeout.py
@@ -107,7 +107,6 @@ class TestCommandTimeoutRecovery:
supervisor_events = []
monkeypatch.setattr(bt, "_find_agent_browser", lambda: "agent-browser")
- monkeypatch.setattr(bt, "_requires_real_termux_browser_install", lambda _cmd: False)
monkeypatch.setattr(bt, "_start_browser_cleanup_thread", lambda: None)
monkeypatch.setattr(bt, "_ensure_cdp_supervisor", lambda _: supervisor_events.append("ensure"))
monkeypatch.setattr(bt, "_stop_cdp_supervisor", lambda _: supervisor_events.append("stop"))
diff --git a/tests/tools/test_browser_orphan_reaper.py b/tests/tools/test_browser_orphan_reaper.py
index 33033c18a9..bee780b27f 100644
--- a/tests/tools/test_browser_orphan_reaper.py
+++ b/tests/tools/test_browser_orphan_reaper.py
@@ -195,9 +195,6 @@ class TestOwnerPidCrossProcess:
monkeypatch.setattr(bt.subprocess, "Popen", _FakePopen)
monkeypatch.setattr(bt, "_find_agent_browser", lambda: "/bin/true")
- monkeypatch.setattr(
- bt, "_requires_real_termux_browser_install", lambda *a: False
- )
monkeypatch.setattr(bt, "_chromium_installed", lambda: True)
monkeypatch.setattr(
bt, "_get_session_info",
diff --git a/tests/tools/test_browser_suspect_recycle.py b/tests/tools/test_browser_suspect_recycle.py
index 5661026595..37b0534751 100644
--- a/tests/tools/test_browser_suspect_recycle.py
+++ b/tests/tools/test_browser_suspect_recycle.py
@@ -38,7 +38,6 @@ def _local_session(name="stuck-session"):
def _install_command_stubs(monkeypatch, tmp_path, process):
"""Common _run_browser_command environment with a fake daemon layer."""
monkeypatch.setattr(bt, "_find_agent_browser", lambda: "agent-browser")
- monkeypatch.setattr(bt, "_requires_real_termux_browser_install", lambda _cmd: False)
monkeypatch.setattr(bt, "_chromium_installed", lambda: True)
monkeypatch.setattr(bt, "_start_browser_cleanup_thread", lambda: None)
monkeypatch.setattr(bt, "_ensure_cdp_supervisor", lambda _tid: None)
diff --git a/tests/tools/test_code_execution.py b/tests/tools/test_code_execution.py
index 66ed46a686..1d7d795d7e 100644
--- a/tests/tools/test_code_execution.py
+++ b/tests/tools/test_code_execution.py
@@ -32,18 +32,6 @@ def _force_local_terminal(monkeypatch):
ensures each test starts (and ends) with the correct value.
"""
monkeypatch.setenv("TERMINAL_ENV", "local")
-
-
-@pytest.fixture(autouse=True)
-def _fresh_kernel_registry():
- """Session kernels are always on: dispose them per-test so a lingering
- kernel child can't outlive the run (hangs pytest at exit) or leak one
- test's interpreter state into the next."""
- from tools.code_kernel import shutdown_all_kernels
-
- shutdown_all_kernels()
- yield
- shutdown_all_kernels()
import sys
import threading
import unittest
@@ -164,7 +152,7 @@ class TestExecuteCodeRemoteTempDir(unittest.TestCase):
self.commands = []
def get_temp_dir(self):
- return "/data/data/com.termux/files/usr/tmp"
+ return "/var/host/tmp"
def execute(self, command, cwd=None, timeout=None):
self.commands.append((command, cwd, timeout))
@@ -187,17 +175,12 @@ class TestExecuteCodeRemoteTempDir(unittest.TestCase):
self.assertEqual(result["exit_code"], 0)
self.assertFalse(result["stdout_truncated"])
self.assertEqual(result["stdout_bytes_total"], len("hello\n".encode("utf-8")))
- # The session-kernel path runs first and fails open on this fake env
- # (no PID from nohup), so search for the per-call sandbox commands
- # rather than pinning positions.
- mkdir_cmd = next(cmd for cmd, _, _ in env.commands
- if "mkdir -p" in cmd and "hermes_exec_" in cmd)
+ mkdir_cmd = env.commands[1][0]
run_cmd = next(cmd for cmd, _, _ in env.commands if "python3 script.py" in cmd)
- cleanup_cmd = next(cmd for cmd, _, _ in env.commands
- if "rm -rf" in cmd and "hermes_exec_" in cmd)
- self.assertIn("mkdir -p /data/data/com.termux/files/usr/tmp/hermes_exec_", mkdir_cmd)
- self.assertIn("HERMES_RPC_DIR=/data/data/com.termux/files/usr/tmp/hermes_exec_", run_cmd)
- self.assertIn("rm -rf /data/data/com.termux/files/usr/tmp/hermes_exec_", cleanup_cmd)
+ cleanup_cmd = env.commands[-1][0]
+ self.assertIn("mkdir -p /var/host/tmp/hermes_exec_", mkdir_cmd)
+ self.assertIn("HERMES_RPC_DIR=/var/host/tmp/hermes_exec_", run_cmd)
+ self.assertIn("rm -rf /var/host/tmp/hermes_exec_", cleanup_cmd)
self.assertNotIn("mkdir -p /tmp/hermes_exec_", mkdir_cmd)
def test_timezone_shell_quoted_in_remote_execution(self):
@@ -546,13 +529,8 @@ class TestEnvVarFiltering(unittest.TestCase):
with patch("model_tools.handle_function_call", return_value='{}'), \
patch("tools.code_execution_tool._load_config",
return_value={"timeout": 10, "max_tool_calls": 50}):
- # reset=True: a session kernel's env is frozen at spawn, so
- # env-building rules are only observable on a FRESH kernel —
- # a reused one would (correctly) show the env from whenever
- # it was first spawned, not this test's os.environ tweaks.
raw = execute_code(code, task_id="test-env",
- enabled_tools=list(SANDBOX_ALLOWED_TOOLS),
- reset=True)
+ enabled_tools=list(SANDBOX_ALLOWED_TOOLS))
finally:
os.environ.clear()
os.environ.update(env_backup)
@@ -810,15 +788,7 @@ class TestHeadTailTruncation(unittest.TestCase):
self.assertIn("TAIL", result["output"])
self.assertGreater(result["stdout_bytes_total"], result["stdout_bytes_captured"])
self.assertGreater(result["stdout_bytes_omitted"], 0)
- # Spillover (#96997-adjacent): the warning now points at the saved
- # full-output file instead of advising a narrower re-run.
self.assertIn("execute_code stdout was truncated", result["warning"])
- self.assertIn("read_file", result["warning"])
- self.assertIn("stdout_spill_path", result)
- with open(result["stdout_spill_path"], encoding="utf-8") as f:
- body = f.read()
- self.assertIn("HEAD", body)
- self.assertIn("TAIL", body)
class TestRpcTokenAuthorization(unittest.TestCase):
diff --git a/tests/tools/test_local_tempdir.py b/tests/tools/test_local_tempdir.py
index b07b1b77ee..093a04c515 100644
--- a/tests/tools/test_local_tempdir.py
+++ b/tests/tools/test_local_tempdir.py
@@ -5,16 +5,16 @@ from tools.environments.local import LocalEnvironment
class TestLocalTempDir:
def test_uses_os_tmpdir_for_session_artifacts(self, monkeypatch):
- monkeypatch.setenv("TMPDIR", "/data/data/com.termux/files/usr/tmp")
+ monkeypatch.setenv("TMPDIR", "/var/host/tmp")
monkeypatch.delenv("TMP", raising=False)
monkeypatch.delenv("TEMP", raising=False)
with patch.object(LocalEnvironment, "init_session", autospec=True, return_value=None):
env = LocalEnvironment(cwd=".", timeout=10)
- assert env.get_temp_dir() == "/data/data/com.termux/files/usr/tmp"
- assert env._snapshot_path == f"/data/data/com.termux/files/usr/tmp/hermes-snap-{env._session_id}.sh"
- assert env._cwd_file == f"/data/data/com.termux/files/usr/tmp/hermes-cwd-{env._session_id}.txt"
+ assert env.get_temp_dir() == "/var/host/tmp"
+ assert env._snapshot_path == f"/var/host/tmp/hermes-snap-{env._session_id}.sh"
+ assert env._cwd_file == f"/var/host/tmp/hermes-cwd-{env._session_id}.txt"
def test_falls_back_to_tempfile_when_tmp_missing(self, monkeypatch):
diff --git a/tests/tools/test_termux_api_detection.py b/tests/tools/test_termux_api_detection.py
deleted file mode 100644
index c1406c4585..0000000000
--- a/tests/tools/test_termux_api_detection.py
+++ /dev/null
@@ -1,242 +0,0 @@
-"""Regression tests for issue #31015 — Termux:API app detection.
-
-`/voice on` was reporting "Termux:API Android app is not installed"
-even on devices where the app *is* installed and the
-`termux-microphone-record` binary works fine. The cause was a single
-brittle probe (`pm list packages com.termux.api`) that returns a
-false negative on some Android versions / Termux configurations.
-
-These tests pin the new probe ladder:
-
- 1. `pm list packages` confirms the app → True (back-compat).
- 2. `pm` missing or non-zero → fall back to
- `cmd package list packages` (modern Android API 28+).
- 3. Both probes inconclusive but `termux-microphone-record` on PATH
- → trust the binary, return True.
- 4. At least one probe ran cleanly and definitively did not list
- the package → return False (the genuine "CLI without app"
- case the existing warning was written for).
-"""
-
-from __future__ import annotations
-
-import subprocess
-from types import SimpleNamespace
-from unittest.mock import MagicMock
-
-import pytest
-
-
-# ── Helpers ────────────────────────────────────────────────────────────────
-
-
-def _make_run_dispatcher(behaviors):
- """Build a fake `subprocess.run` that returns / raises per-command.
-
- `behaviors` maps the first arg of each invocation to either a
- SimpleNamespace (returncode, stdout, stderr) or an Exception class
- instance to raise.
- """
- def _fake_run(cmd, **kwargs):
- key = cmd[0] if isinstance(cmd, (list, tuple)) and cmd else cmd
- action = behaviors.get(key)
- if action is None:
- raise AssertionError(f"Unexpected probe command: {cmd!r}")
- if isinstance(action, BaseException):
- raise action
- return action
- return _fake_run
-
-
-def _force_termux(monkeypatch):
- monkeypatch.setattr("tools.voice_mode._is_termux_environment", lambda: True)
-
-
-# ── _termux_api_app_installed: probe ladder ───────────────────────────────
-
-
-class TestTermuxApiAppInstalledProbeLadder:
- """The probe ladder is the heart of the #31015 fix — keep its truth
- table pinned so future "simplifications" can't silently regress it."""
-
- def test_returns_false_outside_termux(self, monkeypatch):
- monkeypatch.setattr("tools.voice_mode._is_termux_environment", lambda: False)
- from tools.voice_mode import _termux_api_app_installed
- assert _termux_api_app_installed() is False
-
- def test_pm_confirms_package_returns_true(self, monkeypatch):
- _force_termux(monkeypatch)
- run = _make_run_dispatcher({
- "pm": SimpleNamespace(
- returncode=0,
- stdout="package:com.termux.api\n",
- stderr="",
- ),
- })
- monkeypatch.setattr("tools.voice_mode.subprocess.run", run)
-
- from tools.voice_mode import _termux_api_app_installed
- assert _termux_api_app_installed() is True
-
-
- def test_pm_timeout_then_cmd_confirms(self, monkeypatch):
- """A hung `pm` (5s timeout) must not block detection."""
- _force_termux(monkeypatch)
- run = _make_run_dispatcher({
- "pm": subprocess.TimeoutExpired(cmd="pm", timeout=5),
- "cmd": SimpleNamespace(
- returncode=0,
- stdout="package:com.termux.api\n",
- stderr="",
- ),
- })
- monkeypatch.setattr("tools.voice_mode.subprocess.run", run)
-
- from tools.voice_mode import _termux_api_app_installed
- assert _termux_api_app_installed() is True
-
- def test_pm_nonzero_exit_then_cmd_confirms(self, monkeypatch):
- """Non-zero exit (e.g. permission denied for the calling user)
- is treated as inconclusive, not as a definitive "no"."""
- _force_termux(monkeypatch)
- run = _make_run_dispatcher({
- "pm": SimpleNamespace(returncode=1, stdout="", stderr="permission denied"),
- "cmd": SimpleNamespace(
- returncode=0,
- stdout="package:com.termux.api\n",
- stderr="",
- ),
- })
- monkeypatch.setattr("tools.voice_mode.subprocess.run", run)
-
- from tools.voice_mode import _termux_api_app_installed
- assert _termux_api_app_installed() is True
-
- def test_both_probes_inconclusive_but_binary_present_returns_true(self, monkeypatch):
- """Core #31015 case: both probes fail or are unavailable, but the
- `termux-microphone-record` binary is on PATH. Trust the binary —
- a false positive only surfaces a precise runtime error, while a
- false negative blocks /voice on entirely (the user-reported
- symptom)."""
- _force_termux(monkeypatch)
- run = _make_run_dispatcher({
- "pm": FileNotFoundError("pm: command not found"),
- "cmd": FileNotFoundError("cmd: command not found"),
- })
- monkeypatch.setattr("tools.voice_mode.subprocess.run", run)
- monkeypatch.setattr(
- "tools.voice_mode.shutil.which",
- lambda name: "/data/data/com.termux/files/usr/bin/termux-microphone-record"
- if name == "termux-microphone-record" else None,
- )
-
- from tools.voice_mode import _termux_api_app_installed
- assert _termux_api_app_installed() is True
-
-
- def test_match_is_case_insensitive(self, monkeypatch):
- """Defensive against ROMs that capitalise the prefix differently."""
- _force_termux(monkeypatch)
- run = _make_run_dispatcher({
- "pm": SimpleNamespace(
- returncode=0,
- stdout="Package:com.termux.api\n",
- stderr="",
- ),
- })
- monkeypatch.setattr("tools.voice_mode.subprocess.run", run)
-
- from tools.voice_mode import _termux_api_app_installed
- assert _termux_api_app_installed() is True
-
-
-# ── End-to-end through detect_audio_environment ───────────────────────────
-
-
-class TestDetectAudioEnvironmentTermuxFallback:
- """The point of the fix is that #31015 users with the binary on PATH
- no longer see the misleading 'Termux:API Android app is not installed'
- warning when the package-manager probe is inconclusive."""
-
- def test_inconclusive_probes_with_binary_does_not_emit_app_warning(
- self, monkeypatch
- ):
- monkeypatch.setenv("TERMUX_VERSION", "0.118.3")
- monkeypatch.setenv("PREFIX", "/data/data/com.termux/files/usr")
- monkeypatch.delenv("SSH_CLIENT", raising=False)
- monkeypatch.delenv("SSH_TTY", raising=False)
- monkeypatch.delenv("SSH_CONNECTION", raising=False)
-
- # No sounddevice — we go down the Termux:API branch.
- monkeypatch.setattr(
- "tools.voice_mode._import_audio",
- lambda: (_ for _ in ()).throw(ImportError("no audio libs")),
- )
- monkeypatch.setattr(
- "tools.voice_mode._termux_microphone_command",
- lambda: "/data/data/com.termux/files/usr/bin/termux-microphone-record",
- )
- # Both probes fail (the #31015 reproduction).
- run = _make_run_dispatcher({
- "pm": FileNotFoundError("pm: command not found"),
- "cmd": FileNotFoundError("cmd: command not found"),
- })
- monkeypatch.setattr("tools.voice_mode.subprocess.run", run)
- monkeypatch.setattr(
- "tools.voice_mode.shutil.which",
- lambda name: "/data/data/com.termux/files/usr/bin/termux-microphone-record"
- if name == "termux-microphone-record" else None,
- )
-
- from tools.voice_mode import detect_audio_environment
- result = detect_audio_environment()
-
- assert result["available"] is True, (
- f"Voice mode should be available when the binary is on PATH "
- f"and probes are inconclusive (issue #31015). Got: {result}"
- )
- assert not any(
- "Termux:API Android app is not installed" in w
- for w in result["warnings"]
- ), (
- "The misleading 'app is not installed' warning must not fire "
- "when probes are inconclusive but the binary works (issue "
- f"#31015). warnings={result['warnings']!r}"
- )
- assert any(
- "Termux:API microphone recording available" in n
- for n in result.get("notices", [])
- )
-
- def test_clean_probes_no_match_still_blocks(self, monkeypatch):
- """The genuine "CLI installed without the app" case still blocks
- with the existing warning — important so users don't lose the
- install hint when the package manager *can* tell us the truth."""
- monkeypatch.setenv("TERMUX_VERSION", "0.118.3")
- monkeypatch.setenv("PREFIX", "/data/data/com.termux/files/usr")
- monkeypatch.delenv("SSH_CLIENT", raising=False)
- monkeypatch.delenv("SSH_TTY", raising=False)
- monkeypatch.delenv("SSH_CONNECTION", raising=False)
-
- monkeypatch.setattr(
- "tools.voice_mode._import_audio",
- lambda: (_ for _ in ()).throw(ImportError("no audio libs")),
- )
- monkeypatch.setattr(
- "tools.voice_mode._termux_microphone_command",
- lambda: "/data/data/com.termux/files/usr/bin/termux-microphone-record",
- )
- run = _make_run_dispatcher({
- "pm": SimpleNamespace(returncode=0, stdout="", stderr=""),
- "cmd": SimpleNamespace(returncode=0, stdout="", stderr=""),
- })
- monkeypatch.setattr("tools.voice_mode.subprocess.run", run)
-
- from tools.voice_mode import detect_audio_environment
- result = detect_audio_environment()
-
- assert result["available"] is False
- assert any(
- "Termux:API Android app is not installed" in w
- for w in result["warnings"]
- )
diff --git a/tests/tools/test_tool_result_storage.py b/tests/tools/test_tool_result_storage.py
index c72d8f933f..2104070a6f 100644
--- a/tests/tools/test_tool_result_storage.py
+++ b/tests/tools/test_tool_result_storage.py
@@ -122,8 +122,8 @@ class TestResolveStorageDir:
def test_uses_env_temp_dir_when_available(self):
env = MagicMock()
- env.get_temp_dir.return_value = "/data/data/com.termux/files/usr/tmp"
- assert _resolve_storage_dir(env) == "/data/data/com.termux/files/usr/tmp/hermes-results"
+ env.get_temp_dir.return_value = "/var/host/tmp"
+ assert _resolve_storage_dir(env) == "/var/host/tmp/hermes-results"
class TestSafeResultFilename:
diff --git a/tests/tools/test_voice_mode.py b/tests/tools/test_voice_mode.py
index be9f24f6de..d952d8346d 100644
--- a/tests/tools/test_voice_mode.py
+++ b/tests/tools/test_voice_mode.py
@@ -315,71 +315,6 @@ class TestCheckVoiceRequirements:
# AudioRecorder
# ============================================================================
-class TestCreateAudioRecorder:
- def test_termux_uses_termux_audio_recorder_when_api_present(self, monkeypatch):
- monkeypatch.setenv("TERMUX_VERSION", "0.118.3")
- monkeypatch.setenv("PREFIX", "/data/data/com.termux/files/usr")
- monkeypatch.setattr("tools.voice_mode._termux_microphone_command", lambda: "/data/data/com.termux/files/usr/bin/termux-microphone-record")
- monkeypatch.setattr("tools.voice_mode._termux_api_app_installed", lambda: True)
-
- from tools.voice_mode import create_audio_recorder, TermuxAudioRecorder
- recorder = create_audio_recorder()
-
- assert isinstance(recorder, TermuxAudioRecorder)
- assert recorder.supports_silence_autostop is False
-
-class TestTermuxAudioRecorder:
- def test_start_and_stop_use_termux_microphone_commands(self, monkeypatch, temp_voice_dir):
- command_calls = []
- output_path = Path(temp_voice_dir) / "recording_20260409_120000.aac"
-
- def fake_run(cmd, **kwargs):
- command_calls.append(cmd)
- if cmd[1] == "-f":
- Path(cmd[2]).write_bytes(b"aac-bytes")
- return MagicMock(returncode=0, stdout="", stderr="")
-
- monkeypatch.setenv("TERMUX_VERSION", "0.118.3")
- monkeypatch.setenv("PREFIX", "/data/data/com.termux/files/usr")
- monkeypatch.setattr("tools.voice_mode._termux_microphone_command", lambda: "/data/data/com.termux/files/usr/bin/termux-microphone-record")
- monkeypatch.setattr("tools.voice_mode._termux_api_app_installed", lambda: True)
- monkeypatch.setattr("tools.voice_mode.time.strftime", lambda fmt: "20260409_120000")
- monkeypatch.setattr("tools.voice_mode.subprocess.run", fake_run)
-
- from tools.voice_mode import TermuxAudioRecorder
- recorder = TermuxAudioRecorder()
- recorder.start()
- recorder._start_time = time.monotonic() - 1.0
- result = recorder.stop()
-
- assert result == str(output_path)
- assert command_calls[0][:2] == ["/data/data/com.termux/files/usr/bin/termux-microphone-record", "-f"]
- assert command_calls[1] == ["/data/data/com.termux/files/usr/bin/termux-microphone-record", "-q"]
-
- def test_cancel_removes_partial_termux_recording(self, monkeypatch, temp_voice_dir):
- output_path = Path(temp_voice_dir) / "recording_20260409_120000.aac"
-
- def fake_run(cmd, **kwargs):
- if cmd[1] == "-f":
- Path(cmd[2]).write_bytes(b"aac-bytes")
- return MagicMock(returncode=0, stdout="", stderr="")
-
- monkeypatch.setenv("TERMUX_VERSION", "0.118.3")
- monkeypatch.setenv("PREFIX", "/data/data/com.termux/files/usr")
- monkeypatch.setattr("tools.voice_mode._termux_microphone_command", lambda: "/data/data/com.termux/files/usr/bin/termux-microphone-record")
- monkeypatch.setattr("tools.voice_mode._termux_api_app_installed", lambda: True)
- monkeypatch.setattr("tools.voice_mode.time.strftime", lambda fmt: "20260409_120000")
- monkeypatch.setattr("tools.voice_mode.subprocess.run", fake_run)
-
- from tools.voice_mode import TermuxAudioRecorder
- recorder = TermuxAudioRecorder()
- recorder.start()
- recorder.cancel()
-
- assert output_path.exists() is False
- assert recorder.is_recording is False
-
-
class TestAudioRecorder:
def test_start_raises_without_audio_libs(self, monkeypatch):
def _fail_import():
@@ -399,7 +334,6 @@ class TestAudioRecorder:
def _fail_import():
raise OSError("PortAudio library not found")
monkeypatch.setattr("tools.voice_mode._import_audio", _fail_import)
- monkeypatch.setattr("tools.voice_mode._is_termux_environment", lambda: False)
from tools.voice_mode import AudioRecorder
diff --git a/tools/tirith_security.py b/tools/tirith_security.py
index a284c6d400..9955b797df 100644
--- a/tools/tirith_security.py
+++ b/tools/tirith_security.py
@@ -183,7 +183,7 @@ def _read_failure_reason() -> str | None:
mtime = os.path.getmtime(p)
if (time.time() - mtime) >= _MARKER_TTL:
return None
- with open(p, "r", encoding="utf-8") as f:
+ with open(p, "r", encoding="utf-8-sig") as f:
return f.read().strip()
except OSError:
return None
@@ -252,7 +252,7 @@ def _detect_target() -> str | None:
system = platform.system()
machine = platform.machine().lower()
- # Android (Termux) is ABI-compatible with Linux — reuse Linux binaries.
+ # Android is ABI-compatible with Linux — reuse the Linux binaries.
if system == "Darwin":
plat = "apple-darwin"
elif system in {"Linux", "Android"}:
@@ -335,7 +335,7 @@ def _verify_cosign(checksums_path: str, sig_path: str, cert_path: str) -> bool |
def _verify_checksum(archive_path: str, checksums_path: str, archive_name: str) -> bool:
"""Verify SHA-256 of the archive against checksums.txt."""
expected = None
- with open(checksums_path, encoding="utf-8") as f:
+ with open(checksums_path, encoding="utf-8-sig") as f:
for line in f:
# Format: " "
parts = line.strip().split(" ", 1)
diff --git a/ui-tui/README.md b/ui-tui/README.md
index ac1d612f75..9ac86ba4d4 100644
--- a/ui-tui/README.md
+++ b/ui-tui/README.md
@@ -401,7 +401,7 @@ ui-tui/
todoPanel.tsx todo list panel
config/
- env.ts environment variable resolution and Termux/mouse defaults
+ env.ts environment variable resolution and mouse defaults
limits.ts paste size, live-render and history limits
timing.ts streaming batch and debounce timing constants
@@ -456,7 +456,7 @@ ui-tui/
perfPane.tsx FPS / render perf overlay pane
platform.ts platform-aware keybinding and SSH detection helpers
precisionWheel.ts high-precision scroll wheel with sticky-frame budget
- prompt.ts composer prompt text helpers (Termux-safe)
+ prompt.ts composer prompt text helpers
reasoning.ts reasoning tag detection and split helpers
rpc.ts JSON-RPC result and command dispatch helpers
subagentTree.ts subagent tree flattening and aggregate helpers
@@ -464,7 +464,6 @@ ui-tui/
terminalModes.ts terminal mode reset sequences (kitty, mouse, etc.)
terminalParity.ts VSCode-like terminal detection and hint helpers
terminalSetup.ts IDE keybinding config file install helpers
- termux.ts Termux platform detection helpers
text.ts text helpers, ANSI detection, tool trail builders
todo.ts todo item tone and display helpers
viewportStore.ts viewport height nanostore via ScrollBoxHandle
diff --git a/ui-tui/packages/hermes-ink/src/ink/ink-focus-redraw.test.ts b/ui-tui/packages/hermes-ink/src/ink/ink-focus-redraw.test.ts
index 5300b161fe..a008f65807 100644
--- a/ui-tui/packages/hermes-ink/src/ink/ink-focus-redraw.test.ts
+++ b/ui-tui/packages/hermes-ink/src/ink/ink-focus-redraw.test.ts
@@ -22,7 +22,7 @@ import { DISABLE_MOUSE_TRACKING } from './termio/dec.js'
* no frame can be presented between "screen cleared" and "content drawn".
* A separate erase write is the visible flash on an ordinary tab switch.
*
- * Both hold on the alt screen and on the main screen (INLINE_MODE / Termux).
+ * Both hold on the alt screen and on the main screen (INLINE_MODE).
*/
/** Minimal terminal emulator: replays ANSI into a cell grid. */
diff --git a/ui-tui/packages/hermes-ink/src/ink/ink.tsx b/ui-tui/packages/hermes-ink/src/ink/ink.tsx
index ca4f239b70..e1338b3a7b 100644
--- a/ui-tui/packages/hermes-ink/src/ink/ink.tsx
+++ b/ui-tui/packages/hermes-ink/src/ink/ink.tsx
@@ -1073,7 +1073,7 @@ export default class Ink {
optimized.push(this.altScreenParkPatch)
} else if (this.needsEraseBeforePaint) {
- // Main screen (INLINE_MODE / Termux). Same atomicity contract as the
+ // Main screen (INLINE_MODE). Same atomicity contract as the
// alt-screen branch above: fold the clear into this frame's patch list
// so clear+paint land in one write instead of a bare
// stdout.write(ERASE_SCREEN) followed by the frame. No cursor park —
diff --git a/ui-tui/src/__tests__/prompt.test.ts b/ui-tui/src/__tests__/prompt.test.ts
index 68c5735478..7b923c79a4 100644
--- a/ui-tui/src/__tests__/prompt.test.ts
+++ b/ui-tui/src/__tests__/prompt.test.ts
@@ -16,16 +16,4 @@ describe('composerPromptText', () => {
expect(composerPromptText('❯', 'custom')).toBe('❯')
expect(composerPromptText('❯')).toBe('❯')
})
-
- it('uses a Termux-safe ASCII prompt marker in normal mode', () => {
- expect(composerPromptText('❯', 'coder', false, true, 50)).toBe('>')
- })
-
- it('keeps profile prefix suppressed on narrow Termux widths', () => {
- expect(composerPromptText('❯', 'upstr', false, true, 72)).toBe('>')
- })
-
- it('allows profile prefix on very wide Termux panes', () => {
- expect(composerPromptText('❯', 'upstr', false, true, 120)).toBe('upstr >')
- })
})
diff --git a/ui-tui/src/__tests__/termux.test.ts b/ui-tui/src/__tests__/termux.test.ts
deleted file mode 100644
index d8d4ef8348..0000000000
--- a/ui-tui/src/__tests__/termux.test.ts
+++ /dev/null
@@ -1,31 +0,0 @@
-import { describe, expect, it } from 'vitest'
-
-import { isTermuxEnv, isTermuxTuiMode } from '../lib/termux.js'
-
-describe('isTermuxEnv', () => {
- it('detects TERMUX_VERSION marker', () => {
- expect(isTermuxEnv({ TERMUX_VERSION: '0.118.0' } as NodeJS.ProcessEnv)).toBe(true)
- })
-
- it('detects Termux PREFIX path marker', () => {
- expect(isTermuxEnv({ PREFIX: '/data/data/com.termux/files/usr' } as NodeJS.ProcessEnv)).toBe(true)
- })
-
- it('returns false for generic Linux envs', () => {
- expect(isTermuxEnv({ PREFIX: '/usr' } as NodeJS.ProcessEnv)).toBe(false)
- })
-})
-
-describe('isTermuxTuiMode', () => {
- it('defaults to true inside Termux', () => {
- expect(isTermuxTuiMode({ TERMUX_VERSION: '0.118.0' } as NodeJS.ProcessEnv)).toBe(true)
- })
-
- it('allows explicit opt-out override', () => {
- expect(isTermuxTuiMode({ TERMUX_VERSION: '0.118.0', HERMES_TUI_TERMUX_MODE: '0' } as NodeJS.ProcessEnv)).toBe(false)
- })
-
- it('stays false outside Termux even if override is set', () => {
- expect(isTermuxTuiMode({ HERMES_TUI_TERMUX_MODE: '1', PREFIX: '/usr' } as NodeJS.ProcessEnv)).toBe(false)
- })
-})
diff --git a/ui-tui/src/__tests__/termuxComposerLayout.test.ts b/ui-tui/src/__tests__/termuxComposerLayout.test.ts
deleted file mode 100644
index e845ef89c3..0000000000
--- a/ui-tui/src/__tests__/termuxComposerLayout.test.ts
+++ /dev/null
@@ -1,40 +0,0 @@
-import { describe, expect, it } from 'vitest'
-
-import { stableComposerColumns, transcriptBodyWidth } from '../lib/inputMetrics.js'
-import { composerPromptText } from '../lib/prompt.js'
-
-describe('Termux composer prompt + width guards', () => {
- it('uses a single-cell ASCII prompt marker in Termux mode', () => {
- expect(composerPromptText('❯', 'coder', false, true, 50)).toBe('>')
- })
-
- it('suppresses profile prefixes on narrow Termux panes', () => {
- expect(composerPromptText('❯', 'upstr', false, true, 72)).toBe('>')
- })
-
- it('keeps profile context on very wide Termux panes', () => {
- expect(composerPromptText('❯', 'upstr', false, true, 120)).toBe('upstr >')
- })
-
- it('reserves fewer columns for gutter on narrow Termux widths', () => {
- // 32 columns after prompt: desktop reserves 2 for transcript scrollbar,
- // Termux keeps those 2 columns for the active composer.
- expect(stableComposerColumns(40, 8, false)).toBe(28)
- expect(stableComposerColumns(40, 8, true)).toBe(30)
-
- // With ample room, Termux still reserves the gutter for alignment.
- expect(stableComposerColumns(60, 8, true)).toBe(48)
- })
-
- it('never over-allocates transcript body width on narrow panes', () => {
- // Old behavior hard-minned to 20 columns and overflowed narrow layouts.
- expect(transcriptBodyWidth(24, 'assistant', '>', true)).toBe(19)
- expect(transcriptBodyWidth(24, 'user', 'upstr >', true)).toBe(14)
- expect(transcriptBodyWidth(10, 'user', '>', true)).toBeGreaterThanOrEqual(1)
- })
-
- it('keeps legacy desktop floor outside Termux mode', () => {
- expect(transcriptBodyWidth(24, 'assistant', '>')).toBe(20)
- expect(transcriptBodyWidth(24, 'user', 'upstr >')).toBe(20)
- })
-})
diff --git a/ui-tui/src/__tests__/textInputFastEcho.test.ts b/ui-tui/src/__tests__/textInputFastEcho.test.ts
index cc4f088174..ee02d632c6 100644
--- a/ui-tui/src/__tests__/textInputFastEcho.test.ts
+++ b/ui-tui/src/__tests__/textInputFastEcho.test.ts
@@ -254,16 +254,6 @@ describe('supportsFastEchoTerminal', () => {
expect(supportsFastEchoTerminal({ TMUX: '/private/tmp/tmux-501/default' } as NodeJS.ProcessEnv)).toBe(false)
})
- it('tmux wins over Termux fast-echo opt-in', () => {
- expect(
- supportsFastEchoTerminal({
- TMUX: '/tmp/tmux-1000/default,1234,0',
- HERMES_TUI_TERMUX_FAST_ECHO: '1',
- TERMUX_VERSION: '0.118.0'
- } as NodeJS.ProcessEnv)
- ).toBe(false)
- })
-
it('keeps fast-echo enabled when TMUX is empty or unset', () => {
expect(supportsFastEchoTerminal({ TMUX: '' } as NodeJS.ProcessEnv)).toBe(true)
expect(supportsFastEchoTerminal({ TERM_PROGRAM: 'vscode' } as NodeJS.ProcessEnv)).toBe(true)
@@ -286,25 +276,7 @@ describe('supportsFastEchoTerminal', () => {
expect(supportsFastEchoTerminal({ TERM: 'xterm-256color' } as NodeJS.ProcessEnv)).toBe(true)
})
- it('disables fast-echo by default in Termux mode', () => {
- expect(
- supportsFastEchoTerminal({
- TERMUX_VERSION: '0.118.0',
- PREFIX: '/data/data/com.termux/files/usr'
- } as NodeJS.ProcessEnv)
- ).toBe(false)
- })
-
- it('allows explicit Termux fast-echo opt-in via env override', () => {
- expect(
- supportsFastEchoTerminal({
- HERMES_TUI_TERMUX_FAST_ECHO: '1',
- TERMUX_VERSION: '0.118.0'
- } as NodeJS.ProcessEnv)
- ).toBe(true)
- })
-
- it('keeps fast-echo enabled in VS Code and unknown non-Termux terminals', () => {
+ it('keeps fast-echo enabled in VS Code and unknown terminals', () => {
expect(supportsFastEchoTerminal({ TERM_PROGRAM: 'vscode' } as NodeJS.ProcessEnv)).toBe(true)
expect(supportsFastEchoTerminal({ TERM: 'xterm-256color' } as NodeJS.ProcessEnv)).toBe(true)
})
diff --git a/ui-tui/src/components/appLayout.tsx b/ui-tui/src/components/appLayout.tsx
index f358097699..6ac2175eb4 100644
--- a/ui-tui/src/components/appLayout.tsx
+++ b/ui-tui/src/components/appLayout.tsx
@@ -11,7 +11,7 @@ import { $isBlocked, $overlayState, patchOverlayState } from '../app/overlayStor
import { $petBox } from '../app/petFlashStore.js'
import { $uiState } from '../app/uiStore.js'
import { usePet } from '../app/usePet.js'
-import { INLINE_MODE, SHOW_FPS, TERMUX_TUI_MODE } from '../config/env.js'
+import { INLINE_MODE, SHOW_FPS } from '../config/env.js'
import { PLACEHOLDER } from '../content/placeholders.js'
import { prevRenderedMsg } from '../domain/blockLayout.js'
import {
@@ -280,17 +280,11 @@ const ComposerPane = memo(function ComposerPane({
const isBlocked = useStore($isBlocked)
const sh = (composer.inputBuf[0] ?? composer.input).startsWith('!')
- const promptText = composerPromptText(
- ui.theme.brand.prompt,
- ui.info?.profile_name,
- sh,
- TERMUX_TUI_MODE,
- composer.cols
- )
+ const promptText = composerPromptText(ui.theme.brand.prompt, ui.info?.profile_name, sh)
const promptWidth = composerPromptWidth(promptText)
const promptBlank = ' '.repeat(promptWidth)
- const inputColumns = stableComposerColumns(composer.cols, promptWidth, TERMUX_TUI_MODE)
+ const inputColumns = stableComposerColumns(composer.cols, promptWidth)
const inputHeight = inputVisualHeight(composer.input, inputColumns)
const inputMouseRef = useRef(null)
diff --git a/ui-tui/src/components/messageLine.tsx b/ui-tui/src/components/messageLine.tsx
index 7417450e35..a380ce5a56 100644
--- a/ui-tui/src/components/messageLine.tsx
+++ b/ui-tui/src/components/messageLine.tsx
@@ -1,7 +1,6 @@
import { Ansi, Box, NoSelect, Text } from '@hermes/ink'
import { memo, useState } from 'react'
-import { TERMUX_TUI_MODE } from '../config/env.js'
import { LONG_MSG } from '../config/limits.js'
import { hasLeadGap } from '../domain/blockLayout.js'
import { splitComposerHighlights } from '../domain/composerHighlights.js'
@@ -204,7 +203,7 @@ export const MessageLine = memo(function MessageLine({
}
if (msg.role === 'assistant') {
- const bodyWidth = transcriptBodyWidth(cols, msg.role, t.brand.prompt, TERMUX_TUI_MODE)
+ const bodyWidth = transcriptBodyWidth(cols, msg.role, t.brand.prompt)
return isStreaming ? (
// Incremental markdown: split at the last stable block boundary so
@@ -317,7 +316,7 @@ export const MessageLine = memo(function MessageLine({
-
{content}
+
{content}
)
diff --git a/ui-tui/src/components/textInput.tsx b/ui-tui/src/components/textInput.tsx
index cc630e3df4..43cf76d300 100644
--- a/ui-tui/src/components/textInput.tsx
+++ b/ui-tui/src/components/textInput.tsx
@@ -14,7 +14,6 @@ import {
isVoiceToggleKey,
type ParsedVoiceRecordKey
} from '../lib/platform.js'
-import { isTermuxTuiMode } from '../lib/termux.js'
type InkExt = typeof Ink & {
colorize: (str: string, color: string | undefined, type: 'foreground' | 'background') => string
@@ -665,21 +664,6 @@ export function supportsFastEchoTerminal(env: NodeJS.ProcessEnv = process.env):
return false
}
- // Termux terminals are especially sensitive to bypass-path cursor drift and
- // stale paints at soft-wrap boundaries on tall/narrow viewports. Keep this
- // off by default in Termux mode; allow explicit opt-in for local debugging.
- if (isTermuxTuiMode(env)) {
- const override = String(env.HERMES_TUI_TERMUX_FAST_ECHO ?? '')
- .trim()
- .toLowerCase()
-
- if (override) {
- return /^(?:1|true|yes|on)$/i.test(override)
- }
-
- return false
- }
-
return true
}
diff --git a/ui-tui/src/config/env.ts b/ui-tui/src/config/env.ts
index 426e6459ca..c4cd4c3ac1 100644
--- a/ui-tui/src/config/env.ts
+++ b/ui-tui/src/config/env.ts
@@ -1,7 +1,5 @@
import type { MouseTrackingMode } from '@hermes/ink'
-import { isTermuxTuiMode } from '../lib/termux.js'
-
const truthy = (v?: string) => /^(?:1|true|yes|on)$/i.test((v ?? '').trim())
const falsy = (v?: string) => /^(?:0|false|no|off)$/i.test((v ?? '').trim())
@@ -23,8 +21,6 @@ const parseToggle = (v?: string): boolean | null => {
return null
}
-export const TERMUX_TUI_MODE = isTermuxTuiMode()
-
export const STARTUP_RESUME_ID = (process.env.HERMES_TUI_RESUME ?? '').trim()
export const STARTUP_QUERY = (process.env.HERMES_TUI_QUERY ?? '').trim()
export const STARTUP_IMAGE = (process.env.HERMES_TUI_IMAGE ?? '').trim()
@@ -37,15 +33,13 @@ export const STARTUP_IMAGE = (process.env.HERMES_TUI_IMAGE ?? '').trim()
//
// - HERMES_TUI_MOUSE_TRACKING (truthy/falsy) explicitly overrides everything.
// This is the "force a value" knob and intentionally beats the legacy
-// kill-switch and the Termux default.
+// kill-switch.
// - HERMES_TUI_DISABLE_MOUSE=1 forces mouse off — the legacy kill switch.
-// - On Termux the default is mouse off so touch selection isn't intercepted
-// by terminal mouse protocols. Desktop defaults to 'all' to preserve prior
-// behavior.
+// - Otherwise mouse tracking defaults to 'all'.
const mouseTrackingOverride = parseToggle(process.env.HERMES_TUI_MOUSE_TRACKING)
const mouseTrackingDisabledLegacy = truthy(process.env.HERMES_TUI_DISABLE_MOUSE)
-const resolvedBootMouseEnabled = mouseTrackingOverride ?? (TERMUX_TUI_MODE ? false : !mouseTrackingDisabledLegacy)
+const resolvedBootMouseEnabled = mouseTrackingOverride ?? !mouseTrackingDisabledLegacy
export const MOUSE_TRACKING: MouseTrackingMode = resolvedBootMouseEnabled ? 'all' : 'off'
@@ -64,11 +58,8 @@ const inlineOverride = parseToggle(process.env.HERMES_TUI_INLINE)
// Skip AlternateScreen — TUI renders into the primary buffer so the host
// terminal's native scrollback captures whatever scrolls off the top.
-//
-// On Termux we default this on: users often background/foreground the app,
-// and primary-buffer rendering makes long-thread review and copy/paste much
-// less fragile. Override explicitly with HERMES_TUI_INLINE=0/1.
-export const INLINE_MODE = inlineOverride ?? TERMUX_TUI_MODE
+// Off by default; opt in with HERMES_TUI_INLINE=1.
+export const INLINE_MODE = inlineOverride ?? false
// Live FPS counter overlay, fed by ink's onFrame (real render rate, not a
// synthetic timer).
diff --git a/ui-tui/src/entry.tsx b/ui-tui/src/entry.tsx
index 6f856cf57a..e404f7ed17 100644
--- a/ui-tui/src/entry.tsx
+++ b/ui-tui/src/entry.tsx
@@ -5,7 +5,7 @@ import './lib/forceTruecolor.js'
import type { FrameEvent } from '@hermes/ink'
-import { DASHBOARD_TUI_MODE, TERMUX_TUI_MODE } from './config/env.js'
+import { DASHBOARD_TUI_MODE } from './config/env.js'
import { GatewayClient } from './gatewayClient.js'
import { setupGracefulExit } from './lib/gracefulExit.js'
import { formatBytes, type HeapDumpResult, performHeapDump } from './lib/memory.js'
@@ -39,14 +39,9 @@ process.on('exit', () => {
resetTerminalModes()
})
-// Desktop terminals benefit from a clean startup slate because the TUI usually
-// runs in AlternateScreen. On Termux we keep prior output intact so users can
-// review/copy earlier assistant replies after reopening the app.
-if (TERMUX_TUI_MODE) {
- process.stdout.write('\n')
-} else {
- process.stdout.write('\x1b[2J\x1b[H\x1b[3J')
-}
+// Terminals benefit from a clean startup slate because the TUI usually runs
+// in AlternateScreen.
+process.stdout.write('\x1b[2J\x1b[H\x1b[3J')
const gw = new GatewayClient()
diff --git a/ui-tui/src/lib/inputMetrics.ts b/ui-tui/src/lib/inputMetrics.ts
index 5311e8e888..08d51e2de7 100644
--- a/ui-tui/src/lib/inputMetrics.ts
+++ b/ui-tui/src/lib/inputMetrics.ts
@@ -178,26 +178,19 @@ export function transcriptGutterWidth(role: Role, userPrompt: string) {
return role === 'user' ? composerPromptWidth(userPrompt) : 3
}
-export function transcriptBodyWidth(totalCols: number, role: Role, userPrompt: string, termuxMode = false) {
- const horizontalReserve = termuxMode ? 2 : 4
- const available = Math.max(1, totalCols - transcriptGutterWidth(role, userPrompt) - horizontalReserve)
-
- if (termuxMode) {
- // On narrow / unusual aspect-ratio mobile panes, forcing a wide minimum
- // width causes right-edge clipping and chopped words.
- return available
- }
+export function transcriptBodyWidth(totalCols: number, role: Role, userPrompt: string) {
+ const available = Math.max(1, totalCols - transcriptGutterWidth(role, userPrompt) - 4)
return Math.max(20, available)
}
-export function stableComposerColumns(totalCols: number, promptWidth: number, termuxMode = false) {
+export function stableComposerColumns(totalCols: number, promptWidth: number) {
// Physical render/wrap width. Always reserve outer composer padding and
// prompt prefix. Only reserve the transcript scrollbar gutter when the
// terminal is wide enough; on narrow panes, preserving input columns beats
// keeping gutters visually aligned.
const afterPrompt = totalCols - promptWidth
- const reserveScrollbar = afterPrompt >= (termuxMode ? 36 : 24) ? 2 : 0
+ const reserveScrollbar = afterPrompt >= 24 ? 2 : 0
return Math.max(1, totalCols - promptWidth - 2 - reserveScrollbar)
}
diff --git a/ui-tui/src/lib/prompt.ts b/ui-tui/src/lib/prompt.ts
index 27b30474c0..aaf3559dc8 100644
--- a/ui-tui/src/lib/prompt.ts
+++ b/ui-tui/src/lib/prompt.ts
@@ -1,33 +1,12 @@
-const TERMUX_SAFE_PROMPT = '>'
-
export function composerPromptText(
prompt: string,
profileName?: null | string,
- shellMode = false,
- termuxMode = false,
- totalCols?: number
+ shellMode = false
): string {
if (shellMode) {
return '$'
}
- if (termuxMode) {
- // Termux fonts/terminal backends can render decorative prompt glyphs with
- // ambiguous width; keep the live composer marker strictly single-cell ASCII
- // so we never leave stale arrow artifacts while typing.
- const basePrompt = TERMUX_SAFE_PROMPT
-
- // On very wide panes we can still include profile context. On narrow/mobile
- // panes this burns precious columns and increases wrap/clipping risk.
- const wideEnoughForProfile = typeof totalCols === 'number' ? totalCols >= 90 : false
-
- if (wideEnoughForProfile && profileName && !['default', 'custom'].includes(profileName)) {
- return `${profileName} ${basePrompt}`
- }
-
- return basePrompt
- }
-
if (profileName && !['default', 'custom'].includes(profileName)) {
return `${profileName} ${prompt}`
}
diff --git a/ui-tui/src/lib/termux.ts b/ui-tui/src/lib/termux.ts
deleted file mode 100644
index 492e43ccec..0000000000
--- a/ui-tui/src/lib/termux.ts
+++ /dev/null
@@ -1,31 +0,0 @@
-const TERMUX_PREFIX = '/data/data/com.termux/files/usr'
-
-const truthy = (value?: string) => /^(?:1|true|yes|on)$/i.test(String(value ?? '').trim())
-
-export const isTermuxEnv = (env: NodeJS.ProcessEnv = process.env): boolean => {
- const prefix = String(env.PREFIX ?? '')
-
- return Boolean(env.TERMUX_VERSION) || prefix.includes(TERMUX_PREFIX)
-}
-
-/**
- * Return true when Hermes should enable Termux-focused TUI defaults.
- *
- * Defaults to on in Termux, with an explicit opt-out for debugging:
- * HERMES_TUI_TERMUX_MODE=0
- */
-export const isTermuxTuiMode = (env: NodeJS.ProcessEnv = process.env): boolean => {
- if (!isTermuxEnv(env)) {
- return false
- }
-
- const override = String(env.HERMES_TUI_TERMUX_MODE ?? '')
- .trim()
- .toLowerCase()
-
- if (override) {
- return truthy(override)
- }
-
- return true
-}
diff --git a/ui-tui/src/lib/virtualHeights.ts b/ui-tui/src/lib/virtualHeights.ts
index cf1ebd95d7..f6dc3eb541 100644
--- a/ui-tui/src/lib/virtualHeights.ts
+++ b/ui-tui/src/lib/virtualHeights.ts
@@ -1,4 +1,3 @@
-import { TERMUX_TUI_MODE } from '../config/env.js'
import type { Msg } from '../types.js'
import { transcriptBodyWidth } from './inputMetrics.js'
@@ -105,7 +104,7 @@ export const estimatedMsgHeight = (
return Math.max(2, msg.todos.length + 2)
}
- const bodyWidth = transcriptBodyWidth(cols, msg.role, userPrompt, TERMUX_TUI_MODE)
+ const bodyWidth = transcriptBodyWidth(cols, msg.role, userPrompt)
const text = msg.text
let h = wrappedLines(text || ' ', bodyWidth)
diff --git a/website/docs/getting-started/installation.md b/website/docs/getting-started/installation.md
index 98b95c9d7f..d28d8de0fc 100644
--- a/website/docs/getting-started/installation.md
+++ b/website/docs/getting-started/installation.md
@@ -1,7 +1,7 @@
---
sidebar_position: 2
title: "Installation"
-description: "Install Hermes Agent on Linux, macOS, WSL2, native Windows, or Android via Termux"
+description: "Install Hermes Agent on Linux, macOS, WSL2, or native Windows"
---
# Installation
@@ -20,7 +20,7 @@ To easily install the command-line and desktop applications, [download the Herme
### Without Hermes Desktop:
For a command-line only install without Hermes Desktop, run:
-#### Linux / macOS / WSL2 / Android (Termux)
+#### Linux / macOS / WSL2
```bash
curl -fsSL https://hermes-agent.nousresearch.com/install.sh | bash
```
@@ -131,12 +131,9 @@ Running Hermes as a dedicated unprivileged user (e.g. a `hermes` systemd service
curl -fsSL https://hermes-agent.nousresearch.com/install.sh | bash
```
- If you want to skip the Playwright step entirely — for example because you're running headless and don't need browser automation — pass `--skip-browser`:
- ```bash
- curl -fsSL https://hermes-agent.nousresearch.com/install.sh | bash -s -- --skip-browser
- ```
-
- The installer also pre-installs [`cua-driver`](../user-guide/features/computer-use.md) so the Computer Use toolset works the moment you enable it; pass `--skip-computer-use` to opt out (it will then install on demand when you enable the tool).
+ Browsers, node, and other tool binaries are not part of the bootstrap:
+ pm installs them on demand the first time a feature needs them, or all at
+ once with `hermes pm install`.
3. **Make `hermes` available to the service user's shells.** The installer writes the launcher to `~/.local/bin/hermes`. System service accounts often have a minimal PATH that doesn't include `~/.local/bin`. Either add it to the user's environment, or symlink the launcher into a system location:
```bash
diff --git a/website/docs/getting-started/platform-support.md b/website/docs/getting-started/platform-support.md
index 1791716717..cf5ac4b3f3 100644
--- a/website/docs/getting-started/platform-support.md
+++ b/website/docs/getting-started/platform-support.md
@@ -16,9 +16,9 @@ We strive to never break installations and updates for these. Issues & regressio
| OS / Architecture | Installation methods | Notes |
| ----------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------- |
-| **macOS** (Apple Silicon) | [Hermes Desktop](https://hermes-agent.nousresearch.com/), [`install.sh`](./installation.md#linux--macos--wsl2--android-termux) |
+| **macOS** (Apple Silicon) | [Hermes Desktop](https://hermes-agent.nousresearch.com/), [`install.sh`](./installation.md#linux--macos--wsl2) |
| [**Windows 10 / 11**](../user-guide/windows-native.md) (x86_64, aarch64) | [Hermes Desktop](https://hermes-agent.nousresearch.com/), [`install.ps1`](./installation.md#windows-native) | A few features are [not available](../user-guide/windows-native.md#feature-matrix). |
-| **Linux / [WSL2](../user-guide/windows-wsl-quickstart.md)** (x86_64, aarch64) | [`install.sh`](./installation.md#linux--macos--wsl2--android-termux) | We test on the latest Ubuntu and WSL2. If your distro has glibc, systemd, and follows the Filesystem Hierarchy Standard, it's likely to work pretty well. |
+| **Linux / [WSL2](../user-guide/windows-wsl-quickstart.md)** (x86_64, aarch64) | [`install.sh`](./installation.md#linux--macos--wsl2) | We test on the latest Ubuntu and WSL2. If your distro has glibc, systemd, and follows the Filesystem Hierarchy Standard, it's likely to work pretty well. |
| [**Docker Container**](../user-guide/docker.md#quick-start) (x86_64, aarch64) | [`docker pull`](../user-guide/docker.md#quick-start) | Docker installs do not support `hermes update`. Updating is done by running a new image. |
---
@@ -32,7 +32,6 @@ PRs will be accepted to fix issues with them, but they will take precedence belo
| OS / Architecture | Installation methods | Notes |
| ------------------------------ | -------------------------------------------------------------------- | ---------------------------------------------------------------------------- |
-| **Android (Termux)** (aarch64) | [`install.sh`](./installation.md#linux--macos--wsl2--android-termux) | A few features are [not available](./termux.md#known-limitations-on-phones). |
| **Nix** (MacOS, Linux, NixOS) | [`install.sh`](./nix-setup.md) | Breaks often due to node.js packaging woes. Best of luck~! <3 |
## Unsupported
@@ -42,6 +41,7 @@ We suggest that you migrate to a supported distribution method or platform.
They may be broken right now, they may break more in the future.
PRs to fix them will _not_ be accepted, and any code that keeps compatibility with them may be removed at any point.
+- Android / Termux (support was removed)
- installs via the AUR (we might upstream patches if it helps out <3)
- macOS on x86 (Intel) processors
- installs via `pypi` (e.g. `uv tool install hermes-agent`, `pip install hermes-agent`, etc.)
diff --git a/website/docs/getting-started/quickstart.md b/website/docs/getting-started/quickstart.md
index 168609a006..f065e16e7e 100644
--- a/website/docs/getting-started/quickstart.md
+++ b/website/docs/getting-started/quickstart.md
@@ -53,7 +53,7 @@ To easily install the command-line and desktop applications, [download the Herme
### Without Hermes Desktop:
For a command-line only install without Hermes Desktop, run:
-#### Linux / macOS / WSL2 / Android (Termux)
+#### Linux / macOS / WSL2
```bash
curl -fsSL https://hermes-agent.nousresearch.com/install.sh | bash
```
@@ -65,10 +65,6 @@ Run in powershell:
iex (irm https://hermes-agent.nousresearch.com/install.ps1)
```
-:::tip Android / Termux
-If you're installing on a phone, see the dedicated [Termux guide](./termux.md) for the tested manual path, supported extras, and current Android-specific limitations.
-:::
-
After it finishes, reload your shell:
```bash
diff --git a/website/docs/getting-started/termux.md b/website/docs/getting-started/termux.md
deleted file mode 100644
index 6b31efb30c..0000000000
--- a/website/docs/getting-started/termux.md
+++ /dev/null
@@ -1,285 +0,0 @@
----
-sidebar_position: 3
-title: "Android / Termux"
-description: "Run Hermes Agent directly on an Android phone with Termux"
----
-
-# Hermes on Android with Termux
-
-:::warning Tier 2 platform
-Termux (Android) is a [Tier 2 platform](./platform-support.md#tier-2). The installer script and documentation here are maintained on a best-effort basis only. Commits to `main` may break these packages at any point in time.
-:::
-
-Hermes Agent can run directly on an Android phone through [Termux](https://termux.dev/).
-
-It gives you a working local CLI on the phone, plus the core extras that are currently known to install cleanly on Android.
-
-## What is supported in the tested path?
-
-The tested Termux bundle installs:
-
-- the Hermes CLI
-- cron support
-- PTY/background terminal support
-- Telegram gateway support (manual / best-effort background runs)
-- MCP support
-- Honcho memory support
-- ACP support
-
-Concretely, it maps to:
-
-```bash
-python -m pip install -e '.[termux]' -c constraints-termux.txt
-```
-
-## What is not part of the tested path yet?
-
-A few features still need desktop/server-style dependencies that are not published for Android, or have not been validated on phones yet:
-
-- `.[all]` is not supported on Android today
-- the `voice` extra is blocked by `faster-whisper -> ctranslate2`, and `ctranslate2` does not publish Android wheels
-- automatic browser / Playwright bootstrap is skipped in the Termux installer
-- Docker-based terminal isolation is not available inside Termux
-- Android may still suspend Termux background jobs, so gateway persistence is best-effort rather than a normal managed service
-
-That does not stop Hermes from working well as a phone-native CLI agent — it just means the recommended mobile install is intentionally narrower than the desktop/server install.
-
----
-
-## Community-maintained native `pkg` option
-
-:::caution Contributor-operated distribution
-This APT repository is **community-maintained by `@adybag14-cyber` and is not an official NousResearch distribution**. NousResearch does not build, sign, host, or audit these packages. Enabling the repository means trusting the contributor-operated repository and its signing key. Termux itself remains a Tier 2 / best-effort platform.
-:::
-
-For users who prefer a native package-manager install rather than building Python/Rust dependencies on the phone, a community-maintained APT repository is available. The repository bootstrap and packaging sources are published in [`adybag14-cyber/termux-python`](https://github.com/adybag14-cyber/termux-python), with the Hermes package build in [`adybag14-cyber/termux-hermes`](https://github.com/adybag14-cyber/termux-hermes).
-
-Install the repository key/source and Hermes with:
-
-```bash
-curl -fsSL https://raw.githubusercontent.com/adybag14-cyber/termux-python/main/scripts/setup_apt_repo.sh | bash
-pkg install hermes-agent
-```
-
-The repository signing-key fingerprint currently documented by the community distribution is:
-
-```text
-EAD24A2124EFA7393A78B7B14699F966313F7A6B
-```
-
-APT-managed Hermes installs are marked with install method `apt`. Hermes therefore does not run its Git self-updater against package-owned files; use the package manager instead:
-
-```bash
-pkg update
-pkg upgrade hermes-agent
-```
-
-Packaging/repository/signing problems for this option should be reported to the community packaging repositories above. Hermes runtime bugs can still be reported here, keeping in mind that Android/Termux support is best-effort.
-
----
-
-## Option 1: One-line installer
-
-Hermes now ships a Termux-aware installer path:
-
-```bash
-curl -fsSL https://hermes-agent.nousresearch.com/install.sh | bash
-```
-
-On Termux, the installer automatically:
-
-- uses `pkg` for system packages
-- creates the venv with `python -m venv`
-- attempts the broad `.[termux-all]` extra first and falls back to the smaller `.[termux]` extra (then a base install) — the curl installer matches this order automatically
-- links `hermes` into `$PREFIX/bin` so it stays on your Termux PATH
-- skips the untested browser / WhatsApp bootstrap
-
-If you want the explicit commands or need to debug a failed install, use the manual path below.
-
----
-
-## Option 2: Manual install (fully explicit)
-
-### 1. Update Termux and install system packages
-
-```bash
-pkg update
-pkg install -y git python clang rust make pkg-config libffi openssl nodejs ripgrep ffmpeg
-```
-
-Why these packages?
-
-- `python` — runtime + venv support
-- `git` — clone/update the repo
-- `clang`, `rust`, `make`, `pkg-config`, `libffi`, `openssl` — needed to build a few Python dependencies on Android
-- `nodejs` — optional Node runtime for experiments beyond the tested core path
-- `ripgrep` — fast file search
-- `ffmpeg` — media / TTS conversions
-
-### 2. Clone Hermes
-
-```bash
-git clone https://github.com/NousResearch/hermes-agent.git
-cd hermes-agent
-```
-
-### 3. Create a virtual environment
-
-```bash
-python -m venv venv
-source venv/bin/activate
-export ANDROID_API_LEVEL="$(getprop ro.build.version.sdk)"
-python -m pip install --upgrade pip setuptools wheel
-```
-
-`ANDROID_API_LEVEL` is important for Rust / maturin-based packages such as `jiter`.
-
-### 4. Install the tested Termux bundle
-
-```bash
-python -m pip install -e '.[termux]' -c constraints-termux.txt
-```
-
-If you only want the minimal core agent, this also works:
-
-```bash
-python -m pip install -e '.' -c constraints-termux.txt
-```
-
-### 5. Put `hermes` on your Termux PATH
-
-```bash
-ln -sf "$PWD/venv/bin/hermes" "$PREFIX/bin/hermes"
-```
-
-`$PREFIX/bin` is already on PATH in Termux, so this makes the `hermes` command persist across new shells without re-activating the venv every time.
-
-### 6. Verify the install
-
-```bash
-hermes --version
-hermes doctor
-```
-
-### 7. Start Hermes
-
-```bash
-hermes
-```
-
----
-
-## Recommended follow-up setup
-
-### Configure a model
-
-```bash
-hermes model
-```
-
-Or set keys directly in `~/.hermes/.env`.
-
-### Re-run the full interactive setup wizard later
-
-```bash
-hermes setup
-```
-
-### Install optional Node dependencies manually
-
-The tested Termux path skips Node/browser bootstrap on purpose. If you want to experiment with browser tooling later, what you need depends on which backend you use:
-
-- **Cloud browser providers** (Browserbase, Browser Use, Firecrawl) host their own Chromium, so Node.js alone is enough — `agent-browser` resolves lazily via `npx agent-browser` on first use:
-
- ```bash
- pkg install nodejs-lts
- ```
-
-- **Local browser automation** on Termux needs a real `agent-browser` install — the bare npx fallback is deliberately rejected in local mode as too fragile to advertise as ready:
-
- ```bash
- pkg install nodejs-lts
- npm install -g agent-browser && agent-browser install
- ```
-
-The browser tool automatically includes Termux directories (`/data/data/com.termux/files/usr/bin`) in its PATH search, so `agent-browser` and `npx` are discovered without any extra PATH configuration.
-
-Treat browser / WhatsApp tooling on Android as experimental until documented otherwise.
-
----
-
-## Troubleshooting
-
-### `No solution found` when installing `.[all]`
-
-Use the tested Termux bundle instead:
-
-```bash
-python -m pip install -e '.[termux]' -c constraints-termux.txt
-```
-
-The blocker is currently the `voice` extra:
-
-- `voice` pulls `faster-whisper`
-- `faster-whisper` depends on `ctranslate2`
-- `ctranslate2` does not publish Android wheels
-
-### `uv pip install` fails on Android
-
-Use the Termux path with the stdlib venv + `pip` instead:
-
-```bash
-python -m venv venv
-source venv/bin/activate
-export ANDROID_API_LEVEL="$(getprop ro.build.version.sdk)"
-python -m pip install --upgrade pip setuptools wheel
-python -m pip install -e '.[termux]' -c constraints-termux.txt
-```
-
-### `jiter` / `maturin` complains about `ANDROID_API_LEVEL`
-
-Set the API level explicitly before installing:
-
-```bash
-export ANDROID_API_LEVEL="$(getprop ro.build.version.sdk)"
-python -m pip install -e '.[termux]' -c constraints-termux.txt
-```
-
-### `hermes doctor` says ripgrep or Node is missing
-
-Install them with Termux packages:
-
-```bash
-pkg install ripgrep nodejs
-```
-
-### Build failures while installing Python packages
-
-Make sure the build toolchain is installed:
-
-```bash
-pkg install clang rust make pkg-config libffi openssl
-```
-
-Then retry:
-
-```bash
-python -m pip install -e '.[termux]' -c constraints-termux.txt
-```
-
----
-
-## Known limitations on phones
-
-- Docker backend is unavailable
-- local voice transcription via `faster-whisper` is unavailable in the tested path
-- browser automation setup is intentionally skipped by the installer
-- some optional extras may work, but only `.[termux]` and `.[termux-all]` are currently documented as the tested Android bundles
-
-If you hit a new Android-specific issue, please open a GitHub issue with:
-
-- your Android version
-- `termux-info`
-- `python --version`
-- `hermes doctor`
-- the exact install command and full error output
diff --git a/website/docs/index.mdx b/website/docs/index.mdx
index a4f248e8ae..3b7ad423f4 100644
--- a/website/docs/index.mdx
+++ b/website/docs/index.mdx
@@ -83,7 +83,7 @@ To easily install the command-line and desktop applications, [download the Herme
For a command-line only install without Hermes Desktop, run:
-#### Linux / macOS / WSL2 / Android (Termux)
+#### Linux / macOS / WSL2
```bash
curl -fsSL https://hermes-agent.nousresearch.com/install.sh | bash
diff --git a/website/docs/reference/cli-commands.md b/website/docs/reference/cli-commands.md
index 06bce66f8f..310d5979d0 100644
--- a/website/docs/reference/cli-commands.md
+++ b/website/docs/reference/cli-commands.md
@@ -243,7 +243,7 @@ Subcommands:
| Subcommand | Description |
|------------|-------------|
-| `run` | Run the gateway in the foreground. Recommended for WSL, Docker, and Termux. |
+| `run` | Run the gateway in the foreground. Recommended for WSL and Docker. |
| `start` | Start the installed systemd/launchd background service. |
| `stop` | Stop the service (or foreground process). |
| `restart` | Restart the service. |
diff --git a/website/docs/reference/faq.md b/website/docs/reference/faq.md
index fc199a509b..19bd27e4d4 100644
--- a/website/docs/reference/faq.md
+++ b/website/docs/reference/faq.md
@@ -28,7 +28,7 @@ Hermes Agent works with any OpenAI-compatible API. Supported providers include:
Set your provider with `hermes model` or by editing `~/.hermes/.env`. See the [Environment Variables](./environment-variables.md) reference for all provider keys.
-### Does it work on Windows/Android/Termux/my plataform??
+### Does it work on Windows/Android/my platform??
See **[Platform Support](../getting-started/platform-support.md)** for the full platform availability matrix.
### I run Hermes in WSL2. What's the best way to control my normal Windows Chrome?
diff --git a/website/docs/user-guide/windows-native.md b/website/docs/user-guide/windows-native.md
index f703dfe286..82ee8d6468 100644
--- a/website/docs/user-guide/windows-native.md
+++ b/website/docs/user-guide/windows-native.md
@@ -319,7 +319,7 @@ If you edited Hermes config or a skill on Windows using a non-UTF-8 editor (Note
## Where to go next
-- **[Installation](../getting-started/installation.md)** — the full install page, including Linux/macOS/WSL2/Termux.
+- **[Installation](../getting-started/installation.md)** — the full install page, including Linux/macOS/WSL2.
- **[Windows (WSL2) Guide](./windows-wsl-quickstart.md)** — if you want POSIX semantics or the dashboard terminal pane.
- **[CLI Reference](../reference/cli-commands.md)** — every `hermes` subcommand.
- **[FAQ](../reference/faq.md)** — common non-Windows-specific questions.
diff --git a/website/docs/user-guide/windows-wsl-quickstart.md b/website/docs/user-guide/windows-wsl-quickstart.md
index 2128b3be91..c45a7c22b6 100644
--- a/website/docs/user-guide/windows-wsl-quickstart.md
+++ b/website/docs/user-guide/windows-wsl-quickstart.md
@@ -352,7 +352,7 @@ WSL2 stores its VM disk as a sparse VHDX under `%LOCALAPPDATA%\Packages\...`. It
## Where to go next
-- **[Installation](/getting-started/installation)** — actual install steps (Linux/WSL2/Termux all use the same installer).
+- **[Installation](/getting-started/installation)** — actual install steps (Linux/WSL2 use the same installer).
- **[Integrations → Providers → WSL2 Networking](/integrations/providers#wsl2-networking-windows-users)** — the canonical networking deep-dive for local model servers.
- **[MCP guide → WSL → Windows Chrome](/guides/use-mcp-with-hermes#wsl2-bridge-hermes-in-wsl-to-windows-chrome)** — controlling your signed-in Windows Chrome from Hermes in WSL.
- **[Tool Gateway](/user-guide/features/tool-gateway)** and **[Web Dashboard](/user-guide/features/web-dashboard)** — the long-lived services you'll most often want to expose from WSL to the rest of your network.
diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/getting-started/installation.md b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/getting-started/installation.md
index 87804be07c..4d81dd1f2b 100644
--- a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/getting-started/installation.md
+++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/getting-started/installation.md
@@ -1,7 +1,7 @@
---
sidebar_position: 2
title: "安装"
-description: "在 Linux、macOS、WSL2、原生 Windows 或通过 Termux 在 Android 上安装 Hermes Agent"
+description: "在 Linux、macOS、WSL2 或原生 Windows 上安装 Hermes Agent"
---
# 安装
@@ -45,21 +45,7 @@ iex (irm https://hermes-agent.nousresearch.com/install.ps1)
### Android / Termux
-Hermes 现在也提供 Termux 感知的安装路径:
-
-```bash
-curl -fsSL https://hermes-agent.nousresearch.com/install.sh | bash
-```
-
-安装程序会自动检测 Termux 并切换到经过测试的 Android 流程:
-
-- 使用 Termux `pkg` 安装系统依赖(`git`、`python`、`nodejs`、`ripgrep`、`ffmpeg`、构建工具)
-- 使用 `python -m venv` 创建虚拟环境
-- 自动导出 `ANDROID_API_LEVEL` 以用于 Android wheel 构建
-- 优先使用较宽泛的 `.[termux-all]` extra,若首次编译失败则回退到较小的 `.[termux]` extra(最终回退到基础安装)
-- 默认跳过未经测试的浏览器 / WhatsApp 引导
-
-如需完整的显式步骤,请参阅专门的 [Termux 指南](./termux.md)。
+Hermes 不再支持 Android 或 Termux。请参阅[平台支持页面](./platform-support.md)了解支持的平台。
:::note Windows 功能对等性
diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/getting-started/quickstart.md b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/getting-started/quickstart.md
index 135e5ee579..5bf2fcd5db 100644
--- a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/getting-started/quickstart.md
+++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/getting-started/quickstart.md
@@ -57,16 +57,12 @@ description: "与 Hermes Agent 的第一次对话——从安装到开始聊天
仅安装命令行版本(跟踪 main 分支):
```bash
-# Linux / macOS / WSL2 / Android (Termux)
+# Linux / macOS / WSL2
curl -fsSL https://hermes-agent.nousresearch.com/install.sh | bash
```
安装脚本会在 `~/.hermes/hermes-agent` 创建一个受管理的隔离环境(独立的 uv 托管解释器和 venv),这是唯一受支持的安装方式 —— 包括开发用途。请勿使用 `pip install hermes-agent`。
-:::tip Android / Termux
-如果你在手机上安装,请参阅专门的 [Termux 指南](./termux.md),其中包含经过测试的手动安装步骤、支持的扩展功能以及当前 Android 特有的限制。
-:::
-
:::tip Windows 用户
请先安装 [WSL2](https://learn.microsoft.com/en-us/windows/wsl/install),然后在 WSL2 终端中运行上述命令。
:::
diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/getting-started/termux.md b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/getting-started/termux.md
deleted file mode 100644
index 57d051280b..0000000000
--- a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/getting-started/termux.md
+++ /dev/null
@@ -1,236 +0,0 @@
----
-sidebar_position: 3
-title: "Android / Termux"
-description: "通过 Termux 在 Android 手机上直接运行 Hermes Agent"
----
-
-# 在 Android 上通过 Termux 运行 Hermes
-
-这是在 Android 手机上通过 [Termux](https://termux.dev/) 直接运行 Hermes Agent 的已验证路径。
-
-它为你提供手机上可用的本地 CLI,以及目前已知可在 Android 上干净安装的核心扩展功能。
-
-## 已验证路径支持哪些功能?
-
-已验证的 Termux 安装包含:
-- Hermes CLI
-- cron 支持
-- PTY(伪终端)/后台终端支持
-- Telegram gateway 支持(手动 / 尽力而为的后台运行)
-- MCP 支持
-- Honcho 记忆支持
-- ACP 支持
-
-具体对应以下命令:
-
-```bash
-python -m pip install -e '.[termux]' -c constraints-termux.txt
-```
-
-## 哪些功能尚未纳入已验证路径?
-
-部分功能仍依赖桌面/服务器风格的依赖项,这些依赖项尚未为 Android 发布,或尚未在手机上验证:
-
-- `.[all]` 目前不支持 Android
-- `voice` 扩展被 `faster-whisper -> ctranslate2` 阻塞,`ctranslate2` 未发布 Android wheel 包
-- 自动浏览器 / Playwright 引导在 Termux 安装程序中被跳过
-- 基于 Docker 的终端隔离在 Termux 内不可用
-- Android 可能仍会挂起 Termux 后台任务,因此 gateway 持久化是尽力而为,而非正常的托管服务
-
-这并不妨碍 Hermes 作为手机原生 CLI agent 正常工作——只是意味着推荐的移动端安装有意比桌面/服务器安装更精简。
-
----
-
-## 方式一:一行安装命令
-
-Hermes 现已内置 Termux 感知的安装路径:
-
-```bash
-curl -fsSL https://hermes-agent.nousresearch.com/install.sh | bash
-```
-
-在 Termux 上,安装程序会自动:
-- 使用 `pkg` 安装系统包
-- 使用 `python -m venv` 创建虚拟环境
-- 优先尝试较大的 `.[termux-all]` 扩展,失败后回退到较小的 `.[termux]` 扩展(再次失败则进行基础安装)——curl 安装程序自动按此顺序执行
-- 将 `hermes` 链接到 `$PREFIX/bin`,使其保留在 Termux PATH 中
-- 跳过未经验证的浏览器 / WhatsApp 引导
-
-如果你需要显式命令或需要调试失败的安装,请使用下方的手动安装路径。
-
----
-
-## 方式二:手动安装(完全显式)
-
-### 1. 更新 Termux 并安装系统包
-
-```bash
-pkg update
-pkg install -y git python clang rust make pkg-config libffi openssl nodejs ripgrep ffmpeg
-```
-
-各包用途说明:
-- `python` — 运行时 + 虚拟环境支持
-- `git` — 克隆/更新仓库
-- `clang`、`rust`、`make`、`pkg-config`、`libffi`、`openssl` — 在 Android 上构建部分 Python 依赖所需
-- `nodejs` — 可选的 Node 运行时,用于已验证核心路径之外的实验
-- `ripgrep` — 快速文件搜索
-- `ffmpeg` — 媒体 / TTS 转换
-
-### 2. 克隆 Hermes
-
-```bash
-git clone https://github.com/NousResearch/hermes-agent.git
-cd hermes-agent
-```
-
-### 3. 创建虚拟环境
-
-```bash
-python -m venv venv
-source venv/bin/activate
-export ANDROID_API_LEVEL="$(getprop ro.build.version.sdk)"
-python -m pip install --upgrade pip setuptools wheel
-```
-
-`ANDROID_API_LEVEL` 对于基于 Rust / maturin 的包(如 `jiter`)非常重要。
-
-### 4. 安装已验证的 Termux 包
-
-```bash
-python -m pip install -e '.[termux]' -c constraints-termux.txt
-```
-
-如果你只需要最小化的核心 agent,以下命令同样有效:
-
-```bash
-python -m pip install -e '.' -c constraints-termux.txt
-```
-
-### 5. 将 `hermes` 添加到 Termux PATH
-
-```bash
-ln -sf "$PWD/venv/bin/hermes" "$PREFIX/bin/hermes"
-```
-
-`$PREFIX/bin` 在 Termux 中已默认在 PATH 中,因此这样做可以让 `hermes` 命令在新 shell 中持续可用,无需每次重新激活虚拟环境。
-
-### 6. 验证安装
-
-```bash
-hermes --version
-hermes doctor
-```
-
-### 7. 启动 Hermes
-
-```bash
-hermes
-```
-
----
-
-## 推荐的后续配置
-
-### 配置模型
-
-```bash
-hermes model
-```
-
-或直接在 `~/.hermes/.env` 中设置密钥。
-
-### 稍后重新运行完整的交互式设置向导
-
-```bash
-hermes setup
-```
-
-### 手动安装可选的 Node 依赖
-
-已验证的 Termux 路径有意跳过 Node/浏览器引导。如果你之后想尝试浏览器工具:
-
-```bash
-pkg install nodejs-lts
-npm install
-```
-
-浏览器工具会自动将 Termux 目录(`/data/data/com.termux/files/usr/bin`)纳入 PATH 搜索,因此无需额外配置 PATH 即可发现 `agent-browser` 和 `npx`。
-
-在另有文档说明之前,请将 Android 上的浏览器 / WhatsApp 工具视为实验性功能。
-
----
-
-## 故障排查
-
-### 安装 `.[all]` 时出现 `No solution found`
-
-改用已验证的 Termux 包:
-
-```bash
-python -m pip install -e '.[termux]' -c constraints-termux.txt
-```
-
-当前阻塞原因是 `voice` 扩展:
-- `voice` 依赖 `faster-whisper`
-- `faster-whisper` 依赖 `ctranslate2`
-- `ctranslate2` 未发布 Android wheel 包
-
-### `uv pip install` 在 Android 上失败
-
-改用标准库 venv + `pip` 的 Termux 路径:
-
-```bash
-python -m venv venv
-source venv/bin/activate
-export ANDROID_API_LEVEL="$(getprop ro.build.version.sdk)"
-python -m pip install --upgrade pip setuptools wheel
-python -m pip install -e '.[termux]' -c constraints-termux.txt
-```
-
-### `jiter` / `maturin` 报错提示缺少 `ANDROID_API_LEVEL`
-
-在安装前显式设置 API 级别:
-
-```bash
-export ANDROID_API_LEVEL="$(getprop ro.build.version.sdk)"
-python -m pip install -e '.[termux]' -c constraints-termux.txt
-```
-
-### `hermes doctor` 提示缺少 ripgrep 或 Node
-
-使用 Termux 包安装:
-
-```bash
-pkg install ripgrep nodejs
-```
-
-### 安装 Python 包时构建失败
-
-确保已安装构建工具链:
-
-```bash
-pkg install clang rust make pkg-config libffi openssl
-```
-
-然后重试:
-
-```bash
-python -m pip install -e '.[termux]' -c constraints-termux.txt
-```
-
----
-
-## 手机上的已知限制
-
-- Docker 后端不可用
-- 通过 `faster-whisper` 进行的本地语音转录在已验证路径中不可用
-- 安装程序有意跳过浏览器自动化配置
-- 部分可选扩展可能可用,但目前仅 `.[termux]` 和 `.[termux-all]` 被记录为已验证的 Android 安装包
-
-如果你遇到新的 Android 特定问题,请在 GitHub 上提交 issue,并附上:
-- 你的 Android 版本
-- `termux-info`
-- `python --version`
-- `hermes doctor`
-- 确切的安装命令及完整错误输出
\ No newline at end of file
diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/index.mdx b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/index.mdx
index b4f7515680..a7471c6d7d 100644
--- a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/index.mdx
+++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/index.mdx
@@ -63,8 +63,6 @@ curl -fsSL https://hermes-agent.nousresearch.com/install.sh | bash
iex (irm https://hermes-agent.nousresearch.com/install.ps1)
```
-**Android(Termux)** — 与 Linux 相同的 curl 一行命令;安装程序会自动检测 Termux。
-
请参阅完整的 **[安装指南](/getting-started/installation)**,了解安装程序的具体操作、按用户与 root 的目录布局以及 Windows 相关说明。
## Hermes Agent 是什么?
diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/reference/cli-commands.md b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/reference/cli-commands.md
index 70611ad70b..756d474c43 100644
--- a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/reference/cli-commands.md
+++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/reference/cli-commands.md
@@ -209,7 +209,7 @@ hermes gateway
| 子命令 | 说明 |
|------------|-------------|
-| `run` | 在前台运行 gateway。推荐用于 WSL、Docker 和 Termux。 |
+| `run` | 在前台运行 gateway。推荐用于 WSL 和 Docker。 |
| `start` | 启动已安装的 systemd/launchd 后台服务。 |
| `stop` | 停止服务(或前台进程)。 |
| `restart` | 重启服务。 |
diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/reference/faq.md b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/reference/faq.md
index e1c39b9b1f..6260153094 100644
--- a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/reference/faq.md
+++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/reference/faq.md
@@ -54,19 +54,9 @@ curl -fsSL https://hermes-agent.nousresearch.com/install.sh | bash
- [在 Hermes 中使用 MCP](../guides/use-mcp-with-hermes.md#wsl2-bridge-hermes-in-wsl-to-windows-chrome)
- [浏览器自动化](../user-guide/features/browser.md#wsl2--windows-chrome-prefer-mcp-over-browser-connect)
-### 支持 Android / Termux 吗?
+### 支持 Android 吗?
-支持 — Hermes 现已为 Android 手机提供经过测试的 Termux 安装路径。
-
-快速安装:
-
-```bash
-curl -fsSL https://hermes-agent.nousresearch.com/install.sh | bash
-```
-
-完整的手动步骤、支持的扩展及当前限制,请参阅 [Termux 指南](../getting-started/termux.md)。
-
-重要说明:完整的 `.[all]` 扩展目前在 Android 上不可用,因为 `voice` 扩展依赖 `faster-whisper` → `ctranslate2`,而 `ctranslate2` 未发布 Android wheel 包。请改用经过测试的 `.[termux]` 扩展。
+不支持 — Hermes 已移除 Android 和 Termux 支持。请参阅[平台支持页面](../getting-started/platform-support.md)了解支持的平台。
### 我的数据会被发送到哪里?
diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/windows-native.md b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/windows-native.md
index 89555b02cb..205d347e74 100644
--- a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/windows-native.md
+++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/windows-native.md
@@ -318,7 +318,7 @@ UTF-8 stdio 垫片未激活。检查 `HERMES_DISABLE_WINDOWS_UTF8` 是否**未**
## 下一步
-- **[安装](../getting-started/installation.md)** — 完整安装页面,包括 Linux/macOS/WSL2/Termux。
+- **[安装](../getting-started/installation.md)** — 完整安装页面,包括 Linux/macOS/WSL2。
- **[Windows(WSL2)指南](./windows-wsl-quickstart.md)** — 如果你需要 POSIX 语义或 dashboard 终端面板。
- **[CLI 参考](../reference/cli-commands.md)** — 所有 `hermes` 子命令。
- **[FAQ](../reference/faq.md)** — 常见的非 Windows 专属问题。
diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/windows-wsl-quickstart.md b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/windows-wsl-quickstart.md
index 7b108b8f89..0a4d8075d9 100644
--- a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/windows-wsl-quickstart.md
+++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/windows-wsl-quickstart.md
@@ -326,7 +326,7 @@ WSL2 将虚拟机磁盘存储为 `%LOCALAPPDATA%\Packages\...` 下的稀疏 VHDX
## 下一步
-- **[安装说明](/getting-started/installation)** —— 实际安装步骤(Linux/WSL2/Termux 均使用同一安装程序)。
+- **[安装说明](/getting-started/installation)** —— 实际安装步骤(Linux/WSL2 使用同一安装程序)。
- **[集成 → Providers → WSL2 网络配置](/integrations/providers#wsl2-networking-windows-users)** —— 本地模型服务器网络配置的权威深度说明。
- **[MCP 指南 → WSL → Windows Chrome](/guides/use-mcp-with-hermes#wsl2-bridge-hermes-in-wsl-to-windows-chrome)** —— 从 WSL 中的 Hermes 控制你已登录的 Windows Chrome。
- **[Tool Gateway](/user-guide/features/tool-gateway)** 和 **[Web Dashboard](/user-guide/features/web-dashboard)** —— 你最常需要从 WSL 暴露到网络其他部分的长期运行服务。
\ No newline at end of file
diff --git a/website/scripts/generate-llms-txt.py b/website/scripts/generate-llms-txt.py
index 91caeb4ad3..0f93ccff86 100644
--- a/website/scripts/generate-llms-txt.py
+++ b/website/scripts/generate-llms-txt.py
@@ -52,7 +52,6 @@ SECTIONS: list[tuple[str, list[tuple[str, str, str | None]]]] = [
("getting-started/quickstart", "Quickstart", None),
("getting-started/learning-path", "Learning Path", None),
("getting-started/updating", "Updating", None),
- ("getting-started/termux", "Termux (Android)", None),
("getting-started/nix-setup", "Nix Setup", None),
]),
("Using Hermes", [
@@ -308,7 +307,7 @@ def emit_llms_index() -> str:
lines.append(
"Install: `curl -fsSL https://raw.githubusercontent.com/NousResearch/"
"hermes-agent/main/scripts/install.sh | bash` "
- "(Linux, macOS, WSL2, Termux)"
+ "(Linux, macOS, WSL2)"
)
lines.append("")
lines.append("Repo: https://github.com/NousResearch/hermes-agent")
diff --git a/website/sidebars.ts b/website/sidebars.ts
index fbbadcec1f..acf4baed8f 100644
--- a/website/sidebars.ts
+++ b/website/sidebars.ts
@@ -11,7 +11,6 @@ const sidebars: SidebarsConfig = {
'getting-started/quickstart',
'getting-started/installation',
'getting-started/platform-support',
- 'getting-started/termux',
'getting-started/nix-setup',
'getting-started/updating',
'getting-started/learning-path',