From 030822b68daa40301636868e8747bcbc0eae6da5 Mon Sep 17 00:00:00 2001 From: Tianqing Yun Date: Tue, 30 Jun 2026 05:03:31 -0700 Subject: [PATCH 01/17] fix: resolve cua-driver from user-local install paths --- tests/tools/test_computer_use.py | 39 +++++++++++++++++- tools/computer_use/cua_backend.py | 67 ++++++++++++++++++++++++++++--- 2 files changed, 98 insertions(+), 8 deletions(-) diff --git a/tests/tools/test_computer_use.py b/tests/tools/test_computer_use.py index 44b5f1a5dbd1..530254b7dd7c 100644 --- a/tests/tools/test_computer_use.py +++ b/tests/tools/test_computer_use.py @@ -148,6 +148,41 @@ class TestRegistration: patch("tools.computer_use.cua_backend.cua_driver_binary_available", return_value=False): assert cu_tool.check_computer_use_requirements() is False + @pytest.mark.skipif(sys.platform == "win32", reason="POSIX user-local path regression") + def test_check_fn_finds_user_local_cua_driver_when_path_omits_it(self, tmp_path, monkeypatch): + """Desktop/TUI launched from Finder/Dock can omit ~/.local/bin from PATH. + + The cua-driver installer commonly places the binary there, so the + registry check must still expose the computer_use tool schema. + """ + from tools.computer_use import tool as cu_tool + + driver = tmp_path / ".local" / "bin" / "cua-driver" + driver.parent.mkdir(parents=True) + driver.write_text("#!/bin/sh\nexit 0\n") + driver.chmod(0o755) + + monkeypatch.delenv("HERMES_CUA_DRIVER_CMD", raising=False) + monkeypatch.setenv("HOME", str(tmp_path)) + monkeypatch.setenv("PATH", "/usr/bin:/bin:/usr/sbin:/sbin") + + with patch("tools.computer_use.tool.sys.platform", "darwin"), \ + patch("tools.computer_use.cua_backend.sys.platform", "darwin"): + assert cu_tool.check_computer_use_requirements() is True + + def test_cua_driver_cmd_env_override_is_resolved_dynamically(self, tmp_path, monkeypatch): + from tools.computer_use import cua_backend + + driver = tmp_path / "custom-cua-driver" + driver.write_text("#!/bin/sh\nexit 0\n") + driver.chmod(0o755) + + monkeypatch.setenv("HERMES_CUA_DRIVER_CMD", str(driver)) + monkeypatch.setenv("PATH", "/usr/bin:/bin") + + assert cua_backend.resolve_cua_driver_cmd() == str(driver) + assert cua_backend.cua_driver_binary_available() is True + # --------------------------------------------------------------------------- # Dispatch & action routing @@ -2331,8 +2366,8 @@ class TestCuaEnvironmentScrubbing: return MagicMock() with patch.dict(os.environ, test_env, clear=True), \ - patch("tools.computer_use.cua_backend.cua_driver_binary_available", - return_value=True), \ + patch("tools.computer_use.cua_backend.resolve_cua_driver_cmd", + return_value="cua-driver"), \ patch("tools.computer_use.cua_backend._resolve_mcp_invocation", return_value=("cua-driver", ["mcp"])), \ patch("mcp.StdioServerParameters", side_effect=capture_env), \ diff --git a/tools/computer_use/cua_backend.py b/tools/computer_use/cua_backend.py index 8c9b4a8ddb9f..641929748a2b 100644 --- a/tools/computer_use/cua_backend.py +++ b/tools/computer_use/cua_backend.py @@ -137,7 +137,8 @@ def _action_result_from( # only have *looked* like it pinned. For a reproducible version, point # `HERMES_CUA_DRIVER_CMD` at a specific binary instead. -_CUA_DRIVER_CMD = os.environ.get("HERMES_CUA_DRIVER_CMD", "cua-driver") +_CUA_DRIVER_CMD_ENV = "HERMES_CUA_DRIVER_CMD" +_CUA_DRIVER_DEFAULT_CMD = "cua-driver" _CUA_DRIVER_ARGS = ["mcp"] # stdio MCP transport (fallback when the # driver doesn't expose `manifest` — see # `_resolve_mcp_invocation` below) @@ -375,9 +376,59 @@ def _is_macos() -> bool: return sys.platform == "darwin" +def _has_path_separator(value: str) -> bool: + return os.sep in value or (os.altsep is not None and os.altsep in value) + + +def _candidate_cua_driver_commands() -> List[str]: + """Return candidate cua-driver commands in resolution order. + + Desktop apps launched from Finder/Dock often inherit a narrow PATH that + omits user-local install directories. The upstream cua-driver installer + commonly places the binary under ``~/.local/bin`` on POSIX systems, so a + Hermes Desktop/TUI session can otherwise filter out the `computer_use` + tool even though `hermes computer-use doctor` succeeds from a login shell. + """ + configured = os.environ.get(_CUA_DRIVER_CMD_ENV) + if configured: + # An explicit override is authoritative: if it is wrong, report the + # driver missing instead of silently picking a different binary. + return [configured] + + candidates = [_CUA_DRIVER_DEFAULT_CMD] + home = os.path.expanduser("~") + if sys.platform == "win32": + candidates.extend([ + os.path.join(home, ".local", "bin", "cua-driver.exe"), + os.path.join(home, ".local", "bin", "cua-driver"), + ]) + else: + candidates.extend([ + os.path.join(home, ".local", "bin", "cua-driver"), + os.path.join(home, ".cargo", "bin", "cua-driver"), + "/opt/homebrew/bin/cua-driver", + "/usr/local/bin/cua-driver", + ]) + return candidates + + +def resolve_cua_driver_cmd() -> Optional[str]: + """Resolve the cua-driver executable for PATH-constrained surfaces.""" + for candidate in _candidate_cua_driver_commands(): + expanded = os.path.expanduser(candidate) + if _has_path_separator(expanded): + if shutil.which(expanded): + return expanded + else: + resolved = shutil.which(expanded) + if resolved: + return resolved + return None + + def cua_driver_binary_available() -> bool: - """True if `cua-driver` is on $PATH or HERMES_CUA_DRIVER_CMD resolves.""" - return bool(shutil.which(_CUA_DRIVER_CMD)) + """True if `cua-driver` resolves via env, PATH, or known install paths.""" + return resolve_cua_driver_cmd() is not None def cua_driver_update_check(*, timeout: float = 8.0) -> Optional[Dict[str, Any]]: @@ -392,10 +443,13 @@ def cua_driver_update_check(*, timeout: float = 8.0) -> Optional[Dict[str, Any]] ``error`` field is set), or the output didn't parse. Best-effort; never raises. """ + driver_cmd = resolve_cua_driver_cmd() + if not driver_cmd: + return None try: from tools.environments.local import _sanitize_subprocess_env proc = subprocess.run( - [_CUA_DRIVER_CMD, "check-update", "--json"], + [driver_cmd, "check-update", "--json"], capture_output=True, text=True, timeout=timeout, # Some older drivers don't have the verb and fall through to a # stdin-reading mode rather than erroring — DEVNULL gives them EOF @@ -749,14 +803,15 @@ class _CuaDriverSession: self._startup_phase = "binary-check" try: - if not cua_driver_binary_available(): + driver_cmd = resolve_cua_driver_cmd() + if not driver_cmd: raise RuntimeError(cua_driver_install_hint()) # Surface 8: ask cua-driver itself which subcommand spawns # the MCP server, instead of hardcoding ["mcp"]. Falls back # transparently for older drivers / any discovery failure. self._startup_phase = "manifest-discovery" - command, args = _resolve_mcp_invocation(_CUA_DRIVER_CMD) + command, args = _resolve_mcp_invocation(driver_cmd) _t_manifest = _time.monotonic() params = StdioServerParameters( command=command, From 2f9d88caee267aefcf19cb8c9cf1a514bf1fed6c Mon Sep 17 00:00:00 2001 From: Tianqing Yun Date: Mon, 20 Jul 2026 19:39:53 -0700 Subject: [PATCH 02/17] fix: resolve cua-driver across computer-use surfaces --- hermes_cli/main.py | 15 +++--- hermes_cli/tools_config.py | 21 +++++--- .../computer_use/test_cua_cli_fallback_env.py | 10 +++- tests/computer_use/test_doctor.py | 23 +++++++++ .../test_permissions_resolution.py | 32 ++++++++++++ tests/tools/test_computer_use.py | 50 ++++++++++++++++++- tools/computer_use/cua_backend.py | 29 ++++++++--- tools/computer_use/doctor.py | 18 +++---- tools/computer_use/permissions.py | 17 +++---- 9 files changed, 169 insertions(+), 46 deletions(-) create mode 100644 tests/computer_use/test_permissions_resolution.py diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 9bdb87691df1..01eb6a758a57 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -14456,14 +14456,14 @@ def main(): install_cua_driver(upgrade=bool(getattr(args, "upgrade", False))) return if action == "status": - import shutil import subprocess - from hermes_cli.tools_config import _cua_driver_cmd - # Honor HERMES_CUA_DRIVER_CMD for local-build testing — same - # resolver `install_cua_driver` and the runtime backend use, - # so `status` reports what `computer_use` will actually invoke. - driver_cmd = _cua_driver_cmd() - path = shutil.which(driver_cmd) + from tools.computer_use.cua_backend import ( + cua_driver_update_check, + resolve_cua_driver_cmd, + ) + # Must match the runtime resolver: Desktop/TUI processes can omit + # ~/.local/bin even though the official installer put the driver there. + path = resolve_cua_driver_cmd() if path: version = "" try: @@ -14480,7 +14480,6 @@ def main(): else: print(f"cua-driver: installed at {path}") try: - from tools.computer_use.cua_backend import cua_driver_update_check st = cua_driver_update_check() if st and st.get("update_available"): latest = st.get("latest_version") or "?" diff --git a/hermes_cli/tools_config.py b/hermes_cli/tools_config.py index 9c82d5a17257..f3abfb4d9a94 100644 --- a/hermes_cli/tools_config.py +++ b/hermes_cli/tools_config.py @@ -649,10 +649,17 @@ TOOLSET_ENV_REQUIREMENTS = { def _cua_driver_cmd() -> str: - """Return the cua-driver executable name/path, honoring non-empty overrides.""" + """Return the configured cua-driver override, or the bare default name.""" return os.environ.get("HERMES_CUA_DRIVER_CMD", "").strip() or "cua-driver" +def _resolved_cua_driver_cmd() -> Optional[str]: + """Resolve cua-driver exactly as the runtime and Desktop status do.""" + from tools.computer_use.cua_backend import resolve_cua_driver_cmd + + return resolve_cua_driver_cmd() + + def _cua_driver_env() -> dict: """cua-driver child env with the Hermes telemetry policy applied. @@ -824,7 +831,7 @@ def install_cua_driver(upgrade: bool = False) -> bool: fetch_tool = "powershell" if is_windows else "curl" driver_cmd = _cua_driver_cmd() - binary = shutil.which(driver_cmd) + binary = _resolved_cua_driver_cmd() # Not installed → fresh install path (only when caller asked for it). if not binary and not upgrade: @@ -849,7 +856,7 @@ def install_cua_driver(upgrade: bool = False) -> bool: if binary and not upgrade: try: version = subprocess.run( - [driver_cmd, "--version"], + [binary, "--version"], capture_output=True, text=True, timeout=5, env=_cua_driver_env(), creationflags=_post_setup_no_window_flags(), ).stdout.strip() @@ -907,7 +914,7 @@ def install_cua_driver(upgrade: bool = False) -> bool: # Show before/after version when we have a baseline. Best-effort. try: before = subprocess.run( - [driver_cmd, "--version"], + [binary, "--version"], capture_output=True, text=True, timeout=5, env=_cua_driver_env(), creationflags=_post_setup_no_window_flags(), ).stdout.strip() @@ -920,7 +927,7 @@ def install_cua_driver(upgrade: bool = False) -> bool: if ok and before: try: after = subprocess.run( - [driver_cmd, "--version"], + [binary, "--version"], capture_output=True, text=True, timeout=5, env=_cua_driver_env(), creationflags=_post_setup_no_window_flags(), ).stdout.strip() @@ -2672,7 +2679,7 @@ _POST_SETUP_INSTALLED: dict = { # entry when (a) the post_setup is the ONLY install side-effect for # a no-key provider, and (b) an installed-state check is cheap and # doesn't trigger a heavy import. - "cua_driver": lambda: bool(shutil.which(_cua_driver_cmd())), + "cua_driver": lambda: _resolved_cua_driver_cmd() is not None, } @@ -2730,7 +2737,7 @@ _POST_SETUP_READY: dict = { "agent_browser": lambda: _agent_browser_installed(), "browserbase": lambda: _cloud_agent_browser_installed(), "camofox": lambda: _camofox_installed(), - "cua_driver": lambda: bool(shutil.which(_cua_driver_cmd())), + "cua_driver": lambda: _resolved_cua_driver_cmd() is not None, } diff --git a/tests/computer_use/test_cua_cli_fallback_env.py b/tests/computer_use/test_cua_cli_fallback_env.py index 1f3180dfe4fc..5a4b04fdda32 100644 --- a/tests/computer_use/test_cua_cli_fallback_env.py +++ b/tests/computer_use/test_cua_cli_fallback_env.py @@ -31,8 +31,12 @@ def _fake_completed_process(stdout: str) -> MagicMock: def test_cli_fallback_strips_provider_secret_from_subprocess_env(monkeypatch): - monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-super-secret-should-not-leak") + monkeypatch.setenv("ANTHROPIC_API_KEY", "«redacted:sk-…»") monkeypatch.setenv("PATH", "/usr/bin:/bin") + monkeypatch.setattr( + "tools.computer_use.cua_backend.resolve_cua_driver_cmd", + lambda: "/resolved/cua-driver", + ) captured = {} @@ -56,6 +60,10 @@ def test_cli_fallback_applies_telemetry_policy(monkeypatch): """The env should also go through cua_driver_child_env(), like every other cua-driver spawn site, not just _sanitize_subprocess_env alone.""" monkeypatch.delenv("HERMES_CUA_TELEMETRY", raising=False) + monkeypatch.setattr( + "tools.computer_use.cua_backend.resolve_cua_driver_cmd", + lambda: "/resolved/cua-driver", + ) captured = {} def fake_run(cmd, **kwargs): diff --git a/tests/computer_use/test_doctor.py b/tests/computer_use/test_doctor.py index edd2b24b20db..a912176138e7 100644 --- a/tests/computer_use/test_doctor.py +++ b/tests/computer_use/test_doctor.py @@ -20,9 +20,12 @@ shape. from __future__ import annotations import json +import sys from io import StringIO from unittest.mock import MagicMock, patch +import pytest + # ── helpers ──────────────────────────────────────────────────────────────── @@ -323,3 +326,23 @@ class TestDriverCmdResolution: doctor.run_doctor() # First (and only) which call should have used the env var. which_mock.assert_called_with("/env/path/cua-driver") + + @pytest.mark.skipif(sys.platform == "win32", reason="POSIX user-local path regression") + def test_user_local_driver_is_found_when_path_omits_it(self, tmp_path, monkeypatch): + """Doctor must inspect the same user-local driver as the runtime.""" + from tools.computer_use import doctor + + driver = tmp_path / ".local" / "bin" / "cua-driver" + driver.parent.mkdir(parents=True) + driver.write_text("#!/bin/sh\nexit 0\n") + driver.chmod(0o755) + + monkeypatch.delenv("HERMES_CUA_DRIVER_CMD", raising=False) + monkeypatch.setenv("HOME", str(tmp_path)) + monkeypatch.setenv("PATH", "/usr/bin:/bin:/usr/sbin:/sbin") + + with patch("tools.computer_use.doctor._drive_health_report", return_value=_ok_report()) as health, \ + patch("sys.stdout", new_callable=StringIO): + assert doctor.run_doctor() == 0 + + health.assert_called_once_with(str(driver), include=(), skip=()) diff --git a/tests/computer_use/test_permissions_resolution.py b/tests/computer_use/test_permissions_resolution.py new file mode 100644 index 000000000000..c51fc9987f2c --- /dev/null +++ b/tests/computer_use/test_permissions_resolution.py @@ -0,0 +1,32 @@ +"""Regression tests for Computer Use readiness under a thin GUI PATH.""" + +from __future__ import annotations + +import sys +from unittest.mock import MagicMock, patch + +import pytest + + +@pytest.mark.skipif(sys.platform == "win32", reason="POSIX user-local path regression") +def test_status_finds_user_local_driver_when_path_omits_it(tmp_path, monkeypatch): + """Desktop status must agree with the runtime resolver, not bare PATH.""" + from tools.computer_use import permissions + + driver = tmp_path / ".local" / "bin" / "cua-driver" + driver.parent.mkdir(parents=True) + driver.write_text("#!/bin/sh\nexit 0\n") + driver.chmod(0o755) + + monkeypatch.delenv("HERMES_CUA_DRIVER_CMD", raising=False) + monkeypatch.setenv("HOME", str(tmp_path)) + monkeypatch.setenv("PATH", "/usr/bin:/bin:/usr/sbin:/sbin") + + with patch("tools.computer_use.permissions.sys.platform", "darwin"), \ + patch("tools.computer_use.cua_backend.sys.platform", "darwin"), \ + patch.object(permissions, "_run", return_value=MagicMock(stdout="0.0.0")), \ + patch.object(permissions, "_doctor", return_value={"ok": True, "checks": []}), \ + patch.object(permissions, "_mac_permissions"): + status = permissions.computer_use_status() + + assert status["installed"] is True diff --git a/tests/tools/test_computer_use.py b/tests/tools/test_computer_use.py index 530254b7dd7c..a715f14da658 100644 --- a/tests/tools/test_computer_use.py +++ b/tests/tools/test_computer_use.py @@ -1654,13 +1654,18 @@ class TestCuaDriverSessionReconnect: assert cli_calls == [("get_window_state", {"pid": 1, "window_id": 2})] assert result["images"] == ["B64PNG"] - def test_cli_fallback_reads_screenshot_from_file(self, tmp_path): + def test_cli_fallback_reads_screenshot_from_file(self, tmp_path, monkeypatch): """_call_tool_via_cli must base64-read a screenshot written to disk (screenshot_out_file path) when no inline base64 is present.""" import base64 as _b64 from typing import Any, cast from tools.computer_use.cua_backend import _CuaDriverSession + monkeypatch.setattr( + "tools.computer_use.cua_backend.resolve_cua_driver_cmd", + lambda: "/resolved/cua-driver", + ) + png_bytes = b"\x89PNG\r\n\x1a\nFAKEDATA" shot = tmp_path / "shot.png" shot.write_bytes(png_bytes) @@ -1701,6 +1706,10 @@ class TestCuaDriverSessionReconnect: from tools.computer_use.cua_backend import _CuaDriverSession session = cast(Any, _CuaDriverSession.__new__(_CuaDriverSession)) + monkeypatch.setattr( + "tools.computer_use.cua_backend.resolve_cua_driver_cmd", + lambda: "/resolved/cua-driver", + ) class FakeProc: stdout = '{"isError": true, "message": "bad target"}' @@ -2425,6 +2434,30 @@ class TestCuaEnvironmentScrubbing: "At least one safe environment variable should be preserved" +class TestCuaCliFallbackResolution: + def test_cli_fallback_uses_resolved_driver_under_thin_path(self): + """CLI transport must use the same resolved path as MCP startup. + + The CLI fallback runs after an MCP bridge error, precisely when a + Finder/Dock-launched Desktop process may have a PATH without + ``~/.local/bin``. Falling back to the bare ``cua-driver`` command + would reintroduce the original bug at runtime. + """ + from tools.computer_use.cua_backend import _AsyncBridge, _CuaDriverSession + + proc = MagicMock(stdout="{}", stderr="", returncode=0) + session = _CuaDriverSession(_AsyncBridge()) + with patch( + "tools.computer_use.cua_backend.resolve_cua_driver_cmd", + return_value="/Users/example/.local/bin/cua-driver", + ), patch("subprocess.run", return_value=proc) as run: + session._call_tool_via_cli("click", {"x": 1, "y": 2}, timeout=0.1) + + assert run.call_args.args[0][:3] == [ + "/Users/example/.local/bin/cua-driver", "call", "click" + ] + + class TestClickButtonPassthrough: """Surface 5 (NousResearch/hermes-agent#47072) — `middle_click` must actually reach cua-driver as a middle button, not silently degrade to @@ -2856,6 +2889,21 @@ class TestMcpInvocationResolution: assert cmd == "/opt/cua-driver" assert args == ["mcp"] + def test_manifest_bare_command_keeps_resolved_driver_path(self): + """A bare manifest command must not reintroduce the narrow-PATH bug.""" + from unittest.mock import patch + from tools.computer_use.cua_backend import _resolve_mcp_invocation + + manifest = ( + '{"mcp_invocation":' + '{"command":"cua-driver","args":["mcp-stdio","--strict"]}}' + ) + driver = "/Users/example/.local/bin/cua-driver" + with patch("subprocess.run", new=self._fake_run(stdout=manifest)): + cmd, args = _resolve_mcp_invocation(driver) + assert cmd == driver + assert args == ["mcp-stdio", "--strict"] + def test_future_renamed_subcommand_is_honored(self): """The whole point: a future cua-driver that exposes `mcp-stdio` instead of `mcp` keeps working without a Hermes patch.""" diff --git a/tools/computer_use/cua_backend.py b/tools/computer_use/cua_backend.py index 641929748a2b..6f75f15922ef 100644 --- a/tools/computer_use/cua_backend.py +++ b/tools/computer_use/cua_backend.py @@ -335,6 +335,11 @@ def _resolve_mcp_invocation( # The driver knows the subcommand but didn't surface its own path. # Keep our resolved driver_cmd; the args are still authoritative. return driver_cmd, args + if not _has_path_separator(command): + # A manifest may legitimately retain the generic ``cua-driver`` name. + # Under a GUI's thin PATH that would lose the resolved user-local path + # and fail at MCP spawn, so preserve the concrete command we verified. + return driver_cmd, args return command, args # Regex to parse element lines from get_window_state AX tree markdown. @@ -380,16 +385,20 @@ def _has_path_separator(value: str) -> bool: return os.sep in value or (os.altsep is not None and os.altsep in value) -def _candidate_cua_driver_commands() -> List[str]: +def _candidate_cua_driver_commands(override: Optional[str] = None) -> List[str]: """Return candidate cua-driver commands in resolution order. + ``override`` is authoritative when supplied. Otherwise a non-empty + ``HERMES_CUA_DRIVER_CMD`` is authoritative; only when neither is set do we + use PATH and canonical install locations. + Desktop apps launched from Finder/Dock often inherit a narrow PATH that omits user-local install directories. The upstream cua-driver installer commonly places the binary under ``~/.local/bin`` on POSIX systems, so a Hermes Desktop/TUI session can otherwise filter out the `computer_use` tool even though `hermes computer-use doctor` succeeds from a login shell. """ - configured = os.environ.get(_CUA_DRIVER_CMD_ENV) + configured = (override if override is not None else os.environ.get(_CUA_DRIVER_CMD_ENV, "")).strip() if configured: # An explicit override is authoritative: if it is wrong, report the # driver missing instead of silently picking a different binary. @@ -412,9 +421,14 @@ def _candidate_cua_driver_commands() -> List[str]: return candidates -def resolve_cua_driver_cmd() -> Optional[str]: - """Resolve the cua-driver executable for PATH-constrained surfaces.""" - for candidate in _candidate_cua_driver_commands(): +def resolve_cua_driver_cmd(override: Optional[str] = None) -> Optional[str]: + """Resolve the cua-driver executable for every runtime/status surface. + + A supplied override (or ``HERMES_CUA_DRIVER_CMD``) is never silently + replaced by another binary. Otherwise resolve PATH first, then canonical + user-local installation locations used by the official installer. + """ + for candidate in _candidate_cua_driver_commands(override): expanded = os.path.expanduser(candidate) if _has_path_separator(expanded): if shutil.which(expanded): @@ -1118,7 +1132,10 @@ class _CuaDriverSession: os.close(fd) call_args["screenshot_out_file"] = shot_file - cmd = [_CUA_DRIVER_CMD, "call", name, json.dumps(call_args)] + driver_cmd = resolve_cua_driver_cmd() + if not driver_cmd: + raise RuntimeError(cua_driver_install_hint()) + cmd = [driver_cmd, "call", name, json.dumps(call_args)] attempts = 4 backoff = 0.5 parsed: Any = None diff --git a/tools/computer_use/doctor.py b/tools/computer_use/doctor.py index 38c1b43e91d8..54fad0666844 100644 --- a/tools/computer_use/doctor.py +++ b/tools/computer_use/doctor.py @@ -17,7 +17,6 @@ from __future__ import annotations import json import os -import shutil import subprocess import sys from typing import Any, Dict, List, Optional, Sequence @@ -240,9 +239,8 @@ def run_doctor( ) -> int: """Resolve the cua-driver binary, call `health_report`, render the result. - Honors `HERMES_CUA_DRIVER_CMD` via the same `_cua_driver_cmd()` resolver - that `install_cua_driver` + the runtime backend use, so the doctor - diagnoses what your `computer_use` toolset will actually invoke. + Honors `HERMES_CUA_DRIVER_CMD` via the shared runtime resolver, so the + doctor diagnoses what your `computer_use` toolset will actually invoke. """ # Windows ships stdout/stderr wrapped with the system ANSI codec # (`cp1252` on a US locale, `cp936` on zh-CN, etc.). The check-matrix @@ -255,16 +253,12 @@ def run_doctor( stream.reconfigure(encoding="utf-8", errors="replace") # type: ignore[union-attr] except (AttributeError, OSError): pass - if driver_cmd is None: - try: - from hermes_cli.tools_config import _cua_driver_cmd - driver_cmd = _cua_driver_cmd() - except Exception: - driver_cmd = os.environ.get("HERMES_CUA_DRIVER_CMD") or "cua-driver" + from tools.computer_use.cua_backend import resolve_cua_driver_cmd - binary = shutil.which(driver_cmd) + binary = resolve_cua_driver_cmd(driver_cmd) if not binary: - print(f"cua-driver: not installed (looked for {driver_cmd!r}).") + looked_for = driver_cmd or "cua-driver (PATH and canonical install paths)" + print(f"cua-driver: not installed (looked for {looked_for!r}).") print(" Run: hermes computer-use install") return 2 diff --git a/tools/computer_use/permissions.py b/tools/computer_use/permissions.py index d6fd0f3f91fd..4cccf2d683d5 100644 --- a/tools/computer_use/permissions.py +++ b/tools/computer_use/permissions.py @@ -25,7 +25,6 @@ from __future__ import annotations import json import os -import shutil import subprocess import sys from typing import Any, Dict, List, Optional @@ -35,15 +34,11 @@ _RUNTIME_PLATFORMS = frozenset({"darwin", "win32", "linux"}) _BOOLS = ("accessibility", "screen_recording", "screen_recording_capturable") -def _driver_cmd(override: Optional[str]) -> str: - if override: - return override - try: - from hermes_cli.tools_config import _cua_driver_cmd +def _resolve_driver_cmd(override: Optional[str]) -> Optional[str]: + """Use the runtime resolver for UI status and permission commands too.""" + from tools.computer_use.cua_backend import resolve_cua_driver_cmd - return _cua_driver_cmd() - except Exception: - return os.environ.get("HERMES_CUA_DRIVER_CMD", "").strip() or "cua-driver" + return resolve_cua_driver_cmd(override) def _child_env() -> Dict[str, str]: @@ -128,7 +123,7 @@ def computer_use_status(driver_cmd: Optional[str] = None) -> Dict[str, Any]: unknown (binary missing / probe failed). ``can_grant`` is macOS-only. """ plat = sys.platform - binary = shutil.which(_driver_cmd(driver_cmd)) + binary = _resolve_driver_cmd(driver_cmd) out: Dict[str, Any] = { "platform": plat, "platform_supported": plat in _RUNTIME_PLATFORMS, @@ -175,7 +170,7 @@ def request_permissions_grant(driver_cmd: Optional[str] = None) -> int: print("Computer Use permissions are a macOS concept; nothing to grant here.") return 64 - binary = shutil.which(_driver_cmd(driver_cmd)) + binary = _resolve_driver_cmd(driver_cmd) if not binary: print("cua-driver: not installed. Run: hermes computer-use install") return 2 From be96f2202604b675144c17c4b581907018292279 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Thu, 23 Jul 2026 00:44:27 -0500 Subject: [PATCH 03/17] fix(computer_use): probe resolved cua-driver path in post_setup Keep thin-PATH resolution while asserting the absolute binary on the already-installed version check. Map contributor emails for the salvage. Co-authored-by: Tianqing Yun Co-authored-by: Trevor Gordon Co-authored-by: Adrian Soto Mora --- contributors/emails/adrian.soto6@gmail.com | 2 ++ contributors/emails/yuntianqing@yahoo.com | 2 ++ tests/hermes_cli/test_tools_config.py | 6 ++++-- 3 files changed, 8 insertions(+), 2 deletions(-) create mode 100644 contributors/emails/adrian.soto6@gmail.com create mode 100644 contributors/emails/yuntianqing@yahoo.com diff --git a/contributors/emails/adrian.soto6@gmail.com b/contributors/emails/adrian.soto6@gmail.com new file mode 100644 index 000000000000..4f3d6b503411 --- /dev/null +++ b/contributors/emails/adrian.soto6@gmail.com @@ -0,0 +1,2 @@ +adriansotomora +# PR #58831 salvage diff --git a/contributors/emails/yuntianqing@yahoo.com b/contributors/emails/yuntianqing@yahoo.com new file mode 100644 index 000000000000..cedb9d102bae --- /dev/null +++ b/contributors/emails/yuntianqing@yahoo.com @@ -0,0 +1,2 @@ +zengzheqing +# PR #55631 salvage diff --git a/tests/hermes_cli/test_tools_config.py b/tests/hermes_cli/test_tools_config.py index 23d4a0956a52..0b3e84251185 100644 --- a/tests/hermes_cli/test_tools_config.py +++ b/tests/hermes_cli/test_tools_config.py @@ -1228,7 +1228,7 @@ def test_computer_use_blank_custom_driver_command_falls_back_to_default(): def test_computer_use_post_setup_respects_custom_driver_command_when_installed(): - """post_setup already-installed checks should version-probe the override.""" + """post_setup already-installed checks should version-probe the resolved override.""" def fake_which(name: str): return "/opt/bin/custom-cua" if name == "custom-cua" else None @@ -1241,7 +1241,9 @@ def test_computer_use_post_setup_respects_custom_driver_command_when_installed() _run_post_setup("cua_driver") run.assert_called_once() - assert run.call_args.args[0] == ["custom-cua", "--version"] + # Probe the resolved absolute path so thin GUI PATHs cannot reintroduce a + # bare-command miss after HERMES_CUA_DRIVER_CMD was looked up via which(). + assert run.call_args.args[0] == ["/opt/bin/custom-cua", "--version"] def test_computer_use_post_setup_missing_override_does_not_accept_default_binary(): From 8ceada6e30c12f743a06d6046b17028e57f3ae0b Mon Sep 17 00:00:00 2001 From: David Metcalfe <80915+DavidMetcalfe@users.noreply.github.com> Date: Sat, 27 Jun 2026 14:55:22 -0700 Subject: [PATCH 04/17] fix(computer_use): pass --no-overlay to cua-driver on Linux/WSL2 to prevent idle CPU cua-driver's cursor overlay rendering loop can consume CPU indefinitely when idle (#28152, #47032). On Linux/WSL2, the overlay serves no visual purpose and the rendering path is the primary source of idle CPU usage. Add computer_use.no_overlay config option (default: auto-detect) that passes --no-overlay to cua-driver when enabled. Auto-detection disables the overlay on Linux (covers WSL2, headless, containers) where it has no benefit, and keeps it enabled on macOS/Windows where it is visually useful. Refs: #28152, #47032 --- hermes_cli/config.py | 10 +++ tests/computer_use/test_cua_no_overlay.py | 78 +++++++++++++++++++++++ tools/computer_use/cua_backend.py | 57 ++++++++++++++--- 3 files changed, 137 insertions(+), 8 deletions(-) create mode 100644 tests/computer_use/test_cua_no_overlay.py diff --git a/hermes_cli/config.py b/hermes_cli/config.py index 9db871f283c7..c5920a1d7f4b 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -3474,6 +3474,16 @@ DEFAULT_CONFIG = { # every invocation (MCP backend, status, doctor, install). Set true # to let cua-driver use its own default (telemetry on). "cua_telemetry": False, + # Disable the cursor overlay rendered by cua-driver. The overlay + # shows where agent actions land on screen but can consume + # significant CPU when idle (especially on Linux/WSL2 where the + # compositor path has no visual benefit). cua-driver ≥ 0.6.x + # supports --no-overlay. + # None = auto-detect (disable on Linux/headless, enable elsewhere) + # True = always disable the overlay + # False = always enable the overlay (may cause idle CPU on some + # platforms — see #28152, #47032) + "no_overlay": None, }, # Hermes Desktop (Electron app) launch options. These only affect diff --git a/tests/computer_use/test_cua_no_overlay.py b/tests/computer_use/test_cua_no_overlay.py new file mode 100644 index 000000000000..244fd39b6dc1 --- /dev/null +++ b/tests/computer_use/test_cua_no_overlay.py @@ -0,0 +1,78 @@ +"""Tests for the cua-driver --no-overlay policy. + +cua-driver's cursor overlay rendering loop can consume CPU indefinitely when +idle (#28152, #47032). Hermes passes ``--no-overlay`` to suppress it when the +``computer_use.no_overlay`` config is enabled (or auto-detected on Linux). + +These assert the behavior contract (auto-detect on Linux, explicit override, +config failure fails safe toward overlay enabled), not specific config +snapshots. +""" + +import sys +from unittest.mock import patch + +from tools.computer_use import cua_backend + + +class TestNoOverlayFlag: + def test_default_linux_disables(self): + """Auto-detect: Linux => overlay disabled.""" + with patch("hermes_cli.config.load_config", return_value={}), \ + patch.object(sys, "platform", "linux"): + assert cua_backend._cua_no_overlay() is True + + def test_default_macos_enables(self): + """Auto-detect: macOS => overlay enabled (visually useful).""" + with patch("hermes_cli.config.load_config", return_value={}), \ + patch.object(sys, "platform", "darwin"): + assert cua_backend._cua_no_overlay() is False + + def test_default_windows_enables(self): + """Auto-detect: Windows => overlay enabled.""" + with patch("hermes_cli.config.load_config", return_value={}), \ + patch.object(sys, "platform", "win32"): + assert cua_backend._cua_no_overlay() is False + + def test_explicit_true_overrides(self): + with patch("hermes_cli.config.load_config", + return_value={"computer_use": {"no_overlay": True}}): + assert cua_backend._cua_no_overlay() is True + + def test_explicit_false_overrides(self): + with patch("hermes_cli.config.load_config", + return_value={"computer_use": {"no_overlay": False}}), \ + patch.object(sys, "platform", "linux"): + # Explicit False overrides auto-detect on Linux. + assert cua_backend._cua_no_overlay() is False + + def test_config_load_failure_fails_safe(self): + """Unreadable config => default to overlay enabled.""" + with patch("hermes_cli.config.load_config", + side_effect=RuntimeError("boom")): + assert cua_backend._cua_no_overlay() is False + + def test_missing_section_enables(self): + with patch("hermes_cli.config.load_config", + return_value={"other": {}}): + assert cua_backend._cua_no_overlay() is False + + +class TestMcpArgsOverlayFlag: + def test_no_overlay_appended_when_enabled(self): + with patch.object(cua_backend, "_cua_no_overlay", return_value=True): + result = cua_backend._mcp_args_with_overlay_flag(["mcp"]) + assert result == ["mcp", "--no-overlay"] + + def test_no_overlay_not_appended_when_disabled(self): + with patch.object(cua_backend, "_cua_no_overlay", return_value=False): + result = cua_backend._mcp_args_with_overlay_flag(["mcp"]) + assert result == ["mcp"] + + def test_does_not_mutate_original_list(self): + """The original args list must not be mutated.""" + original = ["mcp"] + with patch.object(cua_backend, "_cua_no_overlay", return_value=True): + result = cua_backend._mcp_args_with_overlay_flag(original) + assert "--no-overlay" in result + assert "--no-overlay" not in original diff --git a/tools/computer_use/cua_backend.py b/tools/computer_use/cua_backend.py index 8c9b4a8ddb9f..762ddbf34cea 100644 --- a/tools/computer_use/cua_backend.py +++ b/tools/computer_use/cua_backend.py @@ -171,6 +171,33 @@ _DESKTOP_WINDOW_NAMES = ( _CUA_TELEMETRY_ENV_VAR = "CUA_DRIVER_RS_TELEMETRY_ENABLED" +def _cua_no_overlay() -> bool: + """True when Hermes should pass ``--no-overlay`` to cua-driver. + + Reads ``computer_use.no_overlay`` from config.yaml. Default is + ``None`` (auto-detect): disable the overlay on Linux / headless / WSL2 + where it serves no visual purpose and can consume CPU indefinitely + (#28152, #47032). Explicit ``True`` / ``False`` in config overrides + auto-detection. Fails SAFE — toward enabling the overlay (False) + when config is unreadable, because the flag only takes effect on + cua-driver ≥ 0.6.x and an older driver ignores unknown flags. + """ + try: + from hermes_cli.config import load_config + + cfg = load_config() or {} + cu = cfg.get("computer_use") or {} + val = cu.get("no_overlay") + if val is not None: + return bool(val) + except Exception: + pass + # Auto-detect: disable on Linux (covers WSL2, headless, containers). + # macOS and Windows get the overlay by default — it's visually useful + # there, even though macOS has the vImage redraw bug on some versions. + return sys.platform == "linux" + + def _cua_telemetry_disabled() -> bool: """True when Hermes should disable cua-driver telemetry for this user. @@ -300,6 +327,13 @@ def _resolve_mcp_invocation( Falls back to ``(driver_cmd, ["mcp"])`` for older drivers that don't expose ``manifest``, or any indeterminate failure — the wrapper must not refuse to start just because the discovery hop failed. + + When ``computer_use.no_overlay`` is enabled (or auto-detected on + Linux), ``--no-overlay`` is appended to suppress the cursor overlay + rendering loop that can consume CPU indefinitely when idle + (#28152, #47032). Older drivers that don't recognise the flag will + reject it; callers should fall back to the no-overlay invocation on + spawn failure. """ try: from tools.environments.local import _sanitize_subprocess_env @@ -313,28 +347,35 @@ def _resolve_mcp_invocation( env=_sanitize_subprocess_env(cua_driver_child_env()), ) except Exception: - return driver_cmd, list(_CUA_DRIVER_ARGS) + return driver_cmd, _mcp_args_with_overlay_flag(list(_CUA_DRIVER_ARGS)) out = (proc.stdout or "").strip() if proc.returncode != 0 or not out: - return driver_cmd, list(_CUA_DRIVER_ARGS) + return driver_cmd, _mcp_args_with_overlay_flag(list(_CUA_DRIVER_ARGS)) try: manifest = json.loads(out) except (ValueError, TypeError): - return driver_cmd, list(_CUA_DRIVER_ARGS) + return driver_cmd, _mcp_args_with_overlay_flag(list(_CUA_DRIVER_ARGS)) if not isinstance(manifest, dict): - return driver_cmd, list(_CUA_DRIVER_ARGS) + return driver_cmd, _mcp_args_with_overlay_flag(list(_CUA_DRIVER_ARGS)) invocation = manifest.get("mcp_invocation") if not isinstance(invocation, dict): - return driver_cmd, list(_CUA_DRIVER_ARGS) + return driver_cmd, _mcp_args_with_overlay_flag(list(_CUA_DRIVER_ARGS)) args = invocation.get("args") command = invocation.get("command") if not isinstance(args, list) or not all(isinstance(a, str) for a in args): - return driver_cmd, list(_CUA_DRIVER_ARGS) + return driver_cmd, _mcp_args_with_overlay_flag(list(_CUA_DRIVER_ARGS)) if not isinstance(command, str) or not command: # The driver knows the subcommand but didn't surface its own path. # Keep our resolved driver_cmd; the args are still authoritative. - return driver_cmd, args - return command, args + return driver_cmd, _mcp_args_with_overlay_flag(args) + return command, _mcp_args_with_overlay_flag(args) + + +def _mcp_args_with_overlay_flag(args: List[str]) -> List[str]: + """Return *args* with ``--no-overlay`` appended when the config says to.""" + if _cua_no_overlay(): + return [*args, "--no-overlay"] + return list(args) # Regex to parse element lines from get_window_state AX tree markdown. # From f43ff5b4bbd3df2c703ddd2d180fb76ee0232dba Mon Sep 17 00:00:00 2001 From: David Metcalfe <80915+DavidMetcalfe@users.noreply.github.com> Date: Sat, 27 Jun 2026 17:39:52 -0700 Subject: [PATCH 05/17] fix(computer_use): mock _cua_no_overlay in existing tests, fix platform-dependent assertions - Add autouse fixture to TestMcpInvocationResolution to disable --no-overlay flag so existing tests assert baseline args - Make test_config_load_failure_fails_safe and test_missing_section_enables platform-aware (Linux auto-detect returns True, macOS/Windows False) --- tests/computer_use/test_cua_no_overlay.py | 8 +++++--- tests/tools/test_computer_use.py | 7 +++++++ 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/tests/computer_use/test_cua_no_overlay.py b/tests/computer_use/test_cua_no_overlay.py index 244fd39b6dc1..3b67a1b1f72b 100644 --- a/tests/computer_use/test_cua_no_overlay.py +++ b/tests/computer_use/test_cua_no_overlay.py @@ -47,15 +47,17 @@ class TestNoOverlayFlag: assert cua_backend._cua_no_overlay() is False def test_config_load_failure_fails_safe(self): - """Unreadable config => default to overlay enabled.""" + """Unreadable config => auto-detect (platform-dependent).""" with patch("hermes_cli.config.load_config", side_effect=RuntimeError("boom")): - assert cua_backend._cua_no_overlay() is False + expected = sys.platform == "linux" + assert cua_backend._cua_no_overlay() is expected def test_missing_section_enables(self): with patch("hermes_cli.config.load_config", return_value={"other": {}}): - assert cua_backend._cua_no_overlay() is False + expected = sys.platform == "linux" + assert cua_backend._cua_no_overlay() is expected class TestMcpArgsOverlayFlag: diff --git a/tests/tools/test_computer_use.py b/tests/tools/test_computer_use.py index 44b5f1a5dbd1..502f24b61b5a 100644 --- a/tests/tools/test_computer_use.py +++ b/tests/tools/test_computer_use.py @@ -2795,6 +2795,13 @@ class TestMcpInvocationResolution: fields, wrong types) falls back to the literal `["mcp"]` baseline. """ + @pytest.fixture(autouse=True) + def _no_overlay_off(self): + """Disable the --no-overlay flag so tests assert baseline args.""" + with patch("tools.computer_use.cua_backend._cua_no_overlay", + return_value=False): + yield + @staticmethod def _fake_run(stdout: str = "", returncode: int = 0, raises: Exception = None): """Build a patched subprocess.run that yields the supplied result.""" From 8d4f7a0002ebb5c27f76ea250bdadf8e12cb9857 Mon Sep 17 00:00:00 2001 From: David Metcalfe <80915+DavidMetcalfe@users.noreply.github.com> Date: Sat, 27 Jun 2026 17:42:17 -0700 Subject: [PATCH 06/17] fix(computer_use): refine auto-detect to headless/WSL2 only, add driver version probe Address review feedback from cross-vendor review (Flash + GPT-OSS): 1. Auto-detect now checks for headless Linux (no DISPLAY), WSL2 (/proc/version contains 'microsoft'), instead of all Linux. Desktop Linux with a compositor keeps the overlay. 2. Add _cua_driver_supports_no_overlay() that probes cua-driver --help to check if the flag is supported. Older drivers (< 0.6.x) reject unknown flags, so passing --no-overlay would crash the MCP spawn. 3. Update tests to cover headless vs desktop Linux, WSL2 detection, version probe, and the unsupported-driver fallback path. --- tests/computer_use/test_cua_no_overlay.py | 102 ++++++++++++++++------ tools/computer_use/cua_backend.py | 58 +++++++++--- 2 files changed, 122 insertions(+), 38 deletions(-) diff --git a/tests/computer_use/test_cua_no_overlay.py b/tests/computer_use/test_cua_no_overlay.py index 3b67a1b1f72b..3629aed448de 100644 --- a/tests/computer_use/test_cua_no_overlay.py +++ b/tests/computer_use/test_cua_no_overlay.py @@ -2,24 +2,43 @@ cua-driver's cursor overlay rendering loop can consume CPU indefinitely when idle (#28152, #47032). Hermes passes ``--no-overlay`` to suppress it when the -``computer_use.no_overlay`` config is enabled (or auto-detected on Linux). +``computer_use.no_overlay`` config is enabled (or auto-detected on headless +Linux / WSL2). -These assert the behavior contract (auto-detect on Linux, explicit override, -config failure fails safe toward overlay enabled), not specific config -snapshots. +These assert the behavior contract (auto-detect on headless/WSL2, explicit +override, version probe), not specific config snapshots. """ +import os import sys -from unittest.mock import patch +from unittest.mock import mock_open, patch from tools.computer_use import cua_backend class TestNoOverlayFlag: - def test_default_linux_disables(self): - """Auto-detect: Linux => overlay disabled.""" + def test_default_linux_headless_disables(self): + """Auto-detect: Linux without DISPLAY => overlay disabled.""" with patch("hermes_cli.config.load_config", return_value={}), \ - patch.object(sys, "platform", "linux"): + patch.object(sys, "platform", "linux"), \ + patch.dict(os.environ, {}, clear=False): + os.environ.pop("DISPLAY", None) + assert cua_backend._cua_no_overlay() is True + + def test_default_linux_desktop_enables(self): + """Auto-detect: Linux with DISPLAY => overlay enabled.""" + with patch("hermes_cli.config.load_config", return_value={}), \ + patch.object(sys, "platform", "linux"), \ + patch.dict(os.environ, {"DISPLAY": ":0"}): + assert cua_backend._cua_no_overlay() is False + + def test_default_linux_wsl2_disables(self): + """Auto-detect: WSL2 (microsoft in /proc/version) => overlay disabled.""" + fake_version = "Linux version 5.15.0 (Microsoft@Microsoft.com)" + with patch("hermes_cli.config.load_config", return_value={}), \ + patch.object(sys, "platform", "linux"), \ + patch.dict(os.environ, {"DISPLAY": ":0"}), \ + patch("builtins.open", mock_open(read_data=fake_version)): assert cua_backend._cua_no_overlay() is True def test_default_macos_enables(self): @@ -42,39 +61,72 @@ class TestNoOverlayFlag: def test_explicit_false_overrides(self): with patch("hermes_cli.config.load_config", return_value={"computer_use": {"no_overlay": False}}), \ - patch.object(sys, "platform", "linux"): - # Explicit False overrides auto-detect on Linux. + patch.object(sys, "platform", "linux"), \ + patch.dict(os.environ, {}, clear=False): + os.environ.pop("DISPLAY", None) + # Explicit False overrides auto-detect on headless Linux. assert cua_backend._cua_no_overlay() is False - def test_config_load_failure_fails_safe(self): - """Unreadable config => auto-detect (platform-dependent).""" + def test_config_load_failure_falls_through_to_auto_detect(self): + """Unreadable config => auto-detect.""" with patch("hermes_cli.config.load_config", - side_effect=RuntimeError("boom")): - expected = sys.platform == "linux" - assert cua_backend._cua_no_overlay() is expected + side_effect=RuntimeError("boom")), \ + patch.object(sys, "platform", "darwin"): + assert cua_backend._cua_no_overlay() is False - def test_missing_section_enables(self): + def test_missing_section_falls_through_to_auto_detect(self): with patch("hermes_cli.config.load_config", - return_value={"other": {}}): - expected = sys.platform == "linux" - assert cua_backend._cua_no_overlay() is expected + return_value={"other": {}}), \ + patch.object(sys, "platform", "linux"), \ + patch.dict(os.environ, {"DISPLAY": ":0"}): + assert cua_backend._cua_no_overlay() is False + + +class TestDriverSupportsNoOverlay: + def test_returns_true_when_help_shows_flag(self): + fake_help = "Usage: cua-driver [OPTIONS] COMMAND\n --no-overlay Disable cursor overlay\n" + with patch("subprocess.run") as mock_run: + mock_run.return_value.stdout = fake_help + mock_run.return_value.stderr = "" + assert cua_backend._cua_driver_supports_no_overlay("cua-driver") is True + + def test_returns_false_when_help_lacks_flag(self): + fake_help = "Usage: cua-driver [OPTIONS] COMMAND\n" + with patch("subprocess.run") as mock_run: + mock_run.return_value.stdout = fake_help + mock_run.return_value.stderr = "" + cua_backend._cua_driver_supports_no_overlay.cache_clear() + assert cua_backend._cua_driver_supports_no_overlay("cua-driver") is False + + def test_returns_false_on_subprocess_error(self): + with patch("subprocess.run", side_effect=FileNotFoundError("no such file")): + cua_backend._cua_driver_supports_no_overlay.cache_clear() + assert cua_backend._cua_driver_supports_no_overlay("cua-driver") is False class TestMcpArgsOverlayFlag: - def test_no_overlay_appended_when_enabled(self): - with patch.object(cua_backend, "_cua_no_overlay", return_value=True): + def test_appended_when_enabled_and_supported(self): + with patch.object(cua_backend, "_cua_no_overlay", return_value=True), \ + patch.object(cua_backend, "_cua_driver_supports_no_overlay", return_value=True): result = cua_backend._mcp_args_with_overlay_flag(["mcp"]) assert result == ["mcp", "--no-overlay"] - def test_no_overlay_not_appended_when_disabled(self): - with patch.object(cua_backend, "_cua_no_overlay", return_value=False): + def test_not_appended_when_disabled(self): + with patch.object(cua_backend, "_cua_no_overlay", return_value=False), \ + patch.object(cua_backend, "_cua_driver_supports_no_overlay", return_value=True): + result = cua_backend._mcp_args_with_overlay_flag(["mcp"]) + assert result == ["mcp"] + + def test_not_appended_when_driver_unsupported(self): + with patch.object(cua_backend, "_cua_no_overlay", return_value=True), \ + patch.object(cua_backend, "_cua_driver_supports_no_overlay", return_value=False): result = cua_backend._mcp_args_with_overlay_flag(["mcp"]) assert result == ["mcp"] def test_does_not_mutate_original_list(self): - """The original args list must not be mutated.""" original = ["mcp"] - with patch.object(cua_backend, "_cua_no_overlay", return_value=True): + with patch.object(cua_backend, "_cua_no_overlay", return_value=True), \ + patch.object(cua_backend, "_cua_driver_supports_no_overlay", return_value=True): result = cua_backend._mcp_args_with_overlay_flag(original) assert "--no-overlay" in result assert "--no-overlay" not in original diff --git a/tools/computer_use/cua_backend.py b/tools/computer_use/cua_backend.py index 762ddbf34cea..cbc4a17de4c2 100644 --- a/tools/computer_use/cua_backend.py +++ b/tools/computer_use/cua_backend.py @@ -38,6 +38,7 @@ from __future__ import annotations import asyncio import base64 import concurrent.futures +import functools import json import logging import os @@ -175,12 +176,10 @@ def _cua_no_overlay() -> bool: """True when Hermes should pass ``--no-overlay`` to cua-driver. Reads ``computer_use.no_overlay`` from config.yaml. Default is - ``None`` (auto-detect): disable the overlay on Linux / headless / WSL2 - where it serves no visual purpose and can consume CPU indefinitely - (#28152, #47032). Explicit ``True`` / ``False`` in config overrides - auto-detection. Fails SAFE — toward enabling the overlay (False) - when config is unreadable, because the flag only takes effect on - cua-driver ≥ 0.6.x and an older driver ignores unknown flags. + ``None`` (auto-detect): disable the overlay on headless Linux / WSL2 / + containers where it serves no visual purpose and can consume CPU + indefinitely (#28152, #47032). Explicit ``True`` / ``False`` in config + overrides auto-detection. """ try: from hermes_cli.config import load_config @@ -192,10 +191,20 @@ def _cua_no_overlay() -> bool: return bool(val) except Exception: pass - # Auto-detect: disable on Linux (covers WSL2, headless, containers). - # macOS and Windows get the overlay by default — it's visually useful - # there, even though macOS has the vImage redraw bug on some versions. - return sys.platform == "linux" + # Auto-detect: disable on headless Linux (no DISPLAY), WSL2, or + # containers. Desktop Linux with a running compositor keeps the + # overlay — it's visually useful there. + if sys.platform != "linux": + return False + if not os.environ.get("DISPLAY"): + return True + try: + with open("/proc/version") as f: + if "microsoft" in f.read().lower(): + return True + except Exception: + pass + return False def _cua_telemetry_disabled() -> bool: @@ -371,12 +380,35 @@ def _resolve_mcp_invocation( return command, _mcp_args_with_overlay_flag(args) -def _mcp_args_with_overlay_flag(args: List[str]) -> List[str]: - """Return *args* with ``--no-overlay`` appended when the config says to.""" - if _cua_no_overlay(): +def _mcp_args_with_overlay_flag( + args: List[str], + driver_cmd: str = _CUA_DRIVER_CMD, +) -> List[str]: + """Return *args* with ``--no-overlay`` appended when configured and supported.""" + if _cua_no_overlay() and _cua_driver_supports_no_overlay(driver_cmd): return [*args, "--no-overlay"] return list(args) + +@functools.lru_cache(maxsize=1) +def _cua_driver_supports_no_overlay(driver_cmd: str) -> bool: + """True if the installed cua-driver recognises ``--no-overlay``. + + Probes `` --help`` once and caches the result. Older + drivers (< 0.6.x) reject unknown flags, so passing ``--no-overlay`` + would crash the MCP spawn. + """ + try: + proc = subprocess.run( + [driver_cmd, "--help"], + capture_output=True, text=True, timeout=3.0, + stdin=subprocess.DEVNULL, + ) + help_text = (proc.stdout or "") + (proc.stderr or "") + return "--no-overlay" in help_text + except Exception: + return False + # Regex to parse element lines from get_window_state AX tree markdown. # # cua-driver renders each actionable node as one of: From f7a6c7a6e5a111aa81007f19bf9c66e232e431e1 Mon Sep 17 00:00:00 2001 From: David Metcalfe <80915+DavidMetcalfe@users.noreply.github.com> Date: Sat, 27 Jun 2026 17:51:27 -0700 Subject: [PATCH 07/17] fix(computer_use): add explicit encoding to /proc/version open() --- tools/computer_use/cua_backend.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tools/computer_use/cua_backend.py b/tools/computer_use/cua_backend.py index cbc4a17de4c2..6d2f6b5c6ff5 100644 --- a/tools/computer_use/cua_backend.py +++ b/tools/computer_use/cua_backend.py @@ -199,7 +199,7 @@ def _cua_no_overlay() -> bool: if not os.environ.get("DISPLAY"): return True try: - with open("/proc/version") as f: + with open("/proc/version", encoding="utf-8") as f: if "microsoft" in f.read().lower(): return True except Exception: From 3d846897147ba6ec257cbe930c0f8b855960d0e8 Mon Sep 17 00:00:00 2001 From: David Metcalfe <80915+DavidMetcalfe@users.noreply.github.com> Date: Thu, 16 Jul 2026 21:47:23 -0700 Subject: [PATCH 08/17] fix(computer_use): address sweeper feedback on --no-overlay subprocess + manifest probe MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The hermes-sweeper review #4701565902 (2026-07-15) flagged two consistency issues in `_cua_driver_supports_no_overlay` and one additive-config concern: 1. `cua-backend.py:260` — the `cua-driver --help` support probe inherited the full parent environment. cua-driver is a third-party binary; every other spawn site in this file (manifest probe at `:214`, MCP spawn at `:697`, install probe at `:997`) uses `_sanitize_subprocess_env(cua_driver_child_env())`. The `--help` probe should match. This was a low-impact leak (only help output exits), but inconsistency is the wrong default for a third-party subprocess. 2. `cua_backend.py:238` — when the manifest returned a `command` different from the input `driver_cmd` parameter (e.g. a relocated executable at `/opt/relocated/cua-driver` while the system binary is at `/usr/bin/cua-driver`), the support probe ran against `_CUA_DRIVER_CMD` (the default) instead of the manifest-discovered `command`. Two failure modes: - The wrapper binary supports `--no-overlay` but the system binary doesn't → probe returns False → overlay kept despite capability. - The system binary supports `--no-overlay` but the wrapper doesn't → probe returns True → MCP spawn crashes on the unknown flag. 3. The original commit bumped `_config_version` 31→32 for an additive default (`computer_use.no_overlay: None`). AGENTS.md specifies that additive defaults in existing sections are handled by deep merge and should NOT trigger a version bump. After cherry-picking onto current `origin/main` (which is already at 33), the bump is effectively dropped — resolved to main's 33. Changes: - Add `env=_sanitize_subprocess_env(cua_driver_child_env())` to the `--help` subprocess (with the same import + rationale comment as the manifest probe). - Pass `driver_cmd=command` (or `driver_cmd=driver_cmd` for the fallback path) into `_mcp_args_with_overlay_flag`, so the support probe runs against the binary that will actually be launched. Tests (3 new): - `test_help_probe_passes_sanitized_env` — verifies `subprocess.run` is called with an `env=` kwarg. - `test_manifest_command_drives_support_probe` — verifies the probe runs against the manifest command when it differs from the input driver_cmd. - `test_fallback_uses_input_driver_cmd_for_support_probe` — verifies the fallback path (no command in manifest) uses the input driver_cmd. - `test_probe_distinguishes_support_between_binaries` — sanity check that the lru_cache key on `driver_cmd` prevents cross-binary cache leakage. File-revert negative test confirmed all three of the new "manifest/probe" tests are load-bearing: with the pre-fix code, they fail (probe runs against the default binary instead of the resolved one); with the fix, they pass. 20/20 tests in `tests/computer_use/test_cua_no_overlay.py` green. `TestMcpInvocationResolution` (8/8) still green. Refs: sweeper review #4701565902 --- tests/computer_use/test_cua_no_overlay.py | 109 ++++++++++++++++++++++ tools/computer_use/cua_backend.py | 13 ++- 2 files changed, 120 insertions(+), 2 deletions(-) diff --git a/tests/computer_use/test_cua_no_overlay.py b/tests/computer_use/test_cua_no_overlay.py index 3629aed448de..207b66ce9d2a 100644 --- a/tests/computer_use/test_cua_no_overlay.py +++ b/tests/computer_use/test_cua_no_overlay.py @@ -103,6 +103,115 @@ class TestDriverSupportsNoOverlay: cua_backend._cua_driver_supports_no_overlay.cache_clear() assert cua_backend._cua_driver_supports_no_overlay("cua-driver") is False + def test_help_probe_passes_sanitized_env(self): + """The ``--help`` subprocess must not leak provider credentials + via the inherited parent environment (third-party binary; same + policy as the manifest probe and MCP spawn). + """ + from unittest.mock import MagicMock + with patch("subprocess.run") as mock_run: + mock_run.return_value = MagicMock(stdout="--no-overlay in help", stderr="") + cua_backend._cua_driver_supports_no_overlay.cache_clear() + cua_backend._cua_driver_supports_no_overlay("cua-driver") + kwargs = mock_run.call_args.kwargs + assert "env" in kwargs, ( + "subprocess.run was called without env= — cua-driver is a " + "third-party binary and must not receive inherited secrets" + ) + # The sanitized env must come from the same helper the MCP + # spawn uses, so the policy is consistent across every + # cua-driver invocation in this file. + assert kwargs["env"] is not None + + +class TestMcpInvocationUsesResolvedCommand: + """Surface 8 (NousResearch/hermes-agent#47072) + sweeper feedback + #4701565902: when the manifest surfaces a relocated executable for + ``mcp_invocation.command``, the support probe must run against THAT + binary, not the system-resolved ``_CUA_DRIVER_CMD``. Otherwise a + wrapper/relocation with a different feature set either crashes on + the unknown flag (when the probe falsely reports support) or + silently keeps an unwanted overlay (when the probe falsely reports + no support). + """ + + @staticmethod + def _fake_run(stdout: str = "", returncode: int = 0): + from unittest.mock import MagicMock + def _run(*args, **kwargs): + proc = MagicMock() + proc.stdout = stdout + proc.returncode = returncode + return proc + return _run + + def test_manifest_command_drives_support_probe(self): + """When the manifest returns a distinct command, the support + probe runs against the manifest command, not the input + ``driver_cmd`` parameter. + """ + from unittest.mock import patch + from tools.computer_use.cua_backend import _resolve_mcp_invocation + + manifest = ( + '{"mcp_invocation":' + '{"command":"/opt/relocated/cua-driver","args":["mcp"]}}' + ) + with patch("subprocess.run", new=self._fake_run(stdout=manifest)), \ + patch.object(cua_backend, "_cua_no_overlay", return_value=True), \ + patch.object( + cua_backend, "_cua_driver_supports_no_overlay", + return_value=True, + ) as mock_probe: + cua_backend._cua_driver_supports_no_overlay.cache_clear() + cmd, args = _resolve_mcp_invocation("/usr/bin/cua-driver") + assert cmd == "/opt/relocated/cua-driver" + # The support probe must be called with the manifest-resolved + # command, not the input driver_cmd argument. + mock_probe.assert_called_with("/opt/relocated/cua-driver") + + def test_fallback_uses_input_driver_cmd_for_support_probe(self): + """When the manifest knows the args but NOT the command, the + input ``driver_cmd`` parameter is what gets launched and + probed. + """ + from unittest.mock import patch + from tools.computer_use.cua_backend import _resolve_mcp_invocation + + manifest = '{"mcp_invocation":{"args":["mcp"]}}' + with patch("subprocess.run", new=self._fake_run(stdout=manifest)), \ + patch.object(cua_backend, "_cua_no_overlay", return_value=True), \ + patch.object( + cua_backend, "_cua_driver_supports_no_overlay", + return_value=True, + ) as mock_probe: + cua_backend._cua_driver_supports_no_overlay.cache_clear() + cmd, args = _resolve_mcp_invocation("/my/local/cua-driver") + assert cmd == "/my/local/cua-driver" + # Fallback path: probe runs against the input driver_cmd. + mock_probe.assert_called_with("/my/local/cua-driver") + + def test_probe_distinguishes_support_between_binaries(self): + """Different binaries must produce independent support verdicts. + The cache is keyed on ``driver_cmd``; the same cached result + must not leak between the system binary and a manifest-relocated + one. + """ + with patch.object(cua_backend, "_cua_no_overlay", return_value=True), \ + patch.object( + cua_backend, "_cua_driver_supports_no_overlay", + side_effect=lambda cmd: cmd == "/opt/relocated/cua-driver", + ): + # System binary does NOT support, manifest binary DOES. + args = cua_backend._mcp_args_with_overlay_flag( + ["mcp"], driver_cmd="/usr/bin/cua-driver", + ) + assert "--no-overlay" not in args + args = cua_backend._mcp_args_with_overlay_flag( + ["mcp"], driver_cmd="/opt/relocated/cua-driver", + ) + assert "--no-overlay" in args + class TestMcpArgsOverlayFlag: def test_appended_when_enabled_and_supported(self): diff --git a/tools/computer_use/cua_backend.py b/tools/computer_use/cua_backend.py index 6d2f6b5c6ff5..547604e8448a 100644 --- a/tools/computer_use/cua_backend.py +++ b/tools/computer_use/cua_backend.py @@ -376,8 +376,12 @@ def _resolve_mcp_invocation( if not isinstance(command, str) or not command: # The driver knows the subcommand but didn't surface its own path. # Keep our resolved driver_cmd; the args are still authoritative. - return driver_cmd, _mcp_args_with_overlay_flag(args) - return command, _mcp_args_with_overlay_flag(args) + return driver_cmd, _mcp_args_with_overlay_flag(args, driver_cmd=driver_cmd) + # Manifest surfaced a relocated executable — probe THAT binary for + # `--no-overlay` support rather than the system-resolved one, so a + # wrapper/relocation with a different feature set doesn't crash on + # an unknown flag (or silently keep an unwanted overlay). + return command, _mcp_args_with_overlay_flag(args, driver_cmd=command) def _mcp_args_with_overlay_flag( @@ -399,10 +403,15 @@ def _cua_driver_supports_no_overlay(driver_cmd: str) -> bool: would crash the MCP spawn. """ try: + # cua-driver is a third-party binary — never hand it provider + # API keys via inherited env (same policy as the manifest probe + # and MCP spawn; #53503/#55709/#58889 lineage). + from tools.environments.local import _sanitize_subprocess_env proc = subprocess.run( [driver_cmd, "--help"], capture_output=True, text=True, timeout=3.0, stdin=subprocess.DEVNULL, + env=_sanitize_subprocess_env(cua_driver_child_env()), ) help_text = (proc.stdout or "") + (proc.stderr or "") return "--no-overlay" in help_text From f957fe376080522a799621e40ea84692b8b72424 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Thu, 23 Jul 2026 00:45:26 -0500 Subject: [PATCH 09/17] fix(computer_use): default --no-overlay on macOS for idle CPU Auto-detect now disables the cursor overlay on darwin as well as headless/WSL2 Linux. After start_session, also call set_agent_cursor_enabled(false) when the policy is on so older drivers without --no-overlay still tear the overlay down. Co-authored-by: David Metcalfe <80915+DavidMetcalfe@users.noreply.github.com> --- hermes_cli/config.py | 15 ++++++------- tests/computer_use/test_cua_no_overlay.py | 24 +++++++++++++-------- tools/computer_use/cua_backend.py | 26 +++++++++++++++++------ 3 files changed, 41 insertions(+), 24 deletions(-) diff --git a/hermes_cli/config.py b/hermes_cli/config.py index c5920a1d7f4b..b83f79732212 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -3474,15 +3474,14 @@ DEFAULT_CONFIG = { # every invocation (MCP backend, status, doctor, install). Set true # to let cua-driver use its own default (telemetry on). "cua_telemetry": False, - # Disable the cursor overlay rendered by cua-driver. The overlay - # shows where agent actions land on screen but can consume - # significant CPU when idle (especially on Linux/WSL2 where the - # compositor path has no visual benefit). cua-driver ≥ 0.6.x - # supports --no-overlay. - # None = auto-detect (disable on Linux/headless, enable elsewhere) + # Disable the cursor overlay rendered by cua-driver. The overlay + # shows where agent actions land but can peg a core when idle + # (macOS vImage redraw loop #47032; Linux/WSL2 idle spin #28152). + # cua-driver ≥ 0.6.x supports --no-overlay; Hermes also calls + # set_agent_cursor_enabled(false) after start_session when this is on. + # None = auto-detect (off on macOS + headless/WSL2 Linux; on elsewhere) # True = always disable the overlay - # False = always enable the overlay (may cause idle CPU on some - # platforms — see #28152, #47032) + # False = always enable the overlay "no_overlay": None, }, diff --git a/tests/computer_use/test_cua_no_overlay.py b/tests/computer_use/test_cua_no_overlay.py index 207b66ce9d2a..0288bb4a0940 100644 --- a/tests/computer_use/test_cua_no_overlay.py +++ b/tests/computer_use/test_cua_no_overlay.py @@ -1,12 +1,12 @@ """Tests for the cua-driver --no-overlay policy. cua-driver's cursor overlay rendering loop can consume CPU indefinitely when -idle (#28152, #47032). Hermes passes ``--no-overlay`` to suppress it when the -``computer_use.no_overlay`` config is enabled (or auto-detected on headless -Linux / WSL2). +idle (#28152, #47032). Hermes passes ``--no-overlay`` to suppress it when the +``computer_use.no_overlay`` config is enabled (or auto-detected on macOS and +headless Linux / WSL2). -These assert the behavior contract (auto-detect on headless/WSL2, explicit -override, version probe), not specific config snapshots. +These assert the behavior contract (auto-detect, explicit override, version +probe), not specific config snapshots. """ import os @@ -41,11 +41,11 @@ class TestNoOverlayFlag: patch("builtins.open", mock_open(read_data=fake_version)): assert cua_backend._cua_no_overlay() is True - def test_default_macos_enables(self): - """Auto-detect: macOS => overlay enabled (visually useful).""" + def test_default_macos_disables(self): + """Auto-detect: macOS => overlay disabled (idle CPU / #47032).""" with patch("hermes_cli.config.load_config", return_value={}), \ patch.object(sys, "platform", "darwin"): - assert cua_backend._cua_no_overlay() is False + assert cua_backend._cua_no_overlay() is True def test_default_windows_enables(self): """Auto-detect: Windows => overlay enabled.""" @@ -68,10 +68,16 @@ class TestNoOverlayFlag: assert cua_backend._cua_no_overlay() is False def test_config_load_failure_falls_through_to_auto_detect(self): - """Unreadable config => auto-detect.""" + """Unreadable config => auto-detect (macOS defaults to disabled).""" with patch("hermes_cli.config.load_config", side_effect=RuntimeError("boom")), \ patch.object(sys, "platform", "darwin"): + assert cua_backend._cua_no_overlay() is True + + def test_macos_explicit_false_keeps_overlay(self): + with patch("hermes_cli.config.load_config", + return_value={"computer_use": {"no_overlay": False}}), \ + patch.object(sys, "platform", "darwin"): assert cua_backend._cua_no_overlay() is False def test_missing_section_falls_through_to_auto_detect(self): diff --git a/tools/computer_use/cua_backend.py b/tools/computer_use/cua_backend.py index 547604e8448a..f12ddc2d16b2 100644 --- a/tools/computer_use/cua_backend.py +++ b/tools/computer_use/cua_backend.py @@ -176,10 +176,11 @@ def _cua_no_overlay() -> bool: """True when Hermes should pass ``--no-overlay`` to cua-driver. Reads ``computer_use.no_overlay`` from config.yaml. Default is - ``None`` (auto-detect): disable the overlay on headless Linux / WSL2 / - containers where it serves no visual purpose and can consume CPU - indefinitely (#28152, #47032). Explicit ``True`` / ``False`` in config - overrides auto-detection. + ``None`` (auto-detect): disable the overlay where idle CPU burn is a + known failure mode — macOS (cursor-overlay vImage redraw loop, + #28152/#47032), headless Linux / WSL2 / containers — and keep it on + Windows / desktop Linux with a display. Explicit ``True`` / ``False`` + in config overrides auto-detection. """ try: from hermes_cli.config import load_config @@ -191,9 +192,11 @@ def _cua_no_overlay() -> bool: return bool(val) except Exception: pass - # Auto-detect: disable on headless Linux (no DISPLAY), WSL2, or - # containers. Desktop Linux with a running compositor keeps the - # overlay — it's visually useful there. + # Auto-detect: macOS overlay can peg a core indefinitely after a + # computer_use session (#47032). Prefer off until the driver teardown + # is solid; set computer_use.no_overlay: false to keep the cursor. + if sys.platform == "darwin": + return True if sys.platform != "linux": return False if not os.environ.get("DISPLAY"): @@ -1500,6 +1503,15 @@ class CuaDriverBackend(ComputerUseBackend): except Exception as e: logger.debug("cua-driver start_session failed (continuing anonymous): %s", e) + # Belt-and-suspenders when --no-overlay is unsupported or ignored: + # hide the agent cursor overlay via the session API so macOS idle + # redraw loops cannot keep burning CPU after the first action. + if _cua_no_overlay(): + try: + self.set_agent_cursor_enabled(False, cursor_id=self._session_id) + except Exception as e: + logger.debug("cua-driver set_agent_cursor_enabled failed: %s", e) + def stop(self) -> None: # Tear the cua-driver session down before disconnecting so the # driver can clean up per-session state (cursor overlay, recording From 8b6d34ad859b956546b66c1a826b1e0360d28fce Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Thu, 23 Jul 2026 01:10:19 -0500 Subject: [PATCH 10/17] test(computer_use): pin resolved driver for update-check tests cua_driver_update_check now short-circuits to None when no driver resolves; CI has none installed, so pin resolve_cua_driver_cmd in the update-check and env-sanitization tests. --- tests/computer_use/test_cua_spawn_env_sanitization.py | 5 +++++ tests/tools/test_computer_use.py | 10 ++++++++++ 2 files changed, 15 insertions(+) diff --git a/tests/computer_use/test_cua_spawn_env_sanitization.py b/tests/computer_use/test_cua_spawn_env_sanitization.py index 98be4176bf5c..2d69e0f1a53f 100644 --- a/tests/computer_use/test_cua_spawn_env_sanitization.py +++ b/tests/computer_use/test_cua_spawn_env_sanitization.py @@ -76,6 +76,11 @@ def test_update_check_sanitizes_env(monkeypatch): "latest_version": "1.0.0", "update_available": False, }) + # PATH is pinned to /usr/bin:/bin above, so the driver won't resolve; + # pin it so the check reaches the (sanitized) subprocess spawn. + monkeypatch.setattr( + cua_backend, "resolve_cua_driver_cmd", lambda *a, **k: "cua-driver" + ) monkeypatch.setattr( cua_backend.subprocess, "run", _capture_run(captured, stdout=payload) ) diff --git a/tests/tools/test_computer_use.py b/tests/tools/test_computer_use.py index a715f14da658..a22d325a10e4 100644 --- a/tests/tools/test_computer_use.py +++ b/tests/tools/test_computer_use.py @@ -1233,6 +1233,16 @@ class TestUpdateCheck: no `check-update` verb, offline, an `error` payload, or unparseable output. """ + @pytest.fixture(autouse=True) + def _driver_resolves(self): + # The update check now short-circuits to None when no driver + # resolves; CI has none installed, so pin a resolved path. + with patch( + "tools.computer_use.cua_backend.resolve_cua_driver_cmd", + return_value="/usr/local/bin/cua-driver", + ): + yield + @staticmethod def _run_returning(stdout: str): fake = MagicMock() From cdc123ec2f9043cd4a7e586c1b1843667a08601a Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Thu, 23 Jul 2026 01:10:21 -0500 Subject: [PATCH 11/17] fix(computer_use): only disable agent cursor after session handshake Guard the post-start set_agent_cursor_enabled on _session._started so call_tool cannot re-enter session.start() (matches the start_session lifecycle guard). --- tools/computer_use/cua_backend.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tools/computer_use/cua_backend.py b/tools/computer_use/cua_backend.py index f12ddc2d16b2..546b51a6b41c 100644 --- a/tools/computer_use/cua_backend.py +++ b/tools/computer_use/cua_backend.py @@ -1505,8 +1505,10 @@ class CuaDriverBackend(ComputerUseBackend): # Belt-and-suspenders when --no-overlay is unsupported or ignored: # hide the agent cursor overlay via the session API so macOS idle - # redraw loops cannot keep burning CPU after the first action. - if _cua_no_overlay(): + # redraw loops cannot keep burning CPU after the first action. Only + # once the handshake flipped `_started` — otherwise call_tool would + # re-enter session.start() (see _LIFECYCLE_CALLS). + if _cua_no_overlay() and self._session._started: try: self.set_agent_cursor_enabled(False, cursor_id=self._session_id) except Exception as e: From 58e3d415826a5ce2a2f3a32f6e63f4730f9e6c92 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Wed, 22 Jul 2026 23:08:48 -0500 Subject: [PATCH 12/17] fix(desktop): render agent credit notices as toasts (#69808) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The desktop renderer had no handler for the `notification.show` / `notification.clear` WS events, so every credit-usage notice the backend sends (`agent/credits_tracker.py` → `tui_gateway/server.py`) was silently dropped. Credit warnings like "• Credits 50% used · $220.00 cap" never appeared, even though the Ink TUI renders them in its status bar. Add the two missing branches to the gateway-event dispatcher, delegating to a small, pure-testable module: - `store/agent-notices.ts` — `noticeToToast()` maps a notice to a toast (level → toast kind, sticky → durationMs 0, ttl → ttl_ms), and uses the notice `key` as the toast id. Re-emitting the same key REPLACES the toast, so the credits 50→75→90 line escalates in place instead of stacking, and a key-matched `notification.clear` maps straight to `dismissNotification(key)`. - The notice `text` already carries its own glyph (• ⚠ ✕ ✓), so no toast icon is added. - Notices are account-wide, so the toast shows regardless of which session is focused. The Ink TUI (`ui-tui/src/app/turnController.ts`) is the reference for the latest-wins / sticky-vs-ttl / key-matched-clear behavior. Export `NotificationInput` so the mapping's return type can be named. --- .../hooks/use-message-stream/gateway-event.ts | 14 +++ apps/desktop/src/store/agent-notices.test.ts | 102 ++++++++++++++++++ apps/desktop/src/store/agent-notices.ts | 78 ++++++++++++++ apps/desktop/src/store/notifications.ts | 2 +- 4 files changed, 195 insertions(+), 1 deletion(-) create mode 100644 apps/desktop/src/store/agent-notices.test.ts create mode 100644 apps/desktop/src/store/agent-notices.ts diff --git a/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event.ts b/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event.ts index a96e780e875a..2077d7dd2fc7 100644 --- a/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event.ts +++ b/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event.ts @@ -15,6 +15,7 @@ import { resolveGatewayEventSessionId } from '@/lib/gateway-events' import { triggerHaptic } from '@/lib/haptics' import { modelOptionsQueryKey } from '@/lib/model-options' import { isProviderSetupErrorMessage } from '@/lib/provider-setup-errors' +import { type AgentNoticePayload, clearAgentNotice, showAgentNotice } from '@/store/agent-notices' import { reconcileApprovalModeForProfile } from '@/store/approval-mode' import { billingCtaLabel, clearBillingBlock, runBillingRecovery, setBillingBlock } from '@/store/billing-block' import { clearClarifyRequest, normalizeChoices, setClarifyRequest, warnDroppedChoices } from '@/store/clarify' @@ -865,6 +866,19 @@ export function useGatewayEventHandler(deps: GatewayEventDeps) { ] })) } + } else if (event.type === 'notification.show') { + // Driver-agnostic agent notice (credits usage/grant/depleted/restored + // from `agent/credits_tracker.py`). The Ink TUI renders these in its + // status bar; the desktop renders them as toasts. The notice key doubles + // as the toast id, so the escalating 50→75→90 credits line replaces in + // place instead of stacking. Account-wide signal — shown regardless of + // which session is focused. + showAgentNotice(event.payload as AgentNoticePayload | undefined) + } else if (event.type === 'notification.clear') { + // Key-matched dismissal (e.g. credits restored clears the depleted + // notice). notify() keys the toast by the notice key, so this maps + // straight to dismissNotification(key). + clearAgentNotice((event.payload as AgentNoticePayload | undefined)?.key) } else if (event.type === 'error') { const errorMessage = payload?.message || 'Hermes reported an error' const looksLikeProviderSetup = isProviderSetupErrorMessage(errorMessage) diff --git a/apps/desktop/src/store/agent-notices.test.ts b/apps/desktop/src/store/agent-notices.test.ts new file mode 100644 index 000000000000..f40d4deacb41 --- /dev/null +++ b/apps/desktop/src/store/agent-notices.test.ts @@ -0,0 +1,102 @@ +import { beforeEach, expect, test } from 'vitest' + +import { + type AgentNoticePayload, + clearAgentNotice, + noticeToToast, + showAgentNotice +} from './agent-notices' +import { $notifications, clearNotifications } from './notifications' + +function usage(overrides: Partial = {}): AgentNoticePayload { + return { + key: 'credits.usage', + kind: 'sticky', + level: 'info', + text: '• Credits 50% used · $220.00 cap', + ...overrides + } +} + +beforeEach(() => { + clearNotifications() +}) + +// ── noticeToToast: the whole mapping contract ──────────────────────────────── + +test('drops a notice with no text', () => { + expect(noticeToToast(undefined)).toBeNull() + expect(noticeToToast({ text: '' })).toBeNull() + expect(noticeToToast({ text: ' ' })).toBeNull() +}) + +test('level maps to toast kind (warn → warning)', () => { + expect(noticeToToast(usage({ level: 'info' }))?.kind).toBe('info') + expect(noticeToToast(usage({ level: 'warn' }))?.kind).toBe('warning') + expect(noticeToToast(usage({ level: 'error' }))?.kind).toBe('error') + expect(noticeToToast(usage({ level: 'success' }))?.kind).toBe('success') +}) + +test('unknown / missing level falls back to info', () => { + expect(noticeToToast({ text: 'x', level: 'bogus' })?.kind).toBe('info') + expect(noticeToToast({ text: 'x' })?.kind).toBe('info') +}) + +test('sticky notices never auto-dismiss', () => { + expect(noticeToToast(usage({ kind: 'sticky' }))?.durationMs).toBe(0) +}) + +test('ttl notice carries its ttl_ms as the duration', () => { + const toast = noticeToToast({ key: 'credits.restored', kind: 'ttl', level: 'success', text: '✓ restored', ttl_ms: 8000 }) + expect(toast?.durationMs).toBe(8000) +}) + +test('ttl notice without a usable ttl_ms defers to notify()’s default', () => { + expect(noticeToToast({ text: 'x', kind: 'ttl' })?.durationMs).toBeUndefined() + expect(noticeToToast({ text: 'x', kind: 'ttl', ttl_ms: 0 })?.durationMs).toBeUndefined() +}) + +test('the notice key is the toast id, falling back to id', () => { + expect(noticeToToast(usage({ key: 'credits.usage' }))?.id).toBe('credits.usage') + expect(noticeToToast({ text: 'x', id: 'n1', key: undefined })?.id).toBe('n1') +}) + +test('the glyph-bearing text passes through verbatim as the message', () => { + expect(noticeToToast(usage())?.message).toBe('• Credits 50% used · $220.00 cap') +}) + +// ── show / clear: rendered through the notifications store ──────────────────── + +test('showAgentNotice renders a toast; empty text is a no-op', () => { + showAgentNotice(usage()) + expect($notifications.get()).toHaveLength(1) + expect($notifications.get()[0]?.id).toBe('credits.usage') + + showAgentNotice({ text: '' }) + expect($notifications.get()).toHaveLength(1) +}) + +test('re-emitting the same key replaces the toast instead of stacking (50→75→90)', () => { + showAgentNotice(usage({ level: 'info', text: '• Credits 50% used' })) + showAgentNotice(usage({ level: 'warn', text: '• Credits 75% used' })) + showAgentNotice(usage({ level: 'warn', text: '• Credits 90% used' })) + + const toasts = $notifications.get().filter(item => item.id === 'credits.usage') + expect(toasts).toHaveLength(1) + expect(toasts[0]?.message).toBe('• Credits 90% used') + expect(toasts[0]?.kind).toBe('warning') +}) + +test('clearAgentNotice dismisses only the matching key', () => { + showAgentNotice(usage()) + showAgentNotice({ key: 'credits.depleted', kind: 'sticky', level: 'error', text: '✕ paused' }) + expect($notifications.get()).toHaveLength(2) + + clearAgentNotice('credits.usage') + const ids = $notifications.get().map(item => item.id) + expect(ids).toContain('credits.depleted') + expect(ids).not.toContain('credits.usage') + + clearAgentNotice(undefined) + expect($notifications.get()).toHaveLength(1) +}) diff --git a/apps/desktop/src/store/agent-notices.ts b/apps/desktop/src/store/agent-notices.ts new file mode 100644 index 000000000000..a12dee73b207 --- /dev/null +++ b/apps/desktop/src/store/agent-notices.ts @@ -0,0 +1,78 @@ +import { dismissNotification, type NotificationInput, type NotificationKind, notify } from '@/store/notifications' + +/** + * Wire shape of a `notification.show` payload — the driver-agnostic + * `AgentNotice` spine (`agent/credits_tracker.py`) as forwarded by + * `tui_gateway/server.py`. Snake_case to match the wire; the `text` already + * carries its own leading glyph (• ⚠ ✕ ✓) from the Python policy, so a toast + * NEVER adds another icon on top. + * + * - `level` is severity: info | warn | error | success. + * - `kind` is lifetime: `sticky` (stays until an explicit clear) or `ttl` + * (self-expires after `ttl_ms`). + */ +export interface AgentNoticePayload { + text?: string + level?: string + kind?: string + ttl_ms?: null | number + key?: string + id?: string +} + +const LEVEL_TO_TOAST_KIND: Record = { + error: 'error', + info: 'info', + success: 'success', + warn: 'warning' +} + +/** + * Map an agent notice to a toast input, or `null` when it carries no text. + * + * Pure and side-effect free so it can be unit-tested directly. The mapping is + * the whole contract: + * - `level` → toast kind (info/warn/error/success, warn→warning). + * - `sticky` → `durationMs: 0` (persists); `ttl` → `durationMs: ttl_ms`. + * - the notice `key` doubles as the toast `id`, so re-emitting the same key + * REPLACES the prior toast — the credits 50→75→90 line escalates in place + * instead of stacking, and a key-matched `notification.clear` can dismiss it. + */ +export function noticeToToast(payload: AgentNoticePayload | undefined): NotificationInput | null { + const text = payload?.text?.trim() + + if (!text) { + return null + } + + const isTtl = payload?.kind === 'ttl' + const ttl = isTtl && typeof payload?.ttl_ms === 'number' && payload.ttl_ms > 0 ? payload.ttl_ms : undefined + + return { + // sticky → 0 (never auto-dismiss); ttl with a ttl_ms → that value; a ttl + // without a usable ttl_ms falls back to notify()'s per-kind default. + durationMs: isTtl ? ttl : 0, + id: payload?.key || payload?.id, + kind: LEVEL_TO_TOAST_KIND[payload?.level ?? 'info'] ?? 'info', + message: text + } +} + +/** Render a `notification.show` notice as a toast (no-op when it has no text). */ +export function showAgentNotice(payload: AgentNoticePayload | undefined): void { + const toast = noticeToToast(payload) + + if (toast) { + notify(toast) + } +} + +/** + * Dismiss the toast a `notification.clear` targets. The clear only ever names a + * `key`, which we used as the toast id, so this is a key-matched dismissal. + */ +export function clearAgentNotice(key: string | undefined): void { + if (key) { + dismissNotification(key) + } +} diff --git a/apps/desktop/src/store/notifications.ts b/apps/desktop/src/store/notifications.ts index 82a67e973127..5393d4a26239 100644 --- a/apps/desktop/src/store/notifications.ts +++ b/apps/desktop/src/store/notifications.ts @@ -25,7 +25,7 @@ export interface AppNotification { placement?: NotificationPlacement } -interface NotificationInput { +export interface NotificationInput { id?: string kind?: NotificationKind icon?: string From b8a2b9b93ef732667a0a7bb44f7fb162bfbe80bb Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Wed, 22 Jul 2026 23:34:08 -0500 Subject: [PATCH 13/17] feat(desktop): native OS credit alerts + billing-page nudge (#69808) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round out the credit-notice handling from the previous commit with the two optional pieces from the issue: - Native OS notification for the urgent pair. `credits.depleted` / `credits.restored` also fire an Electron notification when Hermes is backgrounded, via a new `credits` NativeNotificationKind (the existing five didn't fit) with its own toggle in Settings → Notifications (the panel is data-driven off NATIVE_NOTIFICATION_KINDS, so the toggle and i18n are the only additions). The escalating usage line and grant-spent notice stay in-app toasts only. Dispatch is `global` (account-wide, not session-bound) and gated by the user's prefs + backgrounded check. - Billing-page nudge. A `credits.*` crossing invalidates the `['billing','state']` query so Settings → Billing reflects the change immediately instead of waiting up to 30s for its poll. `nativeNoticeInput()` is a pure mapping (urgent-key gate → native input), unit-tested directly; the gateway-event branch does the localized-title lookup and gated dispatch. i18n added for all four locales. --- .../hooks/use-message-stream/gateway-event.ts | 22 +++++++++++-- apps/desktop/src/i18n/en.ts | 7 +++- apps/desktop/src/i18n/ja.ts | 7 +++- apps/desktop/src/i18n/types.ts | 3 +- apps/desktop/src/i18n/zh-hant.ts | 7 +++- apps/desktop/src/i18n/zh.ts | 7 +++- apps/desktop/src/store/agent-notices.test.ts | 32 ++++++++++++++++++ apps/desktop/src/store/agent-notices.ts | 33 +++++++++++++++++++ .../desktop/src/store/native-notifications.ts | 7 ++-- 9 files changed, 115 insertions(+), 10 deletions(-) diff --git a/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event.ts b/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event.ts index 2077d7dd2fc7..30ceb1510562 100644 --- a/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event.ts +++ b/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event.ts @@ -15,7 +15,7 @@ import { resolveGatewayEventSessionId } from '@/lib/gateway-events' import { triggerHaptic } from '@/lib/haptics' import { modelOptionsQueryKey } from '@/lib/model-options' import { isProviderSetupErrorMessage } from '@/lib/provider-setup-errors' -import { type AgentNoticePayload, clearAgentNotice, showAgentNotice } from '@/store/agent-notices' +import { type AgentNoticePayload, clearAgentNotice, nativeNoticeInput, showAgentNotice } from '@/store/agent-notices' import { reconcileApprovalModeForProfile } from '@/store/approval-mode' import { billingCtaLabel, clearBillingBlock, runBillingRecovery, setBillingBlock } from '@/store/billing-block' import { clearClarifyRequest, normalizeChoices, setClarifyRequest, warnDroppedChoices } from '@/store/clarify' @@ -873,7 +873,25 @@ export function useGatewayEventHandler(deps: GatewayEventDeps) { // as the toast id, so the escalating 50→75→90 credits line replaces in // place instead of stacking. Account-wide signal — shown regardless of // which session is focused. - showAgentNotice(event.payload as AgentNoticePayload | undefined) + const notice = event.payload as AgentNoticePayload | undefined + + showAgentNotice(notice) + + // The urgent pair (access paused / restored) also breaks through as a + // native OS notification when Hermes is backgrounded; dispatch is gated + // by the user's notification prefs + backgrounded check. + const native = nativeNoticeInput(notice, translateNow('notifications.native.creditsTitle')) + + if (native) { + dispatchNativeNotification(native) + } + + // A credits crossing moves the account balance. Settings → Billing polls + // `billing.state` every 30s; nudge it so the page reflects the crossing + // immediately instead of up to 30s late. + if (notice?.key?.startsWith('credits.')) { + void queryClient.invalidateQueries({ queryKey: ['billing', 'state'] }) + } } else if (event.type === 'notification.clear') { // Key-matched dismissal (e.g. credits restored clears the depleted // notice). notify() keys the toast by the notice key, so this maps diff --git a/apps/desktop/src/i18n/en.ts b/apps/desktop/src/i18n/en.ts index 8b8ba7bbd390..a1f7724d6c60 100644 --- a/apps/desktop/src/i18n/en.ts +++ b/apps/desktop/src/i18n/en.ts @@ -168,7 +168,8 @@ export const en: Translations = { turnDoneBody: 'The response is ready.', turnErrorTitle: 'Turn failed', backgroundDoneTitle: 'Background task finished', - backgroundFailedTitle: 'Background task failed' + backgroundFailedTitle: 'Background task failed', + creditsTitle: 'Credits' } }, @@ -378,6 +379,10 @@ export const en: Translations = { backgroundDone: { label: 'Background task finished', description: 'A backgrounded terminal command completed.' + }, + credits: { + label: 'Credit alerts', + description: 'Credit access is paused or restored.' } }, test: 'Send test notification', diff --git a/apps/desktop/src/i18n/ja.ts b/apps/desktop/src/i18n/ja.ts index 361378553a34..6d4bd9a89c52 100644 --- a/apps/desktop/src/i18n/ja.ts +++ b/apps/desktop/src/i18n/ja.ts @@ -169,7 +169,8 @@ export const ja = defineLocale({ turnDoneBody: '応答の準備ができました。', turnErrorTitle: 'ターンが失敗しました', backgroundDoneTitle: 'バックグラウンドタスクが完了しました', - backgroundFailedTitle: 'バックグラウンドタスクが失敗しました' + backgroundFailedTitle: 'バックグラウンドタスクが失敗しました', + creditsTitle: 'クレジット' } }, @@ -262,6 +263,10 @@ export const ja = defineLocale({ backgroundDone: { label: 'バックグラウンドタスク完了', description: 'バックグラウンドのターミナルコマンドが完了しました。' + }, + credits: { + label: 'クレジット通知', + description: 'クレジットの利用が停止または復旧しました。' } }, test: 'テスト通知を送信', diff --git a/apps/desktop/src/i18n/types.ts b/apps/desktop/src/i18n/types.ts index 9a55d2f1a46d..f71770813e00 100644 --- a/apps/desktop/src/i18n/types.ts +++ b/apps/desktop/src/i18n/types.ts @@ -211,6 +211,7 @@ export interface Translations { turnErrorTitle: string backgroundDoneTitle: string backgroundFailedTitle: string + creditsTitle: string } } @@ -314,7 +315,7 @@ export interface Translations { enableAllDesc: string focusedHint: string kinds: Record< - 'approval' | 'backgroundDone' | 'input' | 'turnDone' | 'turnError', + 'approval' | 'backgroundDone' | 'credits' | 'input' | 'turnDone' | 'turnError', { label: string; description: string } > test: string diff --git a/apps/desktop/src/i18n/zh-hant.ts b/apps/desktop/src/i18n/zh-hant.ts index 8657437368a0..919039f5504e 100644 --- a/apps/desktop/src/i18n/zh-hant.ts +++ b/apps/desktop/src/i18n/zh-hant.ts @@ -164,7 +164,8 @@ export const zhHant = defineLocale({ turnDoneBody: '回覆已就緒。', turnErrorTitle: '本輪失敗', backgroundDoneTitle: '背景工作已完成', - backgroundFailedTitle: '背景工作失敗' + backgroundFailedTitle: '背景工作失敗', + creditsTitle: '額度' } }, @@ -256,6 +257,10 @@ export const zhHant = defineLocale({ backgroundDone: { label: '背景工作完成', description: '背景終端機指令已完成。' + }, + credits: { + label: '額度提醒', + description: '額度存取被暫停或恢復。' } }, test: '傳送測試通知', diff --git a/apps/desktop/src/i18n/zh.ts b/apps/desktop/src/i18n/zh.ts index 44ef9c68c620..53cf3a93c2bf 100644 --- a/apps/desktop/src/i18n/zh.ts +++ b/apps/desktop/src/i18n/zh.ts @@ -164,7 +164,8 @@ export const zh: Translations = { turnDoneBody: '回复已就绪。', turnErrorTitle: '本轮失败', backgroundDoneTitle: '后台任务已完成', - backgroundFailedTitle: '后台任务失败' + backgroundFailedTitle: '后台任务失败', + creditsTitle: '额度' } }, @@ -368,6 +369,10 @@ export const zh: Translations = { backgroundDone: { label: '后台任务完成', description: '后台终端命令已完成。' + }, + credits: { + label: '额度提醒', + description: '额度访问被暂停或恢复。' } }, test: '发送测试通知', diff --git a/apps/desktop/src/store/agent-notices.test.ts b/apps/desktop/src/store/agent-notices.test.ts index f40d4deacb41..a4028176ed13 100644 --- a/apps/desktop/src/store/agent-notices.test.ts +++ b/apps/desktop/src/store/agent-notices.test.ts @@ -3,6 +3,7 @@ import { beforeEach, expect, test } from 'vitest' import { type AgentNoticePayload, clearAgentNotice, + nativeNoticeInput, noticeToToast, showAgentNotice } from './agent-notices' @@ -100,3 +101,34 @@ test('clearAgentNotice dismisses only the matching key', () => { clearAgentNotice(undefined) expect($notifications.get()).toHaveLength(1) }) + +// ── nativeNoticeInput: only the urgent credit pair breaks through the OS ────── + +test('only credits.depleted and credits.restored map to a native notification', () => { + expect(nativeNoticeInput(usage({ key: 'credits.usage' }), 'Credits')).toBeNull() + expect(nativeNoticeInput(usage({ key: 'credits.grant_spent' }), 'Credits')).toBeNull() + expect(nativeNoticeInput({ text: 'x', key: undefined }, 'Credits')).toBeNull() + expect(nativeNoticeInput({ text: '', key: 'credits.depleted' }, 'Credits')).toBeNull() +}) + +test('the urgent pair maps to a global native input carrying the text as its body', () => { + const depleted = nativeNoticeInput( + { key: 'credits.depleted', kind: 'sticky', level: 'error', text: '✕ Credit access paused · run /topup to top up' }, + 'Credits' + ) + + expect(depleted).toEqual({ + body: '✕ Credit access paused · run /topup to top up', + global: true, + kind: 'credits', + title: 'Credits' + }) + + const restored = nativeNoticeInput( + { key: 'credits.restored', kind: 'ttl', level: 'success', text: '✓ Credit access restored', ttl_ms: 8000 }, + 'Credits' + ) + + expect(restored?.kind).toBe('credits') + expect(restored?.body).toBe('✓ Credit access restored') +}) diff --git a/apps/desktop/src/store/agent-notices.ts b/apps/desktop/src/store/agent-notices.ts index a12dee73b207..c076ee9854e6 100644 --- a/apps/desktop/src/store/agent-notices.ts +++ b/apps/desktop/src/store/agent-notices.ts @@ -1,3 +1,4 @@ +import type { NativeNotificationInput } from '@/store/native-notifications' import { dismissNotification, type NotificationInput, type NotificationKind, notify } from '@/store/notifications' /** @@ -76,3 +77,35 @@ export function clearAgentNotice(key: string | undefined): void { dismissNotification(key) } } + +// Only these two credit notices are urgent enough to break through as a native +// OS notification (when Hermes is backgrounded). The escalating usage line +// (`credits.usage`) and the grant-spent notice stay in-app toasts only — they +// aren't worth interrupting the user's OS for. +const NATIVE_NOTICE_KEYS = new Set(['credits.depleted', 'credits.restored']) + +/** + * Map a notice to a native OS notification input, or `null` when it isn't one of + * the urgent credit notices. Pure — the caller passes the localized `title` and + * decides whether to dispatch. `global: true` because credit state is + * account-wide, not tied to a chat session, so it should fire whenever the user + * is away regardless of which session (if any) is focused. The notice `text` + * already carries its glyph and is passed through as the raw body. + */ +export function nativeNoticeInput( + payload: AgentNoticePayload | undefined, + title: string +): NativeNotificationInput | null { + const text = payload?.text?.trim() + + if (!text || !payload?.key || !NATIVE_NOTICE_KEYS.has(payload.key)) { + return null + } + + return { + body: text, + global: true, + kind: 'credits', + title + } +} diff --git a/apps/desktop/src/store/native-notifications.ts b/apps/desktop/src/store/native-notifications.ts index 5e659d061b40..db56d94a3fa4 100644 --- a/apps/desktop/src/store/native-notifications.ts +++ b/apps/desktop/src/store/native-notifications.ts @@ -8,14 +8,15 @@ import { $activeSessionId } from './session' // Native OS notifications (Electron `Notification`), separate from the in-app // toast feed in `notifications.ts`. Each kind toggles independently. -export type NativeNotificationKind = 'approval' | 'backgroundDone' | 'input' | 'turnDone' | 'turnError' +export type NativeNotificationKind = 'approval' | 'backgroundDone' | 'credits' | 'input' | 'turnDone' | 'turnError' export const NATIVE_NOTIFICATION_KINDS: readonly NativeNotificationKind[] = [ 'approval', 'input', 'turnDone', 'turnError', - 'backgroundDone' + 'backgroundDone', + 'credits' ] // Blocking prompts — surface even while focused if they're for another session. @@ -30,7 +31,7 @@ const STORAGE_KEY = 'hermes:native-notifications' const DEFAULT_PREFS: NativeNotificationPrefs = { enabled: true, - kinds: { approval: true, backgroundDone: true, input: true, turnDone: true, turnError: true } + kinds: { approval: true, backgroundDone: true, credits: true, input: true, turnDone: true, turnError: true } } function readPrefs(): NativeNotificationPrefs { From 59a735b8f32314408c909501e2d8c3c220c8e792 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Thu, 23 Jul 2026 01:13:14 -0500 Subject: [PATCH 14/17] feat(credits): report $used of $cap instead of % in the usage notice The usage gauge is Nous subscription-cap-only (used_fraction requires a cap; non-Nous providers emit no headers, so no notice fires). A bare percentage implied a universal unit that doesn't exist, so report the absolute dollars used of the cap instead: used = cap - remaining, from micros (money-safe), clamped to [0, cap]. Still a snapshot at band-crossing (re-emits on band change, not every turn) to keep the single escalating line and stay quiet on append-only surfaces (messaging pushes one message per crossing). --- agent/credits_tracker.py | 13 +++++++++++-- tests/agent/test_credits_policy.py | 25 ++++++++++++++----------- 2 files changed, 25 insertions(+), 13 deletions(-) diff --git a/agent/credits_tracker.py b/agent/credits_tracker.py index f2186fb91a56..929bc34d3262 100644 --- a/agent/credits_tracker.py +++ b/agent/credits_tracker.py @@ -316,12 +316,21 @@ def evaluate_credits_notices( active.discard(CREDITS_USAGE_KEY) if target_band is not None: # Belt-and-suspenders: a producer could set subscription_limit_micros - # without subscription_limit_usd. Render "$? cap" rather than "$None cap". + # without subscription_limit_usd. Render "$?" rather than "$None". _cap_usd = state.subscription_limit_usd or "?" _level = current_band[1] # type: ignore[index] (current_band set when target_band set) + # Report absolute dollars used, not a bare "N% used": the percentage is + # only meaningful against a Nous subscription cap (no cap → never fires), + # so dollars are clearer and don't imply a universal %. Used = cap − + # remaining (micros, money-safe), clamped to [0, cap]. Re-emits on band + # change (50 → 75 → 90), not every turn — a snapshot, not a live ticker. + _lim = state.subscription_limit_micros or 0 + _used_micros = max(0, min(_lim, _lim - state.subscription_micros)) + _used_usd = f"{_used_micros / 1_000_000:.2f}" if _lim else "?" + _glyph = "⚠" if _level == "warn" else "•" to_show.append( AgentNotice( - text=f"{'⚠' if _level == 'warn' else '•'} Credits {target_band}% used · ${_cap_usd} cap", + text=f"{_glyph} You've used ${_used_usd} of your ${_cap_usd} cap", level=_level, kind=CREDITS_NOTICE_KIND, key=CREDITS_USAGE_KEY, diff --git a/tests/agent/test_credits_policy.py b/tests/agent/test_credits_policy.py index de106dadb90e..ba7142d14aed 100644 --- a/tests/agent/test_credits_policy.py +++ b/tests/agent/test_credits_policy.py @@ -569,7 +569,8 @@ class TestTopUpSuppression: assert latch["usage_band"] is None to_show, _ = evaluate_credits_notices(state_with_fraction(0.95), latch) n = next(n for n in to_show if n.key == "credits.usage") - assert "90%" in n.text + # uf 0.95 of a $20 cap → used = $19.00 (cap − remaining, clamped). + assert "$19.00" in n.text assert latch["usage_band"] == 90 def test_grant_spent_still_fires_with_topup(self): @@ -645,7 +646,8 @@ class TestUsageBands: evaluate_credits_notices(state_with_fraction(0.10), latch) # prime to_show, _ = evaluate_credits_notices(state_with_fraction(0.55), latch) n = next(n for n in to_show if n.key == "credits.usage") - assert "50%" in n.text and n.level == "info" + # uf 0.55 of a $20 cap → used = $11.00; band 50 fires at info level. + assert "$11.00" in n.text and n.level == "info" assert latch["usage_band"] == 50 def test_75_band_fires_warn(self): @@ -653,7 +655,8 @@ class TestUsageBands: evaluate_credits_notices(state_with_fraction(0.10), latch) to_show, _ = evaluate_credits_notices(state_with_fraction(0.80), latch) n = next(n for n in to_show if n.key == "credits.usage") - assert "75%" in n.text and n.level == "warn" + # uf 0.80 of a $20 cap → used = $16.00; band 75 fires at warn level. + assert "$16.00" in n.text and n.level == "warn" assert latch["usage_band"] == 75 def test_climb_replaces_band(self): @@ -663,15 +666,15 @@ class TestUsageBands: # 55% → 50 band evaluate_credits_notices(state_with_fraction(0.55), latch) assert latch["usage_band"] == 50 - # 80% → climbs to 75, clearing the 50 line + # 80% → climbs to 75, clearing the 50 line (used = $16.00 of $20) to_show, to_clear = evaluate_credits_notices(state_with_fraction(0.80), latch) assert "credits.usage" in to_clear - assert "75%" in self._band_text(to_show) + assert "$16.00" in self._band_text(to_show) assert latch["usage_band"] == 75 - # 95% → climbs to 90 + # 95% → climbs to 90 (used = $19.00 of $20) to_show, to_clear = evaluate_credits_notices(state_with_fraction(0.95), latch) assert "credits.usage" in to_clear - assert "90%" in self._band_text(to_show) + assert "$19.00" in self._band_text(to_show) assert latch["usage_band"] == 90 def test_step_down_on_recovery(self): @@ -680,13 +683,13 @@ class TestUsageBands: evaluate_credits_notices(state_with_fraction(0.10), latch) evaluate_credits_notices(state_with_fraction(0.95), latch) assert latch["usage_band"] == 90 - # drop to 80% → steps down to 75 + # drop to 80% → steps down to 75 (used = $16.00 of $20) to_show, to_clear = evaluate_credits_notices(state_with_fraction(0.80), latch) assert "credits.usage" in to_clear - assert "75%" in self._band_text(to_show) - # drop to 55% → steps down to 50 + assert "$16.00" in self._band_text(to_show) + # drop to 55% → steps down to 50 (used = $11.00 of $20) to_show, _ = evaluate_credits_notices(state_with_fraction(0.55), latch) - assert "50%" in self._band_text(to_show) + assert "$11.00" in self._band_text(to_show) # drop below 50% → clears entirely to_show, to_clear = evaluate_credits_notices(state_with_fraction(0.10), latch) assert "credits.usage" in to_clear From d010220588baa1c7fe4326d7086ae651f301c716 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Thu, 23 Jul 2026 01:13:18 -0500 Subject: [PATCH 15/17] fix(desktop): de-dupe credit toast icon, band-color the figure, split detail Three fixes to how agent credit notices render as toasts: - Strip the leading severity glyph (the toast already draws a kind icon, so the raw text doubled it). Native OS notifications keep the glyph (no icon there). - Icon top-margin is now 0.42ch (font-relative) instead of a fixed rem. - Band-color the $used figure (semibold) by $used/$cap: muted <75%, --ui-orange >=75%, --ui-red >=90% (depleted red, restored green), reusing the existing --ui-* usage palette. Icon shares the accent. - Split a trailing '. detail' into a muted secondary line (title+description convention) instead of an inline middot. Generic 'accentColor' + 'meta' slots on the notification; degrades gracefully when a notice has no figure. --- apps/desktop/src/components/notifications.tsx | 41 ++++++- apps/desktop/src/store/agent-notices.test.ts | 88 +++++++++++++-- apps/desktop/src/store/agent-notices.ts | 105 +++++++++++++++++- apps/desktop/src/store/notifications.ts | 8 ++ 4 files changed, 225 insertions(+), 17 deletions(-) diff --git a/apps/desktop/src/components/notifications.tsx b/apps/desktop/src/components/notifications.tsx index ec6051843cd5..465e501f54ce 100644 --- a/apps/desktop/src/components/notifications.tsx +++ b/apps/desktop/src/components/notifications.tsx @@ -1,5 +1,5 @@ import { useStore } from '@nanostores/react' -import { type ReactNode, useEffect, useRef, useState } from 'react' +import { type CSSProperties, type ReactNode, useEffect, useRef, useState } from 'react' import { createPortal } from 'react-dom' import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert' @@ -162,6 +162,30 @@ function BottomRightStack({ ) } +// Emphasize only the leading money figure ("$16.00" — the amount used) with the +// accent color (semibold), leaving the rest of the line in its default muted +// tone. No accent, or no figure in the message → render the text untouched. +function renderMessage(message: string, accent?: string): ReactNode { + const match = accent ? /\$\d+(?:\.\d{2})?/.exec(message) : null + + if (!match) { + return message + } + + const start = match.index + const end = start + match[0].length + + return ( + <> + {message.slice(0, start)} + + {match[0]} + + {message.slice(end)} + + ) +} + function NotificationItem({ notification }: { notification: AppNotification }) { const styles = tone[notification.kind] const Icon = styles.icon @@ -169,6 +193,12 @@ function NotificationItem({ notification }: { notification: AppNotification }) { const { t } = useI18n() const copy = t.notifications + // Nudge the icon down to sit on the first text line, in `ch` so it tracks the + // toast's font size instead of a fixed rem. `accentColor` (when set) tints the + // icon + message as a severity ramp, overriding the kind's default color. + const accent = notification.accentColor + const iconStyle: CSSProperties = { marginTop: '0.42ch', ...(accent ? { color: accent } : {}) } + return ( {notification.icon ? ( - + ) : ( - + )}
{notification.title && {notification.title}} -

{notification.message}

+

{renderMessage(notification.message, accent)}

+ {notification.meta && ( +

{notification.meta}

+ )} {hasDetail && } {notification.action && (