1235 lines
49 KiB
Python
1235 lines
49 KiB
Python
"""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
|