Merge pull request #18036 from NousResearch/fix/bundle-size

ui-tui: bundle with esbuild, drop runtime node_modules
This commit is contained in:
ethernet 2026-05-11 17:46:19 -04:00 committed by GitHub
commit 825bd50e6b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 122 additions and 136 deletions

View file

@ -897,6 +897,11 @@ to avoid false-positive reinstalls on every launch.
def _tui_need_npm_install(root: Path) -> bool: def _tui_need_npm_install(root: Path) -> bool:
"""True when @hermes/ink is missing or node_modules is behind package-lock.json. """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.
Compares ``package-lock.json`` against ``node_modules/.package-lock.json`` Compares ``package-lock.json`` against ``node_modules/.package-lock.json``
(npm's hidden lockfile) by **content**, not mtime: git checkouts and npm (npm's hidden lockfile) by **content**, not mtime: git checkouts and npm
rewrites can bump the root lockfile's timestamp even when installed deps rewrites can bump the root lockfile's timestamp even when installed deps
@ -914,10 +919,16 @@ def _tui_need_npm_install(root: Path) -> bool:
we'd rather not force a reinstall for them. Falls back to mtime we'd rather not force a reinstall for them. Falls back to mtime
comparison if either lockfile is unparseable. comparison if either lockfile is unparseable.
""" """
lock = root / "package-lock.json"
entry = root / "dist" / "entry.js"
# Prebuilt self-contained bundle (nix / packaged release): no lockfile
# shipped, dist/entry.js is the single runtime artefact.
if entry.is_file() and not lock.is_file():
return False
ink = root / "node_modules" / "@hermes" / "ink" / "package.json" ink = root / "node_modules" / "@hermes" / "ink" / "package.json"
if not ink.is_file(): if not ink.is_file():
return True return True
lock = root / "package-lock.json"
if not lock.is_file(): if not lock.is_file():
return False return False
marker = root / "node_modules" / ".package-lock.json" marker = root / "node_modules" / ".package-lock.json"
@ -956,63 +967,6 @@ def _tui_need_npm_install(root: Path) -> bool:
return False return False
def _find_bundled_tui(tui_dir: Path) -> Optional[Path]:
"""Directory whose dist/entry.js we should run: HERMES_TUI_DIR first, else repo ui-tui."""
env = os.environ.get("HERMES_TUI_DIR")
if env:
p = Path(env)
if (p / "dist" / "entry.js").exists() and not _tui_need_npm_install(p):
return p
if (tui_dir / "dist" / "entry.js").exists() and not _tui_need_npm_install(tui_dir):
return tui_dir
return None
def _tui_build_needed(tui_dir: Path) -> bool:
if _hermes_ink_bundle_stale(tui_dir):
return True
entry = tui_dir / "dist" / "entry.js"
if not entry.exists():
return True
dist_m = entry.stat().st_mtime
skip = frozenset({"node_modules", "dist"})
for dirpath, dirnames, filenames in os.walk(tui_dir, topdown=True):
dirnames[:] = [d for d in dirnames if d not in skip]
for fn in filenames:
if fn.endswith((".ts", ".tsx")):
if os.path.getmtime(os.path.join(dirpath, fn)) > dist_m:
return True
for meta in (
"package.json",
"package-lock.json",
"tsconfig.json",
"tsconfig.build.json",
):
mp = tui_dir / meta
if mp.exists() and mp.stat().st_mtime > dist_m:
return True
return False
def _hermes_ink_bundle_stale(tui_dir: Path) -> bool:
ink_root = tui_dir / "packages" / "hermes-ink"
bundle = ink_root / "dist" / "ink-bundle.js"
if not bundle.exists():
return True
bm = bundle.stat().st_mtime
skip = frozenset({"node_modules", "dist"})
for dirpath, dirnames, filenames in os.walk(ink_root, topdown=True):
dirnames[:] = [d for d in dirnames if d not in skip]
for fn in filenames:
if fn.endswith((".ts", ".tsx")):
if os.path.getmtime(os.path.join(dirpath, fn)) > bm:
return True
mp = ink_root / "package.json"
if mp.exists() and mp.stat().st_mtime > bm:
return True
return False
def _ensure_tui_node() -> None: def _ensure_tui_node() -> None:
"""Make sure `node` + `npm` are on PATH for the TUI. """Make sure `node` + `npm` are on PATH for the TUI.
@ -1071,7 +1025,7 @@ def _ensure_tui_node() -> None:
def _make_tui_argv(tui_dir: Path, tui_dev: bool) -> tuple[list[str], Path]: def _make_tui_argv(tui_dir: Path, tui_dev: bool) -> tuple[list[str], Path]:
"""TUI: --dev → tsx src; else node dist (HERMES_TUI_DIR or ui-tui, build when stale).""" """TUI: --dev → tsx src; else node dist (HERMES_TUI_DIR prebuilt or esbuild)."""
_ensure_tui_node() _ensure_tui_node()
def _node_bin(bin: str) -> str: def _node_bin(bin: str) -> str:
@ -1085,23 +1039,31 @@ def _make_tui_argv(tui_dir: Path, tui_dev: bool) -> tuple[list[str], Path]:
sys.exit(1) sys.exit(1)
return path return path
# pre-built dist + node_modules (nix / full HERMES_TUI_DIR) skips npm. # 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): just run it.
if not tui_dev: if not tui_dev:
ext_dir = os.environ.get("HERMES_TUI_DIR")
if ext_dir: if ext_dir:
p = Path(ext_dir) p = Path(ext_dir)
if (p / "dist" / "entry.js").exists() and not _tui_need_npm_install(p): if (p / "dist" / "entry.js").is_file():
node = _node_bin("node") node = _node_bin("node")
return [node, str(p / "dist" / "entry.js")], p return [node, str(p / "dist" / "entry.js")], p
npm = _node_bin("npm") # 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 (no build).
if _tui_need_npm_install(tui_dir): if _tui_need_npm_install(tui_dir):
npm = _node_bin("npm")
if not os.environ.get("HERMES_QUIET"): if not os.environ.get("HERMES_QUIET"):
print("Installing TUI dependencies…") print("Installing TUI dependencies…")
# Capture stdout as well as stderr — some npm errors (notably EACCES on a
# root-owned node_modules in containers) are emitted on stdout, and a
# bare "npm install failed." with no preview defeats debugging. We keep
# the failure-only print path so a successful install stays silent.
result = subprocess.run( result = subprocess.run(
[npm, "install", "--silent", "--no-fund", "--no-audit", "--progress=false"], [npm, "install", "--silent", "--no-fund", "--no-audit", "--progress=false"],
cwd=str(tui_dir), cwd=str(tui_dir),
@ -1119,47 +1081,30 @@ def _make_tui_argv(tui_dir: Path, tui_dev: bool) -> tuple[list[str], Path]:
sys.exit(1) sys.exit(1)
if tui_dev: if tui_dev:
if _hermes_ink_bundle_stale(tui_dir):
result = subprocess.run(
[npm, "run", "build", "--prefix", "packages/hermes-ink"],
cwd=str(tui_dir),
capture_output=True,
text=True,
)
if result.returncode != 0:
combined = f"{result.stdout or ''}{result.stderr or ''}".strip()
preview = "\n".join(combined.splitlines()[-30:])
print("@hermes/ink build failed.")
if preview:
print(preview)
sys.exit(1)
tsx = tui_dir / "node_modules" / ".bin" / "tsx" tsx = tui_dir / "node_modules" / ".bin" / "tsx"
if tsx.exists(): if tsx.exists():
return [str(tsx), "src/entry.tsx"], tui_dir return [str(tsx), "src/entry.tsx"], tui_dir
npm = _node_bin("npm")
return [npm, "start"], tui_dir return [npm, "start"], tui_dir
if _tui_build_needed(tui_dir): # Always rebuild — esbuild is fast and this avoids staleness-edge-case bugs.
result = subprocess.run( npm = _node_bin("npm")
[npm, "run", "build"], result = subprocess.run(
cwd=str(tui_dir), [npm, "run", "build"],
capture_output=True, cwd=str(tui_dir),
text=True, capture_output=True,
) text=True,
if result.returncode != 0: )
combined = f"{result.stdout or ''}{result.stderr or ''}".strip() if result.returncode != 0:
preview = "\n".join(combined.splitlines()[-30:]) combined = f"{result.stdout or ''}{result.stderr or ''}".strip()
print("TUI build failed.") preview = "\n".join(combined.splitlines()[-30:])
if preview: print("TUI build failed.")
print(preview) if preview:
sys.exit(1) print(preview)
root = _find_bundled_tui(tui_dir)
if not root:
print("TUI build did not produce dist/entry.js")
sys.exit(1) sys.exit(1)
node = _node_bin("node") node = _node_bin("node")
return [node, str(root / "dist" / "entry.js")], root return [node, str(tui_dir / "dist" / "entry.js")], tui_dir
def _normalize_tui_toolsets(toolsets: object) -> list[str]: def _normalize_tui_toolsets(toolsets: object) -> list[str]:
@ -5489,7 +5434,6 @@ def _gateway_prompt(prompt_text: str, default: str = "", timeout: float = 300.0)
def _web_ui_build_needed(web_dir: Path) -> bool: def _web_ui_build_needed(web_dir: Path) -> bool:
"""Return True if the web UI dist is missing or stale. """Return True if the web UI dist is missing or stale.
Mirrors the staleness logic used by ``_tui_build_needed()`` for the TUI.
The Vite build outputs to ``hermes_cli/web_dist/`` (per vite.config.ts The Vite build outputs to ``hermes_cli/web_dist/`` (per vite.config.ts
outDir: "../hermes_cli/web_dist"), NOT to ``web/dist/``. Uses the Vite outDir: "../hermes_cli/web_dist"), NOT to ``web/dist/``. Uses the Vite
manifest as the sentinel because it is written last and therefore has the manifest as the sentinel because it is written last and therefore has the

View file

@ -154,8 +154,7 @@ json.dump(sorted(leaf_paths(DEFAULT_CONFIG)), sys.stdout, indent=2)
test -f ${hermes-agent}/ui-tui/dist/entry.js || (echo "FAIL: compiled entry.js missing"; exit 1) test -f ${hermes-agent}/ui-tui/dist/entry.js || (echo "FAIL: compiled entry.js missing"; exit 1)
echo "PASS: compiled entry.js present" echo "PASS: compiled entry.js present"
test -d ${hermes-agent}/ui-tui/node_modules || (echo "FAIL: node_modules missing"; exit 1) # self-contained bundle; no runtime node_modules expected
echo "PASS: node_modules present"
grep -q "HERMES_TUI_DIR" ${hermes-agent}/bin/hermes || \ grep -q "HERMES_TUI_DIR" ${hermes-agent}/bin/hermes || \
(echo "FAIL: HERMES_TUI_DIR not in wrapper"; exit 1) (echo "FAIL: HERMES_TUI_DIR not in wrapper"; exit 1)

View file

@ -4,7 +4,7 @@ let
src = ../ui-tui; src = ../ui-tui;
npmDeps = pkgs.fetchNpmDeps { npmDeps = pkgs.fetchNpmDeps {
inherit src; inherit src;
hash = "sha256-MLcLhjTF6dgdvNBtJWzo8Nh19eNh/ZitD2b07nm61Tc="; hash = "sha256-9r1EYQ600gNXOnNXwakorpEk7hS/FPxZVbB2JksrhYs=";
}; };
npm = hermesNpmLib.mkNpmPassthru { folder = "ui-tui"; attr = "tui"; pname = "hermes-tui"; }; npm = hermesNpmLib.mkNpmPassthru { folder = "ui-tui"; attr = "tui"; pname = "hermes-tui"; };
@ -24,16 +24,10 @@ pkgs.buildNpmPackage (npm // {
mkdir -p $out/lib/hermes-tui mkdir -p $out/lib/hermes-tui
# Single self-contained bundle built by scripts/build.mjs (esbuild).
cp -r dist $out/lib/hermes-tui/dist cp -r dist $out/lib/hermes-tui/dist
# runtime node_modules # package.json kept for "type": "module" resolution on `node dist/entry.js`.
cp -r node_modules $out/lib/hermes-tui/node_modules
# @hermes/ink is a file: dependency, we need to copy it in fr
rm -f $out/lib/hermes-tui/node_modules/@hermes/ink
cp -r packages/hermes-ink $out/lib/hermes-tui/node_modules/@hermes/ink
# package.json needed for "type": "module" resolution
cp package.json $out/lib/hermes-tui/ cp package.json $out/lib/hermes-tui/
runHook postInstall runHook postInstall

View file

@ -15,7 +15,7 @@ session-picker flow.
Environment overrides: Environment overrides:
HERMES_PERF_LOG (default ~/.hermes/perf.log) HERMES_PERF_LOG (default ~/.hermes/perf.log)
HERMES_PERF_NODE (default node from $PATH) HERMES_PERF_NODE (default node from $PATH)
HERMES_TUI_DIR (default /home/bb/hermes-agent/ui-tui) HERMES_TUI_DIR (default: <repo>/ui-tui relative to this script)
Exit code is 0 if the harness ran and parsed results, 2 if the TUI crashed Exit code is 0 if the harness ran and parsed results, 2 if the TUI crashed
or produced no perf data (suggests HERMES_DEV_PERF wiring is broken). or produced no perf data (suggests HERMES_DEV_PERF wiring is broken).
@ -44,7 +44,10 @@ except ImportError:
val = (os.environ.get("HERMES_HOME") or "").strip() val = (os.environ.get("HERMES_HOME") or "").strip()
return Path(val) if val else Path.home() / ".hermes" return Path(val) if val else Path.home() / ".hermes"
DEFAULT_TUI_DIR = Path(os.environ.get("HERMES_TUI_DIR", "/home/bb/hermes-agent/ui-tui")) DEFAULT_TUI_DIR = Path(
os.environ.get("HERMES_TUI_DIR")
or str(Path(__file__).resolve().parent.parent / "ui-tui")
)
DEFAULT_LOG = Path(os.environ.get("HERMES_PERF_LOG", str(get_hermes_home() / "perf.log"))) DEFAULT_LOG = Path(os.environ.get("HERMES_PERF_LOG", str(get_hermes_home() / "perf.log")))
DEFAULT_STATE_DB = get_hermes_home() / "state.db" DEFAULT_STATE_DB = get_hermes_home() / "state.db"

View file

@ -25,12 +25,6 @@ def _touch_tui_entry(root: Path) -> None:
entry.write_text("console.log('tui')") entry.write_text("console.log('tui')")
def _touch_ink_bundle(root: Path) -> None:
bundle = root / "packages" / "hermes-ink" / "dist" / "ink-bundle.js"
bundle.parent.mkdir(parents=True, exist_ok=True)
bundle.write_text("export {}")
def test_need_install_when_ink_missing(tmp_path: Path, main_mod) -> None: def test_need_install_when_ink_missing(tmp_path: Path, main_mod) -> None:
(tmp_path / "package-lock.json").write_text("{}") (tmp_path / "package-lock.json").write_text("{}")
assert main_mod._tui_need_npm_install(tmp_path) is True assert main_mod._tui_need_npm_install(tmp_path) is True
@ -122,17 +116,7 @@ def test_no_install_without_lockfile_when_ink_present(tmp_path: Path, main_mod)
assert main_mod._tui_need_npm_install(tmp_path) is False assert main_mod._tui_need_npm_install(tmp_path) is False
def test_build_needed_when_local_ink_bundle_missing(tmp_path: Path, main_mod) -> None: def test_no_install_prebuilt_bundle_mode(tmp_path: Path, main_mod) -> None:
"""dist/entry.js present and no package-lock.json → prebuilt bundle, skip npm install."""
_touch_tui_entry(tmp_path) _touch_tui_entry(tmp_path)
_touch_ink(tmp_path)
assert main_mod._tui_need_npm_install(tmp_path) is False assert main_mod._tui_need_npm_install(tmp_path) is False
assert main_mod._tui_build_needed(tmp_path) is True
def test_build_not_needed_when_entry_and_ink_bundle_present(tmp_path: Path, main_mod) -> None:
_touch_tui_entry(tmp_path)
_touch_ink(tmp_path)
_touch_ink_bundle(tmp_path)
assert main_mod._tui_build_needed(tmp_path) is False

View file

@ -41,7 +41,7 @@ From the repo root, the normal path is:
hermes --tui hermes --tui
``` ```
The CLI expects `ui-tui/node_modules` to exist. If the TUI deps are missing: The CLI expects `ui-tui/dist/entry.js` to exist, or the whole source code available in which to run `npm install` and `npm run dev`.
```bash ```bash
cd ui-tui cd ui-tui

View file

@ -26,6 +26,7 @@
"@typescript-eslint/eslint-plugin": "^8", "@typescript-eslint/eslint-plugin": "^8",
"@typescript-eslint/parser": "^8", "@typescript-eslint/parser": "^8",
"babel-plugin-react-compiler": "^1.0.0", "babel-plugin-react-compiler": "^1.0.0",
"esbuild": "~0.27.0",
"eslint": "^9", "eslint": "^9",
"eslint-plugin-perfectionist": "^5", "eslint-plugin-perfectionist": "^5",
"eslint-plugin-react": "^7", "eslint-plugin-react": "^7",

View file

@ -6,8 +6,7 @@
"scripts": { "scripts": {
"dev": "npm run build --prefix packages/hermes-ink && tsx --watch src/entry.tsx", "dev": "npm run build --prefix packages/hermes-ink && tsx --watch src/entry.tsx",
"start": "tsx src/entry.tsx", "start": "tsx src/entry.tsx",
"build": "npm run build --prefix packages/hermes-ink && tsc -p tsconfig.build.json && npm run build:compile && chmod +x dist/entry.js", "build": "node scripts/build.mjs",
"build:compile": "babel dist --out-dir dist --config-file ./babel.compiler.config.cjs --extensions .js --keep-file-extension",
"type-check": "tsc --noEmit -p tsconfig.json", "type-check": "tsc --noEmit -p tsconfig.json",
"lint": "eslint src/ packages/", "lint": "eslint src/ packages/",
"lint:fix": "eslint src/ packages/ --fix", "lint:fix": "eslint src/ packages/ --fix",
@ -35,6 +34,7 @@
"@typescript-eslint/eslint-plugin": "^8", "@typescript-eslint/eslint-plugin": "^8",
"@typescript-eslint/parser": "^8", "@typescript-eslint/parser": "^8",
"babel-plugin-react-compiler": "^1.0.0", "babel-plugin-react-compiler": "^1.0.0",
"esbuild": "~0.27.0",
"eslint": "^9", "eslint": "^9",
"eslint-plugin-perfectionist": "^5", "eslint-plugin-perfectionist": "^5",
"eslint-plugin-react": "^7", "eslint-plugin-react": "^7",

61
ui-tui/scripts/build.mjs Normal file
View file

@ -0,0 +1,61 @@
#!/usr/bin/env node
// Bundles src/entry.tsx into a single self-contained dist/entry.js.
// No runtime node_modules needed.
import { build } from 'esbuild'
import { readFileSync, writeFileSync } from 'node:fs'
import { fileURLToPath } from 'node:url'
import { dirname, resolve } from 'node:path'
const here = dirname(fileURLToPath(import.meta.url))
const root = resolve(here, '..')
const out = resolve(root, 'dist/entry.js')
// `react-devtools-core` is only imported when DEV=true at runtime (Ink dev
// mode). Stub it out so the bundle doesn't carry the dep.
const stubDevtools = {
name: 'stub-react-devtools-core',
setup(b) {
b.onResolve({ filter: /^react-devtools-core$/ }, args => ({
path: args.path,
namespace: 'stub-devtools'
}))
b.onLoad({ filter: /.*/, namespace: 'stub-devtools' }, () => ({
contents: 'export default { initialize() {}, connectToDevTools() {} }',
loader: 'js'
}))
}
}
await build({
entryPoints: [resolve(root, 'src/entry.tsx')],
bundle: true,
platform: 'node',
format: 'esm',
target: 'node20',
outfile: out,
jsx: 'automatic',
jsxImportSource: 'react',
// Skip the prebuilt @hermes/ink bundle — esbuild's __esm helper doesn't
// await nested async init, which breaks lazy-initialized exports like
// `render`. Bundling from source sidesteps that.
alias: { '@hermes/ink': resolve(root, 'packages/hermes-ink/src/entry-exports.ts') },
plugins: [stubDevtools],
// Some transitive deps use CommonJS `require(...)` at runtime. ESM bundles
// don't get a `require` binding automatically, so we inject one.
banner: {
js: "import { createRequire as __cr } from 'node:module'; const require = __cr(import.meta.url);"
},
logLevel: 'info'
})
// esbuild preserves the shebang from src/entry.tsx into the bundle, but Nix's
// patchShebangs phase mangles `/usr/bin/env -S node --foo --bar` (it strips
// the `node` token, leaving a broken interpreter). The hermes_cli launcher
// always invokes this file as `node dist/entry.js` anyway, so the shebang is
// redundant — strip it.
const body = readFileSync(out, 'utf8')
if (body.startsWith('#!')) {
writeFileSync(out, body.slice(body.indexOf('\n') + 1))
}
console.log(`built ${out}`)

View file

@ -66,7 +66,7 @@ export HERMES_TUI_DIR=/path/to/prebuilt/ui-tui
hermes --tui hermes --tui
``` ```
The directory must contain `dist/entry.js` and an up-to-date `node_modules`. The directory must contain `dist/entry.js`.
## Keybindings ## Keybindings