diff --git a/README.md b/README.md index fde4cae33..b77cd6202 100644 --- a/README.md +++ b/README.md @@ -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: diff --git a/constraints-termux.txt b/constraints-termux.txt new file mode 100644 index 000000000..dcc1becf6 --- /dev/null +++ b/constraints-termux.txt @@ -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 diff --git a/hermes_cli/doctor.py b/hermes_cli/doctor.py index 361e81d21..6bdfd1232 100644 --- a/hermes_cli/doctor.py +++ b/hermes_cli/doctor.py @@ -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: diff --git a/pyproject.toml b/pyproject.toml index de0e61060..8e637d821 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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 = [ diff --git a/scripts/install.sh b/scripts/install.sh index c04dc4a9d..2b52b0397 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -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 } diff --git a/tests/hermes_cli/test_doctor.py b/tests/hermes_cli/test_doctor.py index f30fb4839..1378ad32f 100644 --- a/tests/hermes_cli/test_doctor.py +++ b/tests/hermes_cli/test_doctor.py @@ -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=***" diff --git a/tests/tools/test_process_registry.py b/tests/tools/test_process_registry.py index 44e3a1bd3..6b2d38197 100644 --- a/tests/tools/test_process_registry.py +++ b/tests/tools/test_process_registry.py @@ -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 # ========================================================================= diff --git a/tests/tools/test_terminal_tool_pty_fallback.py b/tests/tools/test_terminal_tool_pty_fallback.py new file mode 100644 index 000000000..75ef72183 --- /dev/null +++ b/tests/tools/test_terminal_tool_pty_fallback.py @@ -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 diff --git a/tools/process_registry.py b/tools/process_registry.py index b935f49c3..c954378bd 100644 --- a/tools/process_registry.py +++ b/tools/process_registry.py @@ -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( diff --git a/tools/terminal_tool.py b/tools/terminal_tool.py index 0dc0fd587..af35771c8 100644 --- a/tools/terminal_tool.py +++ b/tools/terminal_tool.py @@ -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 diff --git a/website/docs/getting-started/installation.md b/website/docs/getting-started/installation.md index e3282fa8d..5bdb6809e 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, 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. +::: + ### Step 4: Install Optional Submodules (if needed) diff --git a/website/docs/getting-started/quickstart.md b/website/docs/getting-started/quickstart.md index 7ed83e819..bd26f1eeb 100644 --- a/website/docs/getting-started/quickstart.md +++ b/website/docs/getting-started/quickstart.md @@ -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. ::: diff --git a/website/docs/getting-started/termux.md b/website/docs/getting-started/termux.md new file mode 100644 index 000000000..e980b507f --- /dev/null +++ b/website/docs/getting-started/termux.md @@ -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 diff --git a/website/docs/reference/faq.md b/website/docs/reference/faq.md index e8e6fe435..0ec0abd40 100644 --- a/website/docs/reference/faq.md +++ b/website/docs/reference/faq.md @@ -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/`. diff --git a/website/sidebars.ts b/website/sidebars.ts index 39b60d88e..720ccafd5 100644 --- a/website/sidebars.ts +++ b/website/sidebars.ts @@ -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',