From 9fc9c15b4a2d42bfcdf07d7e279043e25cba6810 Mon Sep 17 00:00:00 2001 From: ethernet Date: Wed, 29 Apr 2026 21:33:00 -0400 Subject: [PATCH] fix(banner): show correct update status on nix-built hermes (#17550) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit check_for_updates() looked at __file__.parent.parent for a .git dir to diff against origin/main. A nix-built hermes lives in /nix/store with no .git there, so the check fell through to whatever editable-install dev checkout last populated ~/.hermes/.update_check, producing stale "X commits behind" warnings right after a fresh `nix run --refresh`. Embed the locked flake rev into the wrapper as HERMES_REVISION (only on clean builds — dirty refs don't represent any upstream commit). When set, banner.py compares it to upstream main via `git ls-remote` instead of inspecting a local checkout, and the cache key includes the rev so nix updates invalidate immediately. Without local history we can't count commits, so the message is a plain "update available" with no suggested command — nix users may install via `nix run`, profile, system flake, or home-manager, and we don't know which. Also bump web/package-lock.json npmDepsHash via `nix run .#fix-lockfiles`. --- hermes_cli/banner.py | 122 +++++++++++++++++++++++++++++------------- nix/hermes-agent.nix | 5 ++ nix/overlays.nix | 1 + nix/packages.nix | 3 ++ nix/web.nix | 2 +- web/package-lock.json | 26 +-------- 6 files changed, 97 insertions(+), 62 deletions(-) diff --git a/hermes_cli/banner.py b/hermes_cli/banner.py index d46c853997..c8446f04d9 100644 --- a/hermes_cli/banner.py +++ b/hermes_cli/banner.py @@ -5,6 +5,7 @@ Pure display functions with no HermesCLI state dependency. import json import logging +import os import shutil import subprocess import threading @@ -122,35 +123,36 @@ def get_available_skills() -> Dict[str, List[str]]: # Cache update check results for 6 hours to avoid repeated git fetches _UPDATE_CHECK_CACHE_SECONDS = 6 * 3600 +# Sentinel returned when we know an update exists but can't count commits +# (e.g. nix-built hermes — no local git history to count against). +UPDATE_AVAILABLE_NO_COUNT = -1 -def check_for_updates() -> Optional[int]: - """Check how many commits behind origin/main the local repo is. +_UPSTREAM_REPO_URL = "https://github.com/NousResearch/hermes-agent.git" - Does a ``git fetch`` at most once every 6 hours (cached to - ``~/.hermes/.update_check``). Returns the number of commits behind, - or ``None`` if the check fails or isn't applicable. + +def _check_via_rev(local_rev: str) -> Optional[int]: + """Compare an embedded git revision to upstream main via ls-remote. + + Returns 0 if up-to-date, ``UPDATE_AVAILABLE_NO_COUNT`` if behind, + or ``None`` on failure. """ - hermes_home = get_hermes_home() - repo_dir = hermes_home / "hermes-agent" - cache_file = hermes_home / ".update_check" - - # Must be a git repo — fall back to project root for dev installs - if not (repo_dir / ".git").exists(): - repo_dir = Path(__file__).parent.parent.resolve() - if not (repo_dir / ".git").exists(): - return None - - # Read cache - now = time.time() try: - if cache_file.exists(): - cached = json.loads(cache_file.read_text()) - if now - cached.get("ts", 0) < _UPDATE_CHECK_CACHE_SECONDS: - return cached.get("behind") + result = subprocess.run( + ["git", "ls-remote", _UPSTREAM_REPO_URL, "refs/heads/main"], + capture_output=True, text=True, timeout=10, + ) except Exception: - pass + return None + if result.returncode != 0 or not result.stdout: + return None + upstream_rev = result.stdout.split()[0] + if not upstream_rev: + return None + return 0 if upstream_rev == local_rev else UPDATE_AVAILABLE_NO_COUNT - # Fetch latest refs (fast — only downloads ref metadata, no files) + +def _check_via_local_git(repo_dir: Path) -> Optional[int]: + """Count commits behind origin/main in a local checkout.""" try: subprocess.run( ["git", "fetch", "origin", "--quiet"], @@ -160,7 +162,6 @@ def check_for_updates() -> Optional[int]: except Exception: pass # Offline or timeout — use stale refs, that's fine - # Count commits behind try: result = subprocess.run( ["git", "rev-list", "--count", "HEAD..origin/main"], @@ -168,15 +169,52 @@ def check_for_updates() -> Optional[int]: cwd=str(repo_dir), ) if result.returncode == 0: - behind = int(result.stdout.strip()) - else: - behind = None + return int(result.stdout.strip()) except Exception: - behind = None + pass + return None - # Write cache + +def check_for_updates() -> Optional[int]: + """Check whether a Hermes update is available. + + Two paths: if ``HERMES_REVISION`` is set (nix builds embed it), compare + it to upstream main via ``git ls-remote``. Otherwise look for a local + git checkout and count commits behind ``origin/main``. + + Returns the number of commits behind, ``UPDATE_AVAILABLE_NO_COUNT`` (-1) + if behind but the count is unknown, ``0`` if up-to-date, or ``None`` if + the check failed or doesn't apply. Cached for 6 hours. + """ + hermes_home = get_hermes_home() + cache_file = hermes_home / ".update_check" + embedded_rev = os.environ.get("HERMES_REVISION") or None + + # Read cache — invalidate if the embedded rev has changed since last check + now = time.time() try: - cache_file.write_text(json.dumps({"ts": now, "behind": behind})) + if cache_file.exists(): + cached = json.loads(cache_file.read_text()) + if ( + now - cached.get("ts", 0) < _UPDATE_CHECK_CACHE_SECONDS + and cached.get("rev") == embedded_rev + ): + return cached.get("behind") + except Exception: + pass + + if embedded_rev: + behind = _check_via_rev(embedded_rev) + else: + repo_dir = hermes_home / "hermes-agent" + if not (repo_dir / ".git").exists(): + repo_dir = Path(__file__).parent.parent.resolve() + if not (repo_dir / ".git").exists(): + return None + behind = _check_via_local_git(repo_dir) + + try: + cache_file.write_text(json.dumps({"ts": now, "behind": behind, "rev": embedded_rev})) except Exception: pass @@ -549,13 +587,23 @@ def build_welcome_banner(console: Console, model: str, cwd: str, # Update check — use prefetched result if available try: behind = get_update_result(timeout=0.5) - if behind and behind > 0: - from hermes_cli.config import recommended_update_command - commits_word = "commit" if behind == 1 else "commits" - right_lines.append( - f"[bold yellow]⚠ {behind} {commits_word} behind[/]" - f"[dim yellow] — run [bold]{recommended_update_command()}[/bold] to update[/]" - ) + if behind is not None and behind != 0: + from hermes_cli.config import get_managed_update_command, recommended_update_command + if behind > 0: + commits_word = "commit" if behind == 1 else "commits" + right_lines.append( + f"[bold yellow]⚠ {behind} {commits_word} behind[/]" + f"[dim yellow] — run [bold]{recommended_update_command()}[/bold] to update[/]" + ) + else: + # UPDATE_AVAILABLE_NO_COUNT: nix-built hermes; we know an update + # exists but not by how much, and we don't know how the user + # installed it (nix run, profile, system flake, home-manager). + managed_cmd = get_managed_update_command() + line = "[bold yellow]⚠ update available[/]" + if managed_cmd: + line += f"[dim yellow] — run [bold]{managed_cmd}[/bold][/]" + right_lines.append(line) except Exception: pass # Never break the banner over an update check diff --git a/nix/hermes-agent.nix b/nix/hermes-agent.nix index 85ba71fb13..b6f4e76e96 100644 --- a/nix/hermes-agent.nix +++ b/nix/hermes-agent.nix @@ -19,6 +19,10 @@ pyproject-nix, pyproject-build-systems, npm-lockfile-fix, + # Locked git revision of the flake source — embedded so banner.py can + # check for updates without needing a local .git directory. Null for + # impure / dirty builds where flakes can't determine a rev. + rev ? null, # Overridable parameters extraPythonPackages ? [ ], }: @@ -98,6 +102,7 @@ stdenv.mkDerivation { --set HERMES_TUI_DIR $out/ui-tui \ --set HERMES_PYTHON ${hermesVenv}/bin/python3 \ --set HERMES_NODE ${nodejs_22}/bin/node \ + ${lib.optionalString (rev != null) ''--set HERMES_REVISION ${rev} \''} ${lib.optionalString (extraPythonPackages != [ ]) ''--suffix PYTHONPATH : "${pythonPath}"''} '') [ diff --git a/nix/overlays.nix b/nix/overlays.nix index 4d7bb2a121..474e57d852 100644 --- a/nix/overlays.nix +++ b/nix/overlays.nix @@ -5,6 +5,7 @@ hermes-agent = final.callPackage ./hermes-agent.nix { inherit (inputs) uv2nix pyproject-nix pyproject-build-systems; npm-lockfile-fix = inputs.npm-lockfile-fix.packages.${final.stdenv.hostPlatform.system}.default; + rev = inputs.self.rev or null; }; }; } diff --git a/nix/packages.nix b/nix/packages.nix index f27c43a75e..d95133d26a 100644 --- a/nix/packages.nix +++ b/nix/packages.nix @@ -7,6 +7,9 @@ hermesAgent = pkgs.callPackage ./hermes-agent.nix { inherit (inputs) uv2nix pyproject-nix pyproject-build-systems; npm-lockfile-fix = inputs'.npm-lockfile-fix.packages.default; + # Only embed clean revs — dirtyRev doesn't represent any upstream + # commit, so comparing it would always claim "update available". + rev = inputs.self.rev or null; }; in { diff --git a/nix/web.nix b/nix/web.nix index bff29983d6..7084a04c8e 100644 --- a/nix/web.nix +++ b/nix/web.nix @@ -4,7 +4,7 @@ let src = ../web; npmDeps = pkgs.fetchNpmDeps { inherit src; - hash = "sha256-+B2+Fe4djPzHHcUXRx+m0cuyaopAhW0PcHsMgYfV5VE="; + hash = "sha256-HWB1piIPglTXbzQHXFYHLgVZIbDb60esupXSQGa1+lI="; }; npm = hermesNpmLib.mkNpmPassthru { folder = "web"; attr = "web"; pname = "hermes-web"; }; diff --git a/web/package-lock.json b/web/package-lock.json index 2c6377b4f2..7f987c5a1d 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -76,7 +76,6 @@ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -1125,7 +1124,6 @@ "resolved": "https://registry.npmjs.org/@observablehq/plot/-/plot-0.6.17.tgz", "integrity": "sha512-/qaXP/7mc4MUS0s4cPPFASDRjtsWp85/TbfsciqDgU1HwYixbSbbytNuInD8AcTYC3xaxACgVX06agdfQy9W+g==", "license": "ISC", - "peer": true, "dependencies": { "d3": "^7.9.0", "interval-tree-1d": "^1.0.0", @@ -1778,7 +1776,6 @@ "resolved": "https://registry.npmjs.org/@react-three/fiber/-/fiber-9.6.0.tgz", "integrity": "sha512-90abYK2q5/qDM+GACs9zRvc5KhEEpEWqWlHSd64zTPNxg+9wCJvTfyD9x2so7hlQhjRYO1Fa6flR3BC/kpTFkA==", "license": "MIT", - "peer": true, "dependencies": { "@babel/runtime": "^7.17.8", "@types/webxr": "*", @@ -2484,7 +2481,6 @@ "integrity": "sha512-A1sre26ke7HDIuY/M23nd9gfB+nrmhtYyMINbjI1zHJxYteKR6qSMX56FsmjMcDb3SMcjJg5BiRRgOCC/yBD0g==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~7.16.0" } @@ -2494,7 +2490,6 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -2505,7 +2500,6 @@ "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", "devOptional": true, "license": "MIT", - "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -2570,7 +2564,6 @@ "integrity": "sha512-HDQH9O/47Dxi1ceDhBXdaldtf/WV9yRYMjbjCuNk3qnaTD564qwv61Y7+gTxwxRKzSrgO5uhtw584igXVuuZkA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.59.1", "@typescript-eslint/types": "8.59.1", @@ -2899,7 +2892,6 @@ "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -3052,7 +3044,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", @@ -3560,7 +3551,6 @@ "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", "license": "ISC", - "peer": true, "engines": { "node": ">=12" } @@ -3874,7 +3864,6 @@ "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -4253,8 +4242,7 @@ "version": "3.15.0", "resolved": "https://registry.npmjs.org/gsap/-/gsap-3.15.0.tgz", "integrity": "sha512-dMW4CWBTUK1AEEDeZc1g4xpPGIrSf9fJF960qbTZmN/QwZIWY5wgliS6JWl9/25fpTGJrMRtSjGtOmPnfjZB+A==", - "license": "Standard 'no charge' license: https://gsap.com/standard-license.", - "peer": true + "license": "Standard 'no charge' license: https://gsap.com/standard-license." }, "node_modules/has-flag": { "version": "4.0.0", @@ -4560,7 +4548,6 @@ "resolved": "https://registry.npmjs.org/leva/-/leva-0.10.1.tgz", "integrity": "sha512-BcjnfUX8jpmwZUz2L7AfBtF9vn4ggTH33hmeufDULbP3YgNZ/C+ss/oO3stbrqRQyaOmRwy70y7BGTGO81S3rA==", "license": "MIT", - "peer": true, "dependencies": { "@radix-ui/react-portal": "^1.1.4", "@radix-ui/react-tooltip": "^1.1.8", @@ -4999,7 +4986,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": "^20.0.0 || >=22.0.0" } @@ -5127,7 +5113,6 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -5199,7 +5184,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.5.tgz", "integrity": "sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -5219,7 +5203,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.5.tgz", "integrity": "sha512-J5bAZz+DXMMwW/wV3xzKke59Af6CHY7G4uYLN1OvBcKEsWOs4pQExj86BBKamxl/Ik5bx9whOrvBlSDfWzgSag==", "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -5579,8 +5562,7 @@ "version": "0.180.0", "resolved": "https://registry.npmjs.org/three/-/three-0.180.0.tgz", "integrity": "sha512-o+qycAMZrh+TsE01GqWUxUIKR1AL0S8pq7zDkYOQw8GqfX8b8VoCKYUoHbhiX5j+7hr8XsuHDVU6+gkQJQKg9w==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/tinyglobby": { "version": "0.2.16", @@ -5645,7 +5627,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -5744,7 +5725,6 @@ "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", "license": "MIT", - "peer": true, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } @@ -5760,7 +5740,6 @@ "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.2.tgz", "integrity": "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==", "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -5882,7 +5861,6 @@ "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", "dev": true, "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" }