"""TUI (ui-tui) launcher: node/npm bootstrap, workspace/rebuild checks, argv/env assembly. Split out of ``hermes_cli/main.py``; every moved name is re-imported there, so ``hermes_cli.main.`` keeps resolving (and monkeypatching) as before. Names that stay in main are imported lazily inside the functions that use them (call-time resolution keeps ``hermes_cli.main.`` patches effective and avoids an import cycle). """ import logging import json import os import shutil import subprocess import sys from pathlib import Path from typing import Optional # Log-record parity with the origin module. logger = logging.getLogger("hermes_cli.main") def _read_tui_active_session_file(path: Optional[str]) -> Optional[str]: if not path: return None try: data = json.loads(Path(path).read_text(encoding="utf-8")) sid = str(data.get("session_id") or "").strip() return sid or None except Exception: return None def _print_tui_exit_summary( session_id: Optional[str], active_session_file: Optional[str] = None ) -> None: """Print a shell-visible epilogue after TUI exits.""" from hermes_cli.main import _resolve_last_session target = ( _read_tui_active_session_file(active_session_file) or session_id or _resolve_last_session(source="tui") ) if not target: return db = None try: from hermes_state import SessionDB db = SessionDB() session = db.get_session(target) if not session: return title = db.get_session_title(target) message_count = int(session.get("message_count") or 0) if message_count == 0: return # No real conversation — don't show resume info input_tokens = int(session.get("input_tokens") or 0) output_tokens = int(session.get("output_tokens") or 0) cache_read_tokens = int(session.get("cache_read_tokens") or 0) cache_write_tokens = int(session.get("cache_write_tokens") or 0) reasoning_tokens = int(session.get("reasoning_tokens") or 0) total_tokens = ( input_tokens + output_tokens + cache_read_tokens + cache_write_tokens + reasoning_tokens ) except Exception: return finally: if db is not None: db.close() print() print("Resume this session with:") print(f" hermes --tui --resume {target}") if title: print(f' hermes --tui -c "{title}"') print() print(f"Session: {target}") if title: print(f"Title: {title}") print(f"Messages: {message_count}") print( "Tokens: " f"{total_tokens} (in {input_tokens}, out {output_tokens}, " f"cache {cache_read_tokens + cache_write_tokens}, reasoning {reasoning_tokens})" ) _NPM_LOCK_RUNTIME_KEYS = frozenset( { "ideallyInert", "peer", # npm writes these boolean annotation fields non-deterministically # between the declarative package-lock.json and the hidden actualized # .package-lock.json. The intersection comparison (see # _tui_need_npm_install) already handles the "field present in root # but absent in hidden" case for structured fields like version, # dependencies, license, etc. These boolean flags need explicit # exclusion because when present in *both* lockfiles they may still # differ (e.g. dev: true → stripped in hidden). "dev", "extraneous", "hasInstallScript", "optional", } ) """Lockfile fields npm writes non-deterministically at install time. ``ideallyInert`` is npm's runtime annotation for packages it skipped installing (per-platform opt-outs). ``peer`` is dropped from the hidden ``.package-lock.json`` on dev-dependencies that are *also* declared as peers — the canonical ``package-lock.json`` records the dual role, but npm 9's actualized tree strips it. Neither key represents a real skew between what was declared and what was installed, so we exclude them from the comparison in :func:`_tui_need_npm_install` to avoid false-positive reinstalls on every launch. ``dev``, ``optional``, ``extraneous``, and ``hasInstallScript`` are boolean annotations that npm populates differently in the hidden lock (npm >= 10/11 writes ``extraneous`` into the hidden lock only, and ``dev: true`` from the root lock may be absent or ``false`` in the hidden actualized tree). They never indicate a changed dependency — the authoritative check is the ``resolved``/``integrity`` pair, which the intersection comparison always catches. """ def _workspace_root(dir: Path) -> Path: """Return the npm workspace root for *dir*. In a workspace checkout the single ``package-lock.json`` and hoisted ``node_modules/`` live at the workspace root (the parent of the sub-package directory). Heuristic: if *dir* has a ``package.json`` but **no** ``package-lock.json``, and its **parent** has a ``package-lock.json``, the parent is the workspace root. Otherwise *dir* itself is the root (standalone project or prebuilt-bundle layout). Used by ``_tui_need_npm_install``, ``_make_tui_argv``, and ``_build_web_ui`` so that lockfile/node_modules resolution and ``npm install`` cwd stay consistent — a single helper prevents the checks from diverging if someone accidentally creates a sub-package lockfile (e.g. running ``npm install`` in the wrong directory). """ if ( (dir / "package.json").is_file() and not (dir / "package-lock.json").is_file() and (dir.parent / "package-lock.json").is_file() ): return dir.parent return dir def _termux_workspace_install_context( dir: Path, *, include_child_workspaces: bool = False ) -> tuple[Path, tuple[str, ...]]: """Return Termux-only ``(cwd, npm_args)`` for installing deps for *dir* only.""" ws_root = _workspace_root(dir) if ws_root == dir: return dir, () try: workspace = dir.relative_to(ws_root).as_posix() except ValueError: return ws_root, () workspace_args: list[str] = ["--workspace", workspace] if include_child_workspaces: packages_dir = dir / "packages" if packages_dir.is_dir(): for child in sorted(packages_dir.iterdir()): if child.is_dir() and (child / "package.json").is_file(): workspace_args.extend( ["--workspace", child.relative_to(ws_root).as_posix()] ) workspace_args.append("--include-workspace-root=false") return ws_root, tuple(workspace_args) def _npm_lock_workspace_closure(packages: dict, starts) -> Optional[set]: """Package-map keys reachable from the selected workspaces via npm resolution. *starts* is the set of workspace keys the launch install explicitly scopes to (a single str is accepted for convenience). ``devDependencies`` are followed for **each** of those workspaces, since ``npm install`` installs the dev toolchain for every workspace it selects. Returns ``None`` when none of *starts* are present in *packages* so callers fall back to the full-lockfile comparison. The launch install is scoped with ``npm install --workspace ui-tui`` (see ``_make_tui_argv``), so only the ui-tui workspace's dependency closure is written to the hidden ``.package-lock.json``. On Termux it additionally selects ui-tui's child ``packages/*`` workspaces, so their devDependencies join the closure too. The shared root ``package-lock.json`` additionally lists every *other* workspace's deps (``apps/desktop``, ``web``, …); comparing the two in full reports those unrelated packages as "missing" and reinstalls on every launch (#66978). Keys follow npm's v3 ``packages`` map (``""`` root, ``ui-tui`` / ``apps/desktop`` workspace members, ``node_modules/`` hoisted deps, ``/node_modules/`` nested deps). Dependency names resolve to a key by walking up ``node_modules`` ancestors, mirroring node resolution, and workspace symlinks (``link: true``) are followed to their real entry so a linked workspace's own deps join the closure. """ start_set = {starts} if isinstance(starts, str) else {s for s in starts if s} present = [s for s in start_set if s in packages] if not present: return None def resolve(from_key: str, dep: str) -> Optional[str]: base = from_key while True: prefix = f"{base}/" if base else "" candidate = f"{prefix}node_modules/{dep}" if candidate in packages: return candidate if not base: return None base = base.rsplit("/", 1)[0] if "/" in base else "" seen: set = set() stack = list(present) while stack: key = stack.pop() if key in seen: continue seen.add(key) entry = packages.get(key) if not isinstance(entry, dict): continue # Workspace symlink (e.g. node_modules/@hermes/ink → ui-tui/packages/…): # follow to the real package entry so its dependencies join the closure. resolved = entry.get("resolved") if entry.get("link") and isinstance(resolved, str) and resolved in packages: stack.append(resolved) # devDependencies are installed for each explicitly-selected workspace # (its build toolchain), but not for transitive deps. fields = ["dependencies", "optionalDependencies", "peerDependencies"] if key in start_set: fields.append("devDependencies") for field in fields: deps = entry.get(field) if not isinstance(deps, dict): continue for dep in deps: target = resolve(key, dep) if target is not None: stack.append(target) return seen def _tui_selected_workspace_keys(tui_dir: Path, ws_root: Path) -> set: """Lock-map keys for the workspaces the launch install scopes to. Mirrors ``_make_tui_argv``: always the ui-tui workspace, plus its child ``packages/*`` workspaces on Termux (where ``include_child_workspaces=True`` in ``_termux_workspace_install_context``). ``npm install`` installs the devDependencies of every workspace it selects, so the freshness closure must treat each as a dev-included root — otherwise a devDependency unique to a selected child is dropped from the closure and a genuine missing package slips past the check. Returns an empty set when ui-tui can't be located under *ws_root*, so the caller falls back to the full comparison. """ from hermes_cli.main import _is_termux_startup_environment try: primary = tui_dir.relative_to(ws_root).as_posix() except ValueError: return set() keys = {primary} if _is_termux_startup_environment(): packages_dir = tui_dir / "packages" if packages_dir.is_dir(): for child in sorted(packages_dir.iterdir()): if child.is_dir() and (child / "package.json").is_file(): try: keys.add(child.relative_to(ws_root).as_posix()) except ValueError: continue return keys def _tui_need_npm_install(root: Path) -> bool: """True when @hermes/ink is missing or node_modules is behind package-lock.json. Prebuilt bundle mode: when ``dist/entry.js`` exists and there is no ``package-lock.json`` (nix install layout only ships ``dist/`` + ``package.json``), skip reinstall entirely — the bundle is self-contained and there is nothing to install. With npm workspaces the single ``package-lock.json`` and the hoisted ``node_modules/`` live at the workspace root (the parent of the ``ui-tui/`` directory). The lockfile / ink / marker checks use that workspace root; only the prebuilt-bundle sentinel stays relative to *root* (``ui-tui/dist/entry.js``). Compares ``package-lock.json`` against ``node_modules/.package-lock.json`` (npm's hidden lockfile) by **content**, not mtime: git checkouts and npm rewrites can bump the root lockfile's timestamp even when installed deps already match, which used to trigger a spurious "Installing TUI dependencies" on every launch. For each entry in the root lock's ``packages`` map: - missing from hidden lock → reinstall (unless the entry is marked ``optional`` or ``peer``, which npm may intentionally skip per platform) - present in both → compare only the **intersection** of fields (after stripping ``_NPM_LOCK_RUNTIME_KEYS``). npm's hidden lock intentionally omits many metadata fields (version, license, engines, dependencies, funding, etc.) — those one-side-only fields are normal npm artefacts, not real skew. A real version/dependency change will change ``resolved``/``integrity``, which are present in both locks and will be caught by the intersection comparison. Extra entries that exist only in the hidden lock are ignored — stale transitives left over from a removed dependency don't break runtime and we'd rather not force a reinstall for them. Falls back to mtime comparison if either lockfile is unparseable. """ from hermes_cli.main import _npm_lock_workspace_closure # Prebuilt self-contained bundle (nix / packaged release): no lockfile # shipped, dist/entry.js is the single runtime artefact. entry = root / "dist" / "entry.js" # With npm workspaces the lockfile lives at the workspace root. ws_root = _workspace_root(root) lock = ws_root / "package-lock.json" if entry.is_file() and not lock.is_file(): return False ink = ws_root / "node_modules" / "@hermes" / "ink" / "package.json" if not ink.is_file(): return True if not lock.is_file(): return False marker = ws_root / "node_modules" / ".package-lock.json" if not marker.is_file(): return True # Compare lockfile contents, not mtimes: git checkouts and npm rewrites # can bump the root lockfile timestamp even when installed deps already # match. Fall back to mtime when either file is unparseable. try: wanted = json.loads(lock.read_text(encoding="utf-8")).get("packages") or {} installed = json.loads(marker.read_text(encoding="utf-8")).get("packages") or {} except (OSError, UnicodeDecodeError, json.JSONDecodeError): return lock.stat().st_mtime > marker.stat().st_mtime def entries_differ(pkg: dict, installed_pkg: dict) -> bool: # Only compare keys present in *both* lockfiles with non-null values. # npm's hidden .package-lock.json intentionally omits many metadata # fields the root lock records (version, dependencies, license, # engines, bin, ...), and npm >= 10/11 writes a further *reduced* # hidden lockfile that stores some of them as null. Missing- or # null-on-one-side is a normal npm artefact, not a real skew. The # authoritative fields "resolved" and "integrity" are present in both # locks for installed packages, so a genuinely stale install (root # lockfile bumped while node_modules is behind) still differs on them. a = {k: v for k, v in pkg.items() if k not in _NPM_LOCK_RUNTIME_KEYS} b = { k: v for k, v in installed_pkg.items() if k not in _NPM_LOCK_RUNTIME_KEYS } for k in a.keys() & b.keys(): if a[k] is None or b[k] is None: continue if a[k] != b[k]: return True return False # In a shared workspace checkout the launch install is scoped to the ui-tui # workspace (plus its child packages/* workspaces on Termux), so only that # dependency closure lands in the hidden lock. Limit the comparison to the # same selected-workspace closure so unrelated workspace deps (apps/desktop, # web, …) don't force a reinstall every launch (#66978). Standalone / # own-lockfile layouts (ws_root == root) do a full install, so keep the full # comparison; a missing/unlocatable workspace falls back to it too. closure: Optional[set] = None if ws_root != root: selected = _tui_selected_workspace_keys(root, ws_root) if selected: closure = _npm_lock_workspace_closure(wanted, selected) for name, pkg in wanted.items(): if not name: continue if closure is not None and name not in closure: continue if not isinstance(pkg, dict): continue if name not in installed: # Workspace link entries (`"link": true`, paths outside # node_modules/ like `apps/desktop`, `node_modules/web`) are never # materialized by a partial `npm install --workspace ui-tui` — # they're deliberately skipped (see #38772) and would otherwise # force a reinstall on every launch. if pkg.get("optional") or pkg.get("peer") or pkg.get("link"): continue if not name.startswith("node_modules/"): continue return True if isinstance(installed[name], dict) and entries_differ( pkg, installed[name] ): return True return False _TUI_BUILD_INPUT_DIRS = ( "src", "packages/hermes-ink/src", ) _TUI_BUILD_INPUT_FILES = ( "package.json", "package-lock.json", "tsconfig.json", "tsconfig.build.json", "babel.compiler.config.cjs", "scripts/build.mjs", "packages/hermes-ink/package.json", "packages/hermes-ink/index.js", "packages/hermes-ink/text-input.js", ) _TUI_BUILD_INPUT_SUFFIXES = frozenset( {".cjs", ".js", ".jsx", ".json", ".mjs", ".ts", ".tsx"} ) def _iter_tui_build_inputs(root: Path): """Yield source/config files that affect ``ui-tui/dist/entry.js``.""" for rel in _TUI_BUILD_INPUT_FILES: path = root / rel if path.is_file(): yield path for rel in _TUI_BUILD_INPUT_DIRS: base = root / rel if not base.is_dir(): continue for path in base.rglob("*"): if path.is_file() and path.suffix in _TUI_BUILD_INPUT_SUFFIXES: yield path def _tui_need_rebuild(root: Path) -> bool: """True when ``dist/entry.js`` is missing or older than TUI inputs. The TUI bundle is self-contained. Rebuilding it on every launch adds a visible cold-start tax on slow Termux CPUs, while a simple mtime freshness check still rebuilds immediately after source updates, dependency updates, or local edits. Set ``HERMES_TUI_FORCE_BUILD=1`` to force the old behaviour. """ force = (os.environ.get("HERMES_TUI_FORCE_BUILD") or "").strip().lower() if force in {"1", "true", "yes", "on"}: return True entry = root / "dist" / "entry.js" try: output_mtime = entry.stat().st_mtime except OSError: return True for path in _iter_tui_build_inputs(root): try: if path.stat().st_mtime > output_mtime: return True except OSError: return True return False def _ensure_tui_node() -> None: """Make sure `node` + `npm` are on PATH for the TUI. If either is missing and scripts/lib/node-bootstrap.sh is available, source it and call `ensure_node` (fnm/nvm/proto/brew/bundled cascade). After install, capture the resolved node binary path from the bash subprocess and prepend its directory to os.environ["PATH"] so shutil.which finds the new binaries in this Python process — regardless of which version manager was used (nvm, fnm, proto, brew, or the bundled fallback). Idempotent no-op when node+npm are already discoverable. Set ``HERMES_SKIP_NODE_BOOTSTRAP=1`` to disable auto-install. """ from hermes_cli.main import PROJECT_ROOT if shutil.which("node") and shutil.which("npm"): return if os.environ.get("HERMES_SKIP_NODE_BOOTSTRAP"): return helper = PROJECT_ROOT / "scripts" / "lib" / "node-bootstrap.sh" if not helper.is_file(): return from hermes_constants import get_hermes_home hermes_home = str(get_hermes_home()) try: # Helper writes logs to stderr; we ask bash to print `command -v node` # on stdout once ensure_node succeeds. Subshell PATH edits don't leak # back into Python, so the stdout capture is the bridge. result = subprocess.run( [ "bash", "-c", f'source "{helper}" >&2 && ensure_node >&2 && command -v node', ], env={**os.environ, "HERMES_HOME": hermes_home}, capture_output=True, text=True, encoding="utf-8", errors="replace", check=False, ) except (OSError, subprocess.SubprocessError): return parts = os.environ.get("PATH", "").split(os.pathsep) extras: list[Path] = [] resolved = (result.stdout or "").strip() if resolved: extras.append(Path(resolved).resolve().parent) extras.extend([Path(hermes_home) / "node" / "bin", Path.home() / ".local" / "bin"]) for extra in extras: s = str(extra) if extra.is_dir() and s not in parts: parts.insert(0, s) os.environ["PATH"] = os.pathsep.join(parts) def _find_bundled_tui(hermes_cli_dir: Path | None = None) -> Path | None: """Find a pre-built TUI entry.js bundled in the wheel.""" if hermes_cli_dir is None: hermes_cli_dir = Path(__file__).parent bundled = hermes_cli_dir / "tui_dist" / "entry.js" return bundled if bundled.is_file() else None def _restore_tui_workspace(tui_dir: Path) -> bool: """Try to restore a missing ``ui-tui/`` from git, returning True on success. On Windows an antivirus / NTFS filter driver can leave tracked ``ui-tui/`` files deleted in the working tree after ``hermes update`` (HEAD stays intact; the files just vanish — see issue #49145). Those files are tracked, so ``git restore`` puts them back deterministically. Best-effort: returns False (rather than raising) when git is unavailable, this isn't a checkout, or the restore leaves the directory still missing — the caller then prints the manual-recovery message. """ git = shutil.which("git") if not git or not (tui_dir.parent / ".git").exists(): return False try: subprocess.run( [git, "restore", "--", tui_dir.name], cwd=str(tui_dir.parent), capture_output=True, text=True, encoding="utf-8", errors="replace", check=False, ) except OSError: return False return tui_dir.is_dir() def _ensure_tui_workspace(tui_dir: Path) -> None: """Ensure ``ui-tui/`` exists before any npm/node subprocess uses it as cwd. Without this, a missing workspace falls through to ``subprocess.run(..., cwd=)``, which crashes with ``NotADirectoryError`` (``WinError 267`` on Windows) instead of a usable message (#49145). We first try to self-heal via ``git restore``; only if that can't recover the directory do we abort with concrete manual-recovery steps. """ if tui_dir.is_dir(): return if _restore_tui_workspace(tui_dir): if not os.environ.get("HERMES_QUIET"): print(f"Restored missing TUI workspace: {tui_dir}") return print( "Error: the TUI workspace is missing from this Hermes checkout.\n" f"Expected directory: {tui_dir}\n" "This usually means `hermes update` left tracked ui-tui files deleted.\n" "Recovery:\n" " 1. From the Hermes checkout, run `git restore -- ui-tui`\n" " 2. Run `npm install --silent --no-fund --no-audit --progress=false`\n" " 3. Retry `hermes --tui`\n" "If the checkout is still inconsistent, run `hermes update --force`.", file=sys.stderr, ) sys.exit(1) def _npm_lifecycle_env(env: dict[str, str] | None = None) -> dict[str, str]: """Build a clean environment for the pinned UI toolchain lifecycle.""" run_env = {**os.environ, **(env or {}), "CI": "1"} # esbuild treats this as an executable override. If a shell points it at a # different release, the pinned package's postinstall rejects that binary. run_env.pop("ESBUILD_BINARY_PATH", None) return run_env def _make_tui_argv(tui_dir: Path, tui_dev: bool) -> tuple[list[str], Path]: """TUI: --dev → tsx src; else node dist (HERMES_TUI_DIR prebuilt or esbuild).""" from hermes_cli.main import _ensure_tui_node, _find_bundled_tui, _is_termux_startup_environment, _tui_need_npm_install, _tui_need_rebuild _ensure_tui_node() def _node_bin(bin: str) -> str: if bin == "node": env_node = os.environ.get("HERMES_NODE") if env_node and os.path.isfile(env_node) and os.access(env_node, os.X_OK): return env_node # find_node_executable() prefers the managed $HERMES_HOME/node tree, # which is not on PATH — a bare which() would declare "node not found" # and exit on an install whose only Node is the one Hermes installed, # and would pick a system Node over the managed one when both exist. from hermes_constants import find_node_executable path = find_node_executable(bin) if not path and bin == "node": try: from hermes_cli.dep_ensure import ensure_dependency if ensure_dependency("node"): path = find_node_executable("node") except Exception: pass if not path: print(f"{bin} not found — install Node.js to use the TUI.") sys.exit(1) return path # Footgun: --dev against a prebuilt bundle that has no source/node_modules. ext_dir = os.environ.get("HERMES_TUI_DIR") if tui_dev and ext_dir: print( f"Error: --dev is incompatible with HERMES_TUI_DIR={ext_dir}\n" f"The prebuilt TUI has no source code to hot-reload.\n" f"Unset HERMES_TUI_DIR (e.g. `unset HERMES_TUI_DIR`) to use --dev from a checkout.", file=sys.stderr, ) sys.exit(1) # 1. Prebuilt bundle (nix / packaged release / Docker image): just run it. # # This must run BEFORE _ensure_tui_workspace() below. A prebuilt install # (Docker image, Nix build, or prior `npm run build`) ships # hermes_cli/tui_dist/entry.js but never ships ui-tui/ at all (that # directory only exists in a git checkout) — so requiring the workspace # to exist first made every prebuilt dashboard Chat tab connection # hard-exit before it ever got a chance to try the bundled entry.js it # already has. See #56665. if not tui_dev: if ext_dir: p = Path(ext_dir) if (p / "dist" / "entry.js").is_file(): node = _node_bin("node") return [node, "--expose-gc", str(p / "dist" / "entry.js")], p # 1b. Bundled prebuilt TUI (Docker image, Nix build, or prior npm build) bundled = _find_bundled_tui() if bundled is not None: node = _node_bin("node") return [node, "--expose-gc", str(bundled)], bundled.parent # No prebuilt bundle available (or --dev, which never uses one) — we're # about to npm install/build from source, so the workspace must exist. if not ext_dir: _ensure_tui_workspace(tui_dir) # 2. Normal flow: npm install if needed, always esbuild, then node dist/entry.js. # --dev flow: npm install if needed, then tsx src/entry.tsx. # Existing desktop behaviour runs npm from the workspace root. Termux # scopes the install to ui-tui so launch does not pull desktop/web # dependencies into the hot path. did_install = False termux_startup = _is_termux_startup_environment() termux_need_rebuild = False if termux_startup and not tui_dev: termux_need_rebuild = _tui_need_rebuild(tui_dir) skip_install_for_fresh_termux_bundle = ( termux_startup and not tui_dev and not termux_need_rebuild ) if ( not skip_install_for_fresh_termux_bundle and _tui_need_npm_install(tui_dir) ): npm = _node_bin("npm") if not os.environ.get("HERMES_QUIET"): print("Installing TUI dependencies…") npm_cwd = _workspace_root(tui_dir) # --workspace ui-tui avoids resolving apps/desktop (Electron + node-pty). # See #38772. # When ui-tui/ has its own package-lock.json (e.g. curl install), # _workspace_root() returns tui_dir itself. Passing --workspace in # that case fails because npm cannot find a workspace named "ui-tui" # inside ui-tui/. See #42973. npm_workspace_args: tuple[str, ...] = () if npm_cwd == tui_dir else ("--workspace", "ui-tui") if termux_startup: npm_cwd, npm_workspace_args = _termux_workspace_install_context( tui_dir, include_child_workspaces=True, ) npm_install_cmd = [ npm, "install", *npm_workspace_args, # --include=dev: ui-tui's build toolchain (esbuild, typescript) # lives in devDependencies. An inherited NODE_ENV=production # (e.g. from a container shell or a parent TUI launch) or an # npm `omit=dev` config would silently skip them and the TUI # build would fail. See _run_npm_install_deterministic. "--include=dev", "--silent", "--no-fund", "--no-audit", "--progress=false", ] def _run_tui_install() -> subprocess.CompletedProcess: from hermes_constants import with_hermes_node_path # Managed tree first on PATH: if the EBADENGINE repair below # provisioned a managed Node, npm's shebang/lifecycle scripts must # resolve that node, not the mismatched system one. return subprocess.run( npm_install_cmd, cwd=str(npm_cwd), stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, encoding="utf-8", errors="replace", env=_npm_lifecycle_env(with_hermes_node_path()), ) result = _run_tui_install() if result.returncode != 0: # An npm outside the root package.json's `engines.npm` range fails # here before doing any work; repair once (upgrade a Hermes-managed # npm in place, or provision a managed runtime when the npm belongs # to the user) and retry rather than dumping EBADENGINE at the user. from hermes_cli.npm_engine import maybe_repair_npm_engine combined_output = f"{result.stdout or ''}\n{result.stderr or ''}" repaired_npm = maybe_repair_npm_engine(npm, combined_output) if repaired_npm: npm = repaired_npm npm_install_cmd[0] = repaired_npm result = _run_tui_install() if result.returncode != 0: combined = f"{result.stdout or ''}\n{result.stderr or ''}".strip() preview = "\n".join(combined.splitlines()[-30:]) print("npm install failed.") if preview: print(preview) sys.exit(1) did_install = True if tui_dev: # Keep the local @hermes/ink package exports in sync with source. # --dev runs src/entry.tsx directly, but @hermes/ink resolves through # packages/hermes-ink/dist/entry-exports.js. If that dist bundle is # stale after a pull, newer hooks/components can exist in src while # being missing at runtime (e.g. useCursorAdvance). Prebuild it here. npm = _node_bin("npm") ink_dir = tui_dir / "packages" / "hermes-ink" result = subprocess.run( [npm, "run", "build"], cwd=str(ink_dir), capture_output=True, text=True, encoding="utf-8", errors="replace", env=_npm_lifecycle_env(), ) if result.returncode != 0: combined = f"{result.stdout or ''}{result.stderr or ''}".strip() preview = "\n".join(combined.splitlines()[-30:]) print("TUI dev prebuild failed.") if preview: print(preview) sys.exit(1) tsx = tui_dir / "node_modules" / ".bin" / "tsx" if tsx.exists(): return [str(tsx), "src/entry.tsx"], tui_dir return [npm, "start"], tui_dir # Desktop/dev launches retain the historical "always rebuild" behaviour. # Termux cold starts use the freshness check because esbuild startup is # expensive on old mobile CPUs. should_build = True if termux_startup: should_build = did_install or termux_need_rebuild if should_build: npm = _node_bin("npm") result = subprocess.run( [npm, "run", "build"], cwd=str(tui_dir), capture_output=True, text=True, encoding="utf-8", errors="replace", env=_npm_lifecycle_env(), ) if result.returncode != 0: combined = f"{result.stdout or ''}{result.stderr or ''}".strip() preview = "\n".join(combined.splitlines()[-30:]) print("TUI build failed.") if preview: print(preview) sys.exit(1) node = _node_bin("node") return [node, "--expose-gc", str(tui_dir / "dist" / "entry.js")], tui_dir def _normalize_tui_toolsets(toolsets: object) -> list[str]: """Normalize argparse/Fire-style toolset input for the TUI subprocess.""" try: from hermes_cli.oneshot import _normalize_toolsets return _normalize_toolsets(toolsets) or [] except (AttributeError, ImportError): if not toolsets: return [] raw_items = [toolsets] if isinstance(toolsets, str) else toolsets if not isinstance(raw_items, (list, tuple)): raw_items = [raw_items] normalized: list[str] = [] for item in raw_items: if isinstance(item, str): normalized.extend(part.strip() for part in item.split(",")) else: normalized.append(str(item).strip()) return [item for item in normalized if item] def _read_cgroup_memory_limit() -> Optional[int]: """Return the container memory limit in bytes, or None if unconstrained. Node's V8 heap is NOT cgroup-aware: with a flat ``--max-old-space-size=8192`` it happily grows the heap toward 8GB regardless of the container's real memory limit. In a Docker/k8s container capped below ~9-10GB, the cgroup OOM-killer SIGKILLs Node before V8's own heap monitor ever fires — which runs no JS handler, writes no ``[tui-parent]`` breadcrumb, and the user sees only a bare gateway ``stdin EOF``. Reading the real cgroup limit lets us size the heap cap below it so V8 GCs/exits gracefully instead of being reaped silently. Checks cgroup v2 (``/sys/fs/cgroup/memory.max``) then v1 (``/sys/fs/cgroup/memory/memory.limit_in_bytes``). A literal ``max`` (v2) or the v1 "unlimited" sentinel (a huge near-INT64 value) means no limit. """ candidates = ( "/sys/fs/cgroup/memory.max", # cgroup v2 "/sys/fs/cgroup/memory/memory.limit_in_bytes", # cgroup v1 ) for path in candidates: try: with open(path, "r", encoding="utf-8") as f: raw = f.read().strip() except (OSError, ValueError): continue if raw == "max": return None if not raw: # Blank/empty file: no usable value here. Fall through to the next # candidate (don't mistake an empty v2 file for "unlimited"). continue try: limit = int(raw) except ValueError: continue if limit <= 0: continue # cgroup v1 reports "unlimited" as a huge value (often # 0x7FFFFFFFFFFFF000 ≈ 9.2 EB, sometimes PAGE_COUNTER_MAX). Anything # at/above ~1 PB is effectively unconstrained — treat as no limit. if limit >= (1 << 50): return None return limit return None def _resolve_tui_heap_mb(default_mb: int = 8192) -> int: """Pick a V8 ``--max-old-space-size`` (MB) that fits the container. Returns ``default_mb`` (8192) when unconstrained or when the box is large enough that 8GB fits. In a memory-limited container, returns ~75% of the cgroup limit so the heap + non-heap RSS stays under the cgroup ceiling, clamped to a sane floor (1536MB — below this V8 GC-thrashes and the TUI is barely usable). Never exceeds ``default_mb``. """ from hermes_cli.main import _read_cgroup_memory_limit limit = _read_cgroup_memory_limit() if not limit: return default_mb limit_mb = limit // (1024 * 1024) # Leave headroom for non-heap RSS (Node internals, buffers, the Python # gateway child shares the same cgroup): cap the heap at 75% of the limit. sized = int(limit_mb * 0.75) if sized >= default_mb: return default_mb # Floor so a tiny limit doesn't drive V8 into constant GC. If the container # is smaller than the floor, honor the limit-derived value anyway (better a # graceful V8 exit than a silent cgroup kill). return max(1536, sized) if limit_mb > 2048 else sized def _safe_tui_cwd(env: Optional[dict] = None) -> str: """Return a stable cwd value for the Node TUI child environment.""" from hermes_cli.main import PROJECT_ROOT try: return os.getcwd() except FileNotFoundError: candidate = ((env or {}).get("PWD") or os.environ.get("PWD") or "").strip() if candidate and Path(candidate).is_dir(): return candidate return str(PROJECT_ROOT) def _apply_tui_python_env(env: dict) -> None: """Seed/repair Python-related env vars shared by CLI and dashboard TUI launches.""" from hermes_cli.main import PROJECT_ROOT src_root = str(env.get("HERMES_PYTHON_SRC_ROOT") or "").strip() if not src_root or not Path(src_root).is_dir(): env["HERMES_PYTHON_SRC_ROOT"] = str(PROJECT_ROOT) cwd = str(env.get("HERMES_CWD") or "").strip() if not cwd or not Path(cwd).is_dir(): env["HERMES_CWD"] = _safe_tui_cwd(env) python = str(env.get("HERMES_PYTHON") or "").strip() if os.path.dirname(python): python_path = Path(python) if not python_path.is_absolute(): python_path = Path(env["HERMES_CWD"]) / python_path python_is_executable = python_path.is_file() and os.access(python_path, os.X_OK) else: python_is_executable = bool(shutil.which(python, path=env.get("PATH"))) if not python_is_executable: env["HERMES_PYTHON"] = sys.executable def _launch_tui( resume_session_id: Optional[str] = None, tui_dev: bool = False, model: Optional[str] = None, provider: Optional[str] = None, toolsets: object = None, skills: object = None, verbose: Optional[bool] = None, quiet: bool = False, query: Optional[str] = None, image: Optional[str] = None, worktree: bool = False, checkpoints: bool = False, pass_session_id: bool = False, max_turns: Optional[int] = None, accept_hooks: bool = False, ): """Replace current process with the TUI.""" from hermes_cli.main import PROJECT_ROOT, _apply_tui_python_env, _make_tui_argv, _resolve_tui_heap_mb tui_dir = PROJECT_ROOT / "ui-tui" import tempfile # TUI child is a hermes process: propagate the profile-home contract via # the single factory; keep secrets (the TUI/agent needs provider creds). from tools.environments.local import build_subprocess_env env = build_subprocess_env(scrub_secrets=False, inherit_profile_home=True) try: from hermes_cli.config import apply_terminal_config_to_env apply_terminal_config_to_env(env=env) except Exception: logger.debug("Failed to apply terminal config bridge for TUI launch", exc_info=True) active_session_fd, active_session_file = tempfile.mkstemp( prefix="hermes-tui-active-session-", suffix=".json" ) os.close(active_session_fd) env["HERMES_TUI_ACTIVE_SESSION_FILE"] = active_session_file env.setdefault("NODE_ENV", "development" if tui_dev else "production") wt_info = None if worktree: try: from cli import ( _cleanup_worktree, _git_repo_root, _maintain_pack_health, _prune_stale_worktrees, _setup_worktree, ) repo = _git_repo_root() if repo: _prune_stale_worktrees(repo) # Same maintenance pass as the CLI path: repack on pack # sprawl so `worktree add` never crawls on a multi-agent box # (cli._maintain_pack_health is a cheap no-op below the # threshold). Runs on a thread — the TUI path calls the # pruner synchronously, and a repack must not block launch. import threading as _threading _threading.Thread( target=_maintain_pack_health, args=(repo,), name="pack-maintenance", daemon=True, ).start() wt_info = _setup_worktree() except Exception as exc: print(f"✗ Failed to create TUI worktree: {exc}", file=sys.stderr) wt_info = None if not wt_info: sys.exit(1) env["HERMES_CWD"] = wt_info["path"] env["TERMINAL_CWD"] = wt_info["path"] _apply_tui_python_env(env) if model: env["HERMES_MODEL"] = model env["HERMES_INFERENCE_MODEL"] = model if provider: env["HERMES_TUI_PROVIDER"] = provider env["HERMES_INFERENCE_PROVIDER"] = provider tui_toolsets = _normalize_tui_toolsets(toolsets) if tui_toolsets: env["HERMES_TUI_TOOLSETS"] = ",".join(tui_toolsets) if skills: if isinstance(skills, (list, tuple)): flattened = [] for item in skills: flattened.extend( part.strip() for part in str(item).split(",") if part.strip() ) if flattened: env["HERMES_TUI_SKILLS"] = ",".join(flattened) else: value = str(skills).strip() if value: env["HERMES_TUI_SKILLS"] = value if query: env["HERMES_TUI_QUERY"] = query if image: env["HERMES_TUI_IMAGE"] = image if checkpoints: env["HERMES_TUI_CHECKPOINTS"] = "1" if pass_session_id: env["HERMES_TUI_PASS_SESSION_ID"] = "1" if max_turns is not None: env["HERMES_TUI_MAX_TURNS"] = str(max_turns) if verbose: env["HERMES_TUI_TOOL_PROGRESS"] = "verbose" elif quiet: env["HERMES_TUI_TOOL_PROGRESS"] = "off" if accept_hooks: env["HERMES_ACCEPT_HOOKS"] = "1" # Guarantee a generous V8 heap for the TUI. Default node cap is ~1.5–4GB # depending on version and can fatal-OOM on long sessions with large # transcripts / reasoning blobs. We target 8GB on an unconstrained host, # but V8 is NOT cgroup-aware: in a memory-limited Docker/k8s container a # flat 8GB heap grows past the container limit and the cgroup OOM-killer # SIGKILLs Node — running no JS handler, writing no breadcrumb, leaving the # user with only a bare gateway `stdin EOF`. _resolve_tui_heap_mb() reads # the real cgroup limit and sizes the cap below it so V8 GCs/exits # gracefully (and the memory monitor's onCritical breadcrumb can fire) # instead of being reaped silently. Token-level merge: respect any # user-supplied --max-old-space-size (they may have set it higher). # --expose-gc is *not* added here: Node rejects it in NODE_OPTIONS # ("--expose-gc is not allowed in NODE_OPTIONS") and refuses to start. # It is passed as a direct argv flag in _make_tui_argv() instead. _tokens = env.get("NODE_OPTIONS", "").split() if not any(t.startswith("--max-old-space-size=") for t in _tokens): _tokens.append(f"--max-old-space-size={_resolve_tui_heap_mb()}") env["NODE_OPTIONS"] = " ".join(_tokens) # HERMES_TUI_RESUME is an internal hand-off from the Python wrapper to the # Ink app. Because we start from a full os.environ snapshot (via # build_subprocess_env), an exported/stale value # in the user's shell would otherwise make a plain `hermes --tui` try to # resume a non-existent session and leave the UI at "error: session not # found" with no live session. Only forward a resume id that argparse # resolved for this invocation; direct `node ui-tui/dist/entry.js` users can # still set HERMES_TUI_RESUME themselves. env.pop("HERMES_TUI_RESUME", None) if resume_session_id: env["HERMES_TUI_RESUME"] = resume_session_id argv, cwd = _make_tui_argv(tui_dir, tui_dev) code: Optional[int] = None try: try: code = subprocess.call(argv, cwd=str(cwd), env=env) except KeyboardInterrupt: code = 130 if code in {0, 130}: _print_tui_exit_summary(resume_session_id, active_session_file) finally: try: os.unlink(active_session_file) except OSError: pass if wt_info: try: _cleanup_worktree(wt_info) except Exception: pass # Exit code 42 = TUI requested an update. Relaunch as `hermes update` so # the user sees update output directly and gets the new version. # preserve_inherited=False ensures --tui and other flags are NOT carried # into the update subcommand. if code == 42: from hermes_cli.relaunch import relaunch print() print("⚕ Launching update...") print() relaunch(["update"], preserve_inherited=False) sys.exit(code) def _pin_kanban_board_env() -> None: """Pin the active kanban board into ``HERMES_KANBAN_BOARD`` for the chat session. Without this, in-process tools (``kanban_*``) and shelled-out CLI calls (``hermes kanban …``) resolve the board on different paths: the env-pin if set, otherwise the global ``/kanban/current`` file. A concurrent ``hermes kanban boards switch`` from another session can flip the file mid-turn, so the same chat sees its tool calls hit board A while its shell calls hit board B (#20074). Pinning at chat boot mirrors what the dispatcher already does for spawned workers. """ if os.environ.get("HERMES_KANBAN_BOARD"): return try: from hermes_cli.kanban_db import get_current_board os.environ["HERMES_KANBAN_BOARD"] = get_current_board() except Exception: pass def _sync_bundled_skills_quietly() -> None: """Seed ``~/.hermes/skills/`` with the bundled skill library on first launch. Called from any CLI entrypoint that the user might use as their first interaction with Hermes — chat, dashboard (the desktop GUI's backend), and gateway. The skills_sync module is manifest-based and idempotent: skipped skills cost ~milliseconds, so calling this repeatedly is fine. Failures are swallowed because skills are an enhancement, not a hard dependency. Hermes still functions without them; the user just sees an empty skills library. """ try: from tools.skills_sync import sync_skills sync_skills(quiet=True) except Exception: pass def _resolve_use_tui(args) -> bool: """Decide whether to launch the TUI for a chat/bare invocation. Precedence (highest first): 1. ``--cli`` flag → always classic REPL 2. ``--tui`` flag → always TUI (explicit ask) 3. no TTY → always classic (ambient prefs don't apply) 4. ``HERMES_TUI=1`` env → TUI 5. ``display.interface`` config value ("cli" | "tui") 6. default → classic REPL Explicit flags always win over config so muscle memory and scripts keep working regardless of the configured default. The TTY gate (3) is load-bearing: ambient TUI preferences (env var or config default) must never hijack a NON-interactive invocation. Kanban workers, cron jobs, and pipelines run ``hermes … chat -q`` with stdout on a pipe; booting the Ink TUI there hits its no-TTY bail-out, which prints a resume hint and exits 0 — a kanban worker then dies with "exited cleanly without calling kanban_complete — protocol violation" on every attempt (found dogfooding the desktop kanban board). A user who *explicitly* passes ``--tui`` still gets the informative bail-out. """ if getattr(args, "cli", False): return False if getattr(args, "tui", False): return True try: if not (sys.stdin.isatty() and sys.stdout.isatty()): return False except Exception: return False if os.environ.get("HERMES_TUI") == "1": return True try: from hermes_cli.config import load_config iface = (load_config().get("display", {}) or {}).get("interface", "cli") return isinstance(iface, str) and iface.strip().lower() == "tui" except Exception: return False