feat: add tested Termux install path and EOF-aware gh auth

This commit is contained in:
adybag14-cyber
2026-04-08 17:48:25 +02:00
committed by Teknium
parent e053433c84
commit e79cc88985
15 changed files with 724 additions and 23 deletions

View File

@@ -33,8 +33,10 @@ Use any model you want — [Nous Portal](https://portal.nousresearch.com), [Open
curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh | bash
```
Works on Linux, macOS, and WSL2. The installer handles everything — Python, Node.js, dependencies, and the `hermes` command. No prerequisites except git.
Works on Linux, macOS, WSL2, and Android via Termux. The installer handles the platform-specific setup for you.
> **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.
>
> **Windows:** Native Windows is not supported. Please install [WSL2](https://learn.microsoft.com/en-us/windows/wsl/install) and run the command above.
After installation:

15
constraints-termux.txt Normal file
View File

@@ -0,0 +1,15 @@
# Termux / Android dependency constraints for Hermes Agent.
#
# Usage:
# python -m pip install -e '.[termux]' -c constraints-termux.txt
#
# These pins keep the tested Android install path stable when upstream packages
# move faster than Termux-compatible wheels / sdists.
ipython<10
jedi>=0.18.1,<0.20
parso>=0.8.4,<0.9
stack-data>=0.6,<0.7
pexpect>4.3,<5
matplotlib-inline>=0.1.7,<0.2
asttokens>=2.1,<3

View File

@@ -54,6 +54,23 @@ _PROVIDER_ENV_HINTS = (
)
def _is_termux() -> bool:
prefix = os.getenv("PREFIX", "")
return bool(os.getenv("TERMUX_VERSION") or "com.termux/files/usr" in prefix)
def _python_install_cmd() -> str:
return "python -m pip install" if _is_termux() else "uv pip install"
def _system_package_install_cmd(pkg: str) -> str:
if _is_termux():
return f"pkg install {pkg}"
if sys.platform == "darwin":
return f"brew install {pkg}"
return f"sudo apt install {pkg}"
def _has_provider_env_config(content: str) -> bool:
"""Return True when ~/.hermes/.env contains provider auth/base URL settings."""
return any(key in content for key in _PROVIDER_ENV_HINTS)
@@ -200,7 +217,7 @@ def run_doctor(args):
check_ok(name)
except ImportError:
check_fail(name, "(missing)")
issues.append(f"Install {name}: uv pip install {module}")
issues.append(f"Install {name}: {_python_install_cmd()} {module}")
for module, name in optional_packages:
try:
@@ -503,7 +520,7 @@ def run_doctor(args):
check_ok("ripgrep (rg)", "(faster file search)")
else:
check_warn("ripgrep (rg) not found", "(file search uses grep fallback)")
check_info("Install for faster search: sudo apt install ripgrep")
check_info(f"Install for faster search: {_system_package_install_cmd('ripgrep')}")
# Docker (optional)
terminal_env = os.getenv("TERMINAL_ENV", "local")
@@ -577,6 +594,8 @@ def run_doctor(args):
check_warn("agent-browser not installed", "(run: npm install)")
else:
check_warn("Node.js not found", "(optional, needed for browser tools)")
if _is_termux():
check_info("Install Node.js on Termux with: pkg install nodejs")
# npm audit for all Node.js packages
if shutil.which("npm"):
@@ -739,8 +758,9 @@ def run_doctor(args):
__import__("tinker_atropos")
check_ok("tinker-atropos", "(RL training backend)")
except ImportError:
check_warn("tinker-atropos found but not installed", "(run: uv pip install -e ./tinker-atropos)")
issues.append("Install tinker-atropos: uv pip install -e ./tinker-atropos")
install_cmd = f"{_python_install_cmd()} -e ./tinker-atropos"
check_warn("tinker-atropos found but not installed", f"(run: {install_cmd})")
issues.append(f"Install tinker-atropos: {install_cmd}")
else:
check_warn("tinker-atropos requires Python 3.11+", f"(current: {py_version.major}.{py_version.minor})")
else:

View File

@@ -63,6 +63,17 @@ homeassistant = ["aiohttp>=3.9.0,<4"]
sms = ["aiohttp>=3.9.0,<4"]
acp = ["agent-client-protocol>=0.9.0,<1.0"]
mistral = ["mistralai>=2.3.0,<3"]
termux = [
# Tested Android / Termux path: keeps the core CLI feature-rich while
# avoiding extras that currently depend on non-Android wheels (notably
# faster-whisper -> ctranslate2 via the voice extra).
"hermes-agent[cron]",
"hermes-agent[cli]",
"hermes-agent[pty]",
"hermes-agent[mcp]",
"hermes-agent[honcho]",
"hermes-agent[acp]",
]
dingtalk = ["dingtalk-stream>=0.1.0,<1"]
feishu = ["lark-oapi>=1.5.3,<2"]
rl = [

View File

@@ -2,8 +2,8 @@
# ============================================================================
# Hermes Agent Installer
# ============================================================================
# Installation script for Linux and macOS.
# Uses uv for fast Python provisioning and package management.
# Installation script for Linux, macOS, and Android/Termux.
# Uses uv for desktop/server installs and Python's stdlib venv + pip on Termux.
#
# Usage:
# curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh | bash
@@ -117,6 +117,10 @@ log_error() {
echo -e "${RED}${NC} $1"
}
is_termux() {
[ -n "${TERMUX_VERSION:-}" ] || [[ "${PREFIX:-}" == *"com.termux/files/usr"* ]]
}
# ============================================================================
# System detection
# ============================================================================
@@ -124,12 +128,17 @@ log_error() {
detect_os() {
case "$(uname -s)" in
Linux*)
OS="linux"
if [ -f /etc/os-release ]; then
. /etc/os-release
DISTRO="$ID"
if is_termux; then
OS="android"
DISTRO="termux"
else
DISTRO="unknown"
OS="linux"
if [ -f /etc/os-release ]; then
. /etc/os-release
DISTRO="$ID"
else
DISTRO="unknown"
fi
fi
;;
Darwin*)
@@ -158,6 +167,12 @@ detect_os() {
# ============================================================================
install_uv() {
if [ "$DISTRO" = "termux" ]; then
log_info "Termux detected — using Python's stdlib venv + pip instead of uv"
UV_CMD=""
return 0
fi
log_info "Checking for uv package manager..."
# Check common locations for uv
@@ -209,6 +224,25 @@ install_uv() {
}
check_python() {
if [ "$DISTRO" = "termux" ]; then
log_info "Checking Termux Python..."
if command -v python >/dev/null 2>&1; then
PYTHON_PATH="$(command -v python)"
if "$PYTHON_PATH" -c 'import sys; raise SystemExit(0 if sys.version_info >= (3, 11) else 1)' 2>/dev/null; then
PYTHON_FOUND_VERSION=$($PYTHON_PATH --version 2>/dev/null)
log_success "Python found: $PYTHON_FOUND_VERSION"
return 0
fi
fi
log_info "Installing Python via pkg..."
pkg install -y python >/dev/null
PYTHON_PATH="$(command -v python)"
PYTHON_FOUND_VERSION=$($PYTHON_PATH --version 2>/dev/null)
log_success "Python installed: $PYTHON_FOUND_VERSION"
return 0
fi
log_info "Checking Python $PYTHON_VERSION..."
# Let uv handle Python — it can download and manage Python versions
@@ -243,6 +277,17 @@ check_git() {
fi
log_error "Git not found"
if [ "$DISTRO" = "termux" ]; then
log_info "Installing Git via pkg..."
pkg install -y git >/dev/null
if command -v git >/dev/null 2>&1; then
GIT_VERSION=$(git --version | awk '{print $3}')
log_success "Git $GIT_VERSION installed"
return 0
fi
fi
log_info "Please install Git:"
case "$OS" in
@@ -262,6 +307,9 @@ check_git() {
;;
esac
;;
android)
log_info " pkg install git"
;;
macos)
log_info " xcode-select --install"
log_info " Or: brew install git"
@@ -290,11 +338,29 @@ check_node() {
return 0
fi
log_info "Node.js not found — installing Node.js $NODE_VERSION LTS..."
if [ "$DISTRO" = "termux" ]; then
log_info "Node.js not found — installing Node.js via pkg..."
else
log_info "Node.js not found — installing Node.js $NODE_VERSION LTS..."
fi
install_node
}
install_node() {
if [ "$DISTRO" = "termux" ]; then
log_info "Installing Node.js via pkg..."
if pkg install -y nodejs >/dev/null; then
local installed_ver
installed_ver=$(node --version 2>/dev/null)
log_success "Node.js $installed_ver installed via pkg"
HAS_NODE=true
else
log_warn "Failed to install Node.js via pkg"
HAS_NODE=false
fi
return 0
fi
local arch=$(uname -m)
local node_arch
case "$arch" in
@@ -413,6 +479,30 @@ install_system_packages() {
need_ffmpeg=true
fi
# Termux always needs the Android build toolchain for the tested pip path,
# even when ripgrep/ffmpeg are already present.
if [ "$DISTRO" = "termux" ]; then
local termux_pkgs=(clang rust make pkg-config libffi openssl)
if [ "$need_ripgrep" = true ]; then
termux_pkgs+=("ripgrep")
fi
if [ "$need_ffmpeg" = true ]; then
termux_pkgs+=("ffmpeg")
fi
log_info "Installing Termux packages: ${termux_pkgs[*]}"
if pkg install -y "${termux_pkgs[@]}" >/dev/null; then
[ "$need_ripgrep" = true ] && HAS_RIPGREP=true && log_success "ripgrep installed"
[ "$need_ffmpeg" = true ] && HAS_FFMPEG=true && log_success "ffmpeg installed"
log_success "Termux build dependencies installed"
return 0
fi
log_warn "Could not auto-install all Termux packages"
log_info "Install manually: pkg install ${termux_pkgs[*]}"
return 0
fi
# Nothing to install — done
if [ "$need_ripgrep" = false ] && [ "$need_ffmpeg" = false ]; then
return 0
@@ -550,6 +640,9 @@ show_manual_install_hint() {
*) log_info " Use your package manager or visit the project homepage" ;;
esac
;;
android)
log_info " pkg install $pkg"
;;
macos) log_info " brew install $pkg" ;;
esac
}
@@ -646,6 +739,19 @@ setup_venv() {
return 0
fi
if [ "$DISTRO" = "termux" ]; then
log_info "Creating virtual environment with Termux Python..."
if [ -d "venv" ]; then
log_info "Virtual environment already exists, recreating..."
rm -rf venv
fi
"$PYTHON_PATH" -m venv venv
log_success "Virtual environment ready ($(./venv/bin/python --version 2>/dev/null))"
return 0
fi
log_info "Creating virtual environment with Python $PYTHON_VERSION..."
if [ -d "venv" ]; then
@@ -662,6 +768,46 @@ setup_venv() {
install_deps() {
log_info "Installing dependencies..."
if [ "$DISTRO" = "termux" ]; then
if [ "$USE_VENV" = true ]; then
export VIRTUAL_ENV="$INSTALL_DIR/venv"
PIP_PYTHON="$INSTALL_DIR/venv/bin/python"
else
PIP_PYTHON="$PYTHON_PATH"
fi
if [ -z "${ANDROID_API_LEVEL:-}" ]; then
ANDROID_API_LEVEL="$(getprop ro.build.version.sdk 2>/dev/null || true)"
if [ -z "$ANDROID_API_LEVEL" ]; then
ANDROID_API_LEVEL=24
fi
export ANDROID_API_LEVEL
log_info "Using ANDROID_API_LEVEL=$ANDROID_API_LEVEL for Android wheel builds"
fi
"$PIP_PYTHON" -m pip install --upgrade pip setuptools wheel >/dev/null
if ! "$PIP_PYTHON" -m pip install -e '.[termux]' -c constraints-termux.txt; then
log_warn "Termux feature install (.[termux]) failed, trying base install..."
if ! "$PIP_PYTHON" -m pip install -e '.' -c constraints-termux.txt; then
log_error "Package installation failed on Termux."
log_info "Ensure these packages are installed: pkg install clang rust make pkg-config libffi openssl"
log_info "Then re-run: cd $INSTALL_DIR && python -m pip install -e '.[termux]' -c constraints-termux.txt"
exit 1
fi
fi
log_success "Main package installed"
log_info "Termux note: browser/WhatsApp tooling is not installed by default; see the Termux guide for optional follow-up steps."
if [ -d "tinker-atropos" ] && [ -f "tinker-atropos/pyproject.toml" ]; then
log_info "tinker-atropos submodule found — skipping install (optional, for RL training)"
log_info " To install later: $PIP_PYTHON -m pip install -e \"./tinker-atropos\""
fi
log_success "All dependencies installed"
return 0
fi
if [ "$USE_VENV" = true ]; then
# Tell uv to install into our venv (no need to activate)
export VIRTUAL_ENV="$INSTALL_DIR/venv"
@@ -743,7 +889,11 @@ setup_path() {
if [ ! -x "$HERMES_BIN" ]; then
log_warn "hermes entry point not found at $HERMES_BIN"
log_info "This usually means the pip install didn't complete successfully."
log_info "Try: cd $INSTALL_DIR && uv pip install -e '.[all]'"
if [ "$DISTRO" = "termux" ]; then
log_info "Try: cd $INSTALL_DIR && python -m pip install -e '.[termux]' -c constraints-termux.txt"
else
log_info "Try: cd $INSTALL_DIR && uv pip install -e '.[all]'"
fi
return 0
fi
@@ -878,6 +1028,13 @@ install_node_deps() {
return 0
fi
if [ "$DISTRO" = "termux" ]; then
log_info "Skipping automatic Node/browser dependency setup on Termux"
log_info "Browser automation and WhatsApp bridge are not part of the tested Termux install path yet."
log_info "If you want to experiment manually later, run: cd $INSTALL_DIR && npm install"
return 0
fi
if [ -f "$INSTALL_DIR/package.json" ]; then
log_info "Installing Node.js dependencies (browser tools)..."
cd "$INSTALL_DIR"
@@ -1090,7 +1247,11 @@ print_success() {
echo -e "${YELLOW}"
echo "Note: Node.js could not be installed automatically."
echo "Browser tools need Node.js. Install manually:"
echo " https://nodejs.org/en/download/"
if [ "$DISTRO" = "termux" ]; then
echo " pkg install nodejs"
else
echo " https://nodejs.org/en/download/"
fi
echo -e "${NC}"
fi
@@ -1099,7 +1260,11 @@ print_success() {
echo -e "${YELLOW}"
echo "Note: ripgrep (rg) was not found. File search will use"
echo "grep as a fallback. For faster search in large codebases,"
echo "install ripgrep: sudo apt install ripgrep (or brew install ripgrep)"
if [ "$DISTRO" = "termux" ]; then
echo "install ripgrep: pkg install ripgrep"
else
echo "install ripgrep: sudo apt install ripgrep (or brew install ripgrep)"
fi
echo -e "${NC}"
fi
}

View File

@@ -14,6 +14,23 @@ from hermes_cli import doctor as doctor_mod
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_non_termux_package_hint_defaults_to_apt(self, monkeypatch):
monkeypatch.delenv("TERMUX_VERSION", raising=False)
monkeypatch.setenv("PREFIX", "/usr")
monkeypatch.setattr(sys, "platform", "linux")
assert doctor._is_termux() is False
assert doctor._python_install_cmd() == "uv pip install"
assert doctor._system_package_install_cmd("ripgrep") == "sudo apt install ripgrep"
class TestProviderEnvDetection:
def test_detects_openai_api_key(self):
content = "OPENAI_BASE_URL=http://localhost:1234/v1\nOPENAI_API_KEY=***"

View File

@@ -135,6 +135,64 @@ class TestReadLog:
assert "5 lines" in result["showing"]
# =========================================================================
# Stdin helpers
# =========================================================================
class TestStdinHelpers:
def test_close_stdin_not_found(self, registry):
result = registry.close_stdin("nonexistent")
assert result["status"] == "not_found"
def test_close_stdin_pipe_mode(self, registry):
proc = MagicMock()
proc.stdin = MagicMock()
s = _make_session()
s.process = proc
registry._running[s.id] = s
result = registry.close_stdin(s.id)
proc.stdin.close.assert_called_once()
assert result["status"] == "ok"
def test_close_stdin_pty_mode(self, registry):
pty = MagicMock()
s = _make_session()
s._pty = pty
registry._running[s.id] = s
result = registry.close_stdin(s.id)
pty.sendeof.assert_called_once()
assert result["status"] == "ok"
def test_close_stdin_allows_eof_driven_process_to_finish(self, registry, tmp_path):
session = registry.spawn_local(
'python3 -c "import sys; print(sys.stdin.read().strip())"',
cwd=str(tmp_path),
use_pty=False,
)
try:
time.sleep(0.5)
assert registry.submit_stdin(session.id, "hello")["status"] == "ok"
assert registry.close_stdin(session.id)["status"] == "ok"
deadline = time.time() + 5
while time.time() < deadline:
poll = registry.poll(session.id)
if poll["status"] == "exited":
assert poll["exit_code"] == 0
assert "hello" in poll["output_preview"]
return
time.sleep(0.2)
pytest.fail("process did not exit after stdin was closed")
finally:
registry.kill_process(session.id)
# =========================================================================
# List sessions
# =========================================================================

View File

@@ -0,0 +1,91 @@
import json
from types import SimpleNamespace
import tools.terminal_tool as terminal_tool_module
from tools import process_registry as process_registry_module
def _base_config(tmp_path):
return {
"env_type": "local",
"docker_image": "",
"singularity_image": "",
"modal_image": "",
"daytona_image": "",
"cwd": str(tmp_path),
"timeout": 30,
}
def test_command_requires_pipe_stdin_detects_gh_with_token():
assert terminal_tool_module._command_requires_pipe_stdin(
"gh auth login --hostname github.com --git-protocol https --with-token"
) is True
assert terminal_tool_module._command_requires_pipe_stdin(
"gh auth login --web"
) is False
def test_terminal_background_disables_pty_for_gh_with_token(monkeypatch, tmp_path):
config = _base_config(tmp_path)
dummy_env = SimpleNamespace(env={})
captured = {}
def fake_spawn_local(**kwargs):
captured.update(kwargs)
return SimpleNamespace(id="proc_test", pid=1234, notify_on_complete=False)
monkeypatch.setattr(terminal_tool_module, "_get_env_config", lambda: config)
monkeypatch.setattr(terminal_tool_module, "_start_cleanup_thread", lambda: None)
monkeypatch.setattr(terminal_tool_module, "_check_all_guards", lambda *_args, **_kwargs: {"approved": True})
monkeypatch.setattr(process_registry_module.process_registry, "spawn_local", fake_spawn_local)
monkeypatch.setitem(terminal_tool_module._active_environments, "default", dummy_env)
monkeypatch.setitem(terminal_tool_module._last_activity, "default", 0.0)
try:
result = json.loads(
terminal_tool_module.terminal_tool(
command="gh auth login --hostname github.com --git-protocol https --with-token",
background=True,
pty=True,
)
)
finally:
terminal_tool_module._active_environments.pop("default", None)
terminal_tool_module._last_activity.pop("default", None)
assert captured["use_pty"] is False
assert result["session_id"] == "proc_test"
assert "PTY disabled" in result["pty_note"]
def test_terminal_background_keeps_pty_for_regular_interactive_commands(monkeypatch, tmp_path):
config = _base_config(tmp_path)
dummy_env = SimpleNamespace(env={})
captured = {}
def fake_spawn_local(**kwargs):
captured.update(kwargs)
return SimpleNamespace(id="proc_test", pid=1234, notify_on_complete=False)
monkeypatch.setattr(terminal_tool_module, "_get_env_config", lambda: config)
monkeypatch.setattr(terminal_tool_module, "_start_cleanup_thread", lambda: None)
monkeypatch.setattr(terminal_tool_module, "_check_all_guards", lambda *_args, **_kwargs: {"approved": True})
monkeypatch.setattr(process_registry_module.process_registry, "spawn_local", fake_spawn_local)
monkeypatch.setitem(terminal_tool_module._active_environments, "default", dummy_env)
monkeypatch.setitem(terminal_tool_module._last_activity, "default", 0.0)
try:
result = json.loads(
terminal_tool_module.terminal_tool(
command="python3 -c \"print(input())\"",
background=True,
pty=True,
)
)
finally:
terminal_tool_module._active_environments.pop("default", None)
terminal_tool_module._last_activity.pop("default", None)
assert captured["use_pty"] is True
assert "pty_note" not in result

View File

@@ -700,6 +700,29 @@ class ProcessRegistry:
"""Send data + newline to a running process's stdin (like pressing Enter)."""
return self.write_stdin(session_id, data + "\n")
def close_stdin(self, session_id: str) -> dict:
"""Close a running process's stdin / send EOF without killing the process."""
session = self.get(session_id)
if session is None:
return {"status": "not_found", "error": f"No process with ID {session_id}"}
if session.exited:
return {"status": "already_exited", "error": "Process has already finished"}
if hasattr(session, '_pty') and session._pty:
try:
session._pty.sendeof()
return {"status": "ok", "message": "EOF sent"}
except Exception as e:
return {"status": "error", "error": str(e)}
if not session.process or not session.process.stdin:
return {"status": "error", "error": "Process stdin not available (non-local backend or stdin closed)"}
try:
session.process.stdin.close()
return {"status": "ok", "message": "stdin closed"}
except Exception as e:
return {"status": "error", "error": str(e)}
def list_sessions(self, task_id: str = None) -> list:
"""List all running and recently-finished processes."""
with self._lock:
@@ -915,14 +938,14 @@ PROCESS_SCHEMA = {
"Actions: 'list' (show all), 'poll' (check status + new output), "
"'log' (full output with pagination), 'wait' (block until done or timeout), "
"'kill' (terminate), 'write' (send raw stdin data without newline), "
"'submit' (send data + Enter, for answering prompts)."
"'submit' (send data + Enter, for answering prompts), 'close' (close stdin/send EOF)."
),
"parameters": {
"type": "object",
"properties": {
"action": {
"type": "string",
"enum": ["list", "poll", "log", "wait", "kill", "write", "submit"],
"enum": ["list", "poll", "log", "wait", "kill", "write", "submit", "close"],
"description": "Action to perform on background processes"
},
"session_id": {
@@ -962,7 +985,7 @@ def _handle_process(args, **kw):
if action == "list":
return _json.dumps({"processes": process_registry.list_sessions(task_id=task_id)}, ensure_ascii=False)
elif action in ("poll", "log", "wait", "kill", "write", "submit"):
elif action in ("poll", "log", "wait", "kill", "write", "submit", "close"):
if not session_id:
return tool_error(f"session_id is required for {action}")
if action == "poll":
@@ -978,7 +1001,9 @@ def _handle_process(args, **kw):
return _json.dumps(process_registry.write_stdin(session_id, str(args.get("data", ""))), ensure_ascii=False)
elif action == "submit":
return _json.dumps(process_registry.submit_stdin(session_id, str(args.get("data", ""))), ensure_ascii=False)
return tool_error(f"Unknown process action: {action}. Use: list, poll, log, wait, kill, write, submit")
elif action == "close":
return _json.dumps(process_registry.close_stdin(session_id), ensure_ascii=False)
return tool_error(f"Unknown process action: {action}. Use: list, poll, log, wait, kill, write, submit, close")
registry.register(

View File

@@ -1112,6 +1112,21 @@ def _interpret_exit_code(command: str, exit_code: int) -> str | None:
return None
def _command_requires_pipe_stdin(command: str) -> bool:
"""Return True when PTY mode would break stdin-driven commands.
Some CLIs change behavior when stdin is a TTY. In particular,
`gh auth login --with-token` expects the token to arrive via piped stdin and
waits for EOF; when we launch it under a PTY, `process.submit()` only sends a
newline, so the command appears to hang forever with no visible progress.
"""
normalized = " ".join(command.lower().split())
return (
normalized.startswith("gh auth login")
and "--with-token" in normalized
)
def terminal_tool(
command: str,
background: bool = False,
@@ -1332,6 +1347,17 @@ def terminal_tool(
}, ensure_ascii=False)
# Prepare command for execution
pty_disabled_reason = None
effective_pty = pty
if pty and _command_requires_pipe_stdin(command):
effective_pty = False
pty_disabled_reason = (
"PTY disabled for this command because it expects piped stdin/EOF "
"(for example gh auth login --with-token). For local background "
"processes, call process(action='close') after writing so it receives "
"EOF."
)
if background:
# Spawn a tracked background process via the process registry.
# For local backends: uses subprocess.Popen with output buffering.
@@ -1349,7 +1375,7 @@ def terminal_tool(
task_id=effective_task_id,
session_key=session_key,
env_vars=env.env if hasattr(env, 'env') else None,
use_pty=pty,
use_pty=effective_pty,
)
else:
proc_session = process_registry.spawn_via_env(
@@ -1369,6 +1395,8 @@ def terminal_tool(
}
if approval_note:
result_data["approval"] = approval_note
if pty_disabled_reason:
result_data["pty_note"] = pty_disabled_reason
# Transparent timeout clamping note
max_timeout = effective_timeout

View File

@@ -1,7 +1,7 @@
---
sidebar_position: 2
title: "Installation"
description: "Install Hermes Agent on Linux, macOS, or WSL2"
description: "Install Hermes Agent on Linux, macOS, WSL2, or Android via Termux"
---
# Installation
@@ -16,6 +16,23 @@ Get Hermes Agent up and running in under two minutes with the one-line installer
curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh | bash
```
### Android / Termux
Hermes now ships a Termux-aware installer path too:
```bash
curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh | bash
```
The installer detects Termux automatically and switches to a tested Android flow:
- uses Termux `pkg` for system dependencies (`git`, `python`, `nodejs`, `ripgrep`, `ffmpeg`, build tools)
- creates the virtualenv with `python -m venv`
- exports `ANDROID_API_LEVEL` automatically for Android wheel builds
- installs a curated `.[termux]` extra with `pip`
- skips the untested browser / WhatsApp bootstrap by default
If you want the fully explicit path, follow the dedicated [Termux guide](./termux.md).
:::warning Windows
Native Windows is **not supported**. Please install [WSL2](https://learn.microsoft.com/en-us/windows/wsl/install) and run Hermes Agent from there. The install command above works inside WSL2.
:::
@@ -125,6 +142,7 @@ uv pip install -e "."
| `tts-premium` | ElevenLabs premium voices | `uv pip install -e ".[tts-premium]"` |
| `voice` | CLI microphone input + audio playback | `uv pip install -e ".[voice]"` |
| `pty` | PTY terminal support | `uv pip install -e ".[pty]"` |
| `termux` | Tested Android / Termux bundle (`cron`, `cli`, `pty`, `mcp`, `honcho`, `acp`) | `python -m pip install -e ".[termux]" -c constraints-termux.txt` |
| `honcho` | AI-native memory (Honcho integration) | `uv pip install -e ".[honcho]"` |
| `mcp` | Model Context Protocol support | `uv pip install -e ".[mcp]"` |
| `homeassistant` | Home Assistant integration | `uv pip install -e ".[homeassistant]"` |
@@ -134,6 +152,10 @@ uv pip install -e "."
You can combine extras: `uv pip install -e ".[messaging,cron]"`
:::tip Termux users
`.[all]` is not currently available on Android because the `voice` extra pulls `faster-whisper`, which depends on `ctranslate2` wheels that are not published for Android. Use `.[termux]` for the tested mobile install path, then add individual extras only as needed.
:::
</details>
### Step 4: Install Optional Submodules (if needed)

View File

@@ -13,10 +13,14 @@ This guide walks you through installing Hermes Agent, setting up a provider, and
Run the one-line installer:
```bash
# Linux / macOS / WSL2
# Linux / macOS / WSL2 / Android (Termux)
curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh | bash
```
:::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.
:::
:::tip Windows Users
Install [WSL2](https://learn.microsoft.com/en-us/windows/wsl/install) first, then run the command above inside your WSL2 terminal.
:::

View File

@@ -0,0 +1,228 @@
---
sidebar_position: 3
title: "Android / Termux"
description: "Run Hermes Agent directly on an Android phone with Termux"
---
# Hermes on Android with Termux
This is the tested path for running Hermes Agent 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
- 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
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.
---
## Option 1: One-line installer
Hermes now ships a Termux-aware installer path:
```bash
curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh | bash
```
On Termux, the installer automatically:
- uses `pkg` for system packages
- creates the venv with `python -m venv`
- installs `.[termux]` with `pip`
- 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 --recurse-submodules https://github.com/NousResearch/hermes-agent.git
cd hermes-agent
```
If you already cloned without submodules:
```bash
git submodule update --init --recursive
```
### 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. Verify the install
```bash
hermes version
hermes doctor
```
### 6. 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 later:
```bash
npm install
```
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]` is currently documented as the tested Android bundle
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

View File

@@ -36,6 +36,20 @@ Set your provider with `hermes model` or by editing `~/.hermes/.env`. See the [E
curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh | bash
```
### Does it work on Android / Termux?
Yes — Hermes now has a tested Termux install path for Android phones.
Quick install:
```bash
curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh | bash
```
For the fully explicit manual steps, supported extras, and current limitations, see the [Termux guide](../getting-started/termux.md).
Important caveat: the full `.[all]` extra is not currently available on Android because the `voice` extra depends on `faster-whisper``ctranslate2`, and `ctranslate2` does not publish Android wheels. Use the tested `.[termux]` extra instead.
### Is my data sent anywhere?
API calls go **only to the LLM provider you configure** (e.g., OpenRouter, your local Ollama instance). Hermes Agent does not collect telemetry, usage data, or analytics. Your conversations, memory, and skills are stored locally in `~/.hermes/`.

View File

@@ -9,6 +9,7 @@ const sidebars: SidebarsConfig = {
items: [
'getting-started/quickstart',
'getting-started/installation',
'getting-started/termux',
'getting-started/nix-setup',
'getting-started/updating',
'getting-started/learning-path',