Files
hermes-agent/hermes_cli/main_tui_launch.py
T

1235 lines
49 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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.<name>`` 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.<name>`` 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/<name>`` hoisted deps,
``<dir>/node_modules/<name>`` 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=<missing ui-tui>)``, 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 ``<root>/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