From b131e0d87f319b22bcccc3d22d81949d17b6b49b Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 2 Sep 2026 15:29:56 -0700 Subject: [PATCH] refactor(hermes_cli/main): extract TUI launcher cluster to main_tui_launch.py --- hermes_cli/main.py | 1263 +--------------------- hermes_cli/main_tui_launch.py | 1234 +++++++++++++++++++++ tests/test_managed_runtime_resolution.py | 7 +- 3 files changed, 1269 insertions(+), 1235 deletions(-) create mode 100644 hermes_cli/main_tui_launch.py diff --git a/hermes_cli/main.py b/hermes_cli/main.py index df68dfb4ab..f96af38438 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -873,6 +873,36 @@ from hermes_cli.model_setup_flows import ( _model_flow_ai_gateway, ) logger = logging.getLogger(__name__) +from hermes_cli.main_tui_launch import ( # noqa: E402,F401 (re-exported; tests patch hermes_cli.main.) + _NPM_LOCK_RUNTIME_KEYS, + _TUI_BUILD_INPUT_DIRS, + _TUI_BUILD_INPUT_FILES, + _TUI_BUILD_INPUT_SUFFIXES, + _apply_tui_python_env, + _ensure_tui_node, + _ensure_tui_workspace, + _find_bundled_tui, + _iter_tui_build_inputs, + _launch_tui, + _make_tui_argv, + _normalize_tui_toolsets, + _npm_lifecycle_env, + _npm_lock_workspace_closure, + _pin_kanban_board_env, + _print_tui_exit_summary, + _read_cgroup_memory_limit, + _read_tui_active_session_file, + _resolve_tui_heap_mb, + _resolve_use_tui, + _restore_tui_workspace, + _safe_tui_cwd, + _sync_bundled_skills_quietly, + _termux_workspace_install_context, + _tui_need_npm_install, + _tui_need_rebuild, + _tui_selected_workspace_keys, + _workspace_root, +) def _is_termux_startup_environment(env: dict[str, str] | None = None) -> bool: @@ -1587,1208 +1617,6 @@ def _resolve_continue_arg(args, *, use_tui: bool) -> None: sys.exit(1) -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.""" - 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. - """ - 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. - """ - # 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. - """ - 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).""" - _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``. - """ - 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.""" - 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.""" - 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.""" - 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 - - def cmd_chat(args): """Run interactive chat CLI.""" _apply_safe_mode(args) @@ -4412,14 +3240,6 @@ def _prompt_provider_choice(choices, *, default=0, title="Select provider:"): return None - - - - - - - - _DEFAULT_QWEN_PORTAL_MODELS = [ "qwen3-coder-plus", "qwen3-coder", @@ -4612,8 +3432,6 @@ def _save_custom_provider( print(f' 💾 Saved to custom providers as "{name}" (edit in config.yaml)') - - def _remove_custom_provider(config): """Let the user remove a saved custom provider from config.yaml.""" from hermes_cli.config import load_config, save_config @@ -4672,8 +3490,6 @@ def _remove_custom_provider(config): print(f'✅ Removed "{removed_name}" from custom providers.') - - # Lazy-export the model catalog at module level. Tests and a handful of # downstream call sites read `hermes_cli.main._PROVIDER_MODELS` directly, # so the symbol needs to be reachable as a module attribute. But importing @@ -4953,10 +3769,6 @@ def _prompt_reasoning_effort_selection(efforts, current_effort=""): return None - - - - def _prompt_api_key( pconfig, existing_key: str, @@ -5051,8 +3863,6 @@ def _prompt_api_key( return existing_key, False - - def _infer_stepfun_region(base_url: str) -> str: """Infer the current StepFun region from the configured endpoint.""" normalized = (base_url or "").strip().lower() @@ -5074,14 +3884,6 @@ def _stepfun_base_url_for_region(region: str) -> str: ) - - - - - - - - def _run_anthropic_oauth_flow(save_env_value): """Run the Claude OAuth setup-token flow. Returns True if credentials were saved.""" from agent.anthropic_adapter import ( @@ -5175,8 +3977,6 @@ def _run_anthropic_oauth_flow(save_env_value): return False - - def cmd_login(args): """Authenticate Hermes CLI with a provider.""" from hermes_cli.auth import login_command @@ -7874,7 +6674,6 @@ def _desktop_linux_sandbox_helper_is_regular_file(packaged_executable: Path) -> return stat.S_ISREG(sandbox_lstat.st_mode) - def _desktop_linux_sandbox_fixup(packaged_executable: Path) -> bool: """Configure Electron's Linux SUID sandbox helper when required.""" if sys.platform != "linux": @@ -10797,8 +9596,6 @@ def _coalesce_session_name_args(argv: list) -> list: from hermes_cli.profile_cmd import cmd_profile, _render_distribution_plan # noqa: E402,F401 (re-export) - - def _report_dashboard_status() -> int: """Print live listening dashboard/serve processes and return the count. diff --git a/hermes_cli/main_tui_launch.py b/hermes_cli/main_tui_launch.py new file mode 100644 index 0000000000..b1b1694b68 --- /dev/null +++ b/hermes_cli/main_tui_launch.py @@ -0,0 +1,1234 @@ +"""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 diff --git a/tests/test_managed_runtime_resolution.py b/tests/test_managed_runtime_resolution.py index e3943e351a..30ae739e0c 100644 --- a/tests/test_managed_runtime_resolution.py +++ b/tests/test_managed_runtime_resolution.py @@ -85,13 +85,16 @@ _ALLOWED: dict[tuple[str, str], str] = { "Fallback rung of _append_node_dir_for_service(), after the managed " "dirs from iter_hermes_node_dirs() are already appended." ), - ("hermes_cli/main.py", "node"): ( + ("hermes_cli/main_tui_launch.py", "node"): ( "_ensure_tui_node()'s idempotence gate: the question really is 'is " "node already discoverable on PATH', before bootstrapping one." ), - ("hermes_cli/main.py", "npm"): ( + ("hermes_cli/main_tui_launch.py", "npm"): ( "Same _ensure_tui_node() gate as node." ), + ("hermes_cli/main.py", "npm"): ( + "_resolve_node_runtime_npm()'s WSL re-scan: PATH minus /mnt/* IS the question." + ), ("tools/browser_tool.py", "npx"): ( "agent-browser runs via `npx`, resolved against the extended browser " "PATH that _merge_browser_path() already seeds with the managed dirs."