mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-08 13:12:08 +00:00
fix(tui): opentui launches when fnm default is older than 26.3; chrome bar reads the real cwd
Two reasons the local TUI stopped running OpenTUI / showed the wrong directory: 1. Node resolution. OpenTUI needs Node >= 26.3 (node:ffi floor), but _node26_bin_or_none only checked HERMES_NODE + `which node`. When fnm's default flips to an older line (e.g. v25.9) the active node fails the gate and the engine silently falls back to Ink even though a usable v26.3 sits installed. _fnm_node26_candidates now discovers fnm's installed versions (FNM_DIR / XDG_DATA_HOME/fnm / ~/.local/share/fnm / macOS Library path), newest first, version-probed — so the engine launches without the user re-aliasing their global default. 2. Launch cwd. The launcher runs the engine with cwd=<engine package dir> so its build/resolution works; the gateway it spawns then auto-detected THAT dir as the workspace (chrome bar showed 'ui-opentui (feat/opentui-native- engine)' regardless of where you ran hermes). TERMINAL_CWD — the gateway's canonical launch-dir channel — was only exported in worktree mode; now it's set to the real cwd for every launch (worktree mode still overrides to the worktree path). The TUI's session.create no longer sends process.cwd() (the engine dir) — a new launchCwd() reads the launcher's HERMES_CWD/TERMINAL_CWD, falling back to process.cwd() only for standalone smokes. Together: session cwd, chrome bar, terminal-tool cwd, and /sessions grouping all anchor to where you actually ran hermes. Verified live — chrome bar shows '/tmp/cwd-probe (my-feature)' launched from there with fnm default on v25.9. 8 new tests (fnm discovery order/precedence/empty-safety; launchCwd env precedence).
This commit is contained in:
parent
01669f2f12
commit
7b7ab279f2
5 changed files with 184 additions and 9 deletions
|
|
@ -1672,12 +1672,64 @@ def _node_version_tuple(node_bin: str) -> tuple[int, int, int] | None:
|
|||
return None
|
||||
|
||||
|
||||
def _fnm_node26_candidates() -> list[str]:
|
||||
"""Node binaries from fnm's installed versions, newest first.
|
||||
|
||||
fnm keeps each version at ``<FNM_DIR>/node-versions/v<X.Y.Z>/installation/
|
||||
bin/node`` (default ``FNM_DIR``: ``$XDG_DATA_HOME/fnm`` or ``~/.local/share/
|
||||
fnm``; macOS Homebrew also uses ``~/Library/Application Support/fnm``). When
|
||||
the *active* node is older than 26.3 — e.g. the user's fnm default is on
|
||||
v25 — the right 26.x is still installed and usable; surface it so OpenTUI
|
||||
works without the user re-aliasing their global default. Version-sorted so
|
||||
the newest qualifying node wins.
|
||||
"""
|
||||
roots: list[Path] = []
|
||||
fnm_dir = os.environ.get("FNM_DIR")
|
||||
if fnm_dir:
|
||||
roots.append(Path(fnm_dir))
|
||||
xdg = os.environ.get("XDG_DATA_HOME")
|
||||
if xdg:
|
||||
roots.append(Path(xdg) / "fnm")
|
||||
roots.append(Path.home() / ".local" / "share" / "fnm")
|
||||
roots.append(Path.home() / "Library" / "Application Support" / "fnm")
|
||||
|
||||
seen: set[Path] = set()
|
||||
found: list[tuple[tuple[int, int, int], str]] = []
|
||||
for root in roots:
|
||||
versions_dir = root / "node-versions"
|
||||
if versions_dir in seen or not versions_dir.is_dir():
|
||||
continue
|
||||
seen.add(versions_dir)
|
||||
try:
|
||||
entries = list(versions_dir.iterdir())
|
||||
except OSError:
|
||||
continue
|
||||
for entry in entries:
|
||||
node_bin = entry / "installation" / "bin" / "node"
|
||||
if not (node_bin.is_file() and os.access(node_bin, os.X_OK)):
|
||||
continue
|
||||
# Trust the directory name for sorting; the real probe happens in
|
||||
# the caller (a renamed/symlinked dir still gets version-checked).
|
||||
name = entry.name.lstrip("v").split("-", 1)[0]
|
||||
parts = name.split(".")
|
||||
try:
|
||||
ver = (int(parts[0]), int(parts[1]), int(parts[2]))
|
||||
except (IndexError, ValueError):
|
||||
ver = (0, 0, 0)
|
||||
found.append((ver, str(node_bin)))
|
||||
found.sort(key=lambda pair: pair[0], reverse=True)
|
||||
return [path for _, path in found]
|
||||
|
||||
|
||||
def _node26_bin_or_none() -> str | None:
|
||||
"""Resolve a Node >= 26.3.0 binary (no exit — a probe), or ``None``.
|
||||
|
||||
``HERMES_NODE`` override > ``node`` on PATH, each gated on version >= 26.3.0.
|
||||
OpenTUI's native renderer loads via the experimental ``node:ffi`` API that only
|
||||
exists on Node 26.3+, so an older Node is treated as "not available".
|
||||
Order: ``HERMES_NODE`` override > ``node`` on PATH > newest fnm-installed
|
||||
version. Each is gated on the real ``--version`` being >= 26.3.0. OpenTUI's
|
||||
native renderer loads via the experimental ``node:ffi`` API that only exists
|
||||
on Node 26.3+, so an older Node is treated as "not available" — but an
|
||||
installed-yet-inactive 26.x (common when fnm's default is on an older line)
|
||||
is discovered and used so the engine still launches.
|
||||
"""
|
||||
candidates: list[str] = []
|
||||
env_node = os.environ.get("HERMES_NODE")
|
||||
|
|
@ -1686,6 +1738,7 @@ def _node26_bin_or_none() -> str | None:
|
|||
path = shutil.which("node")
|
||||
if path:
|
||||
candidates.append(path)
|
||||
candidates.extend(_fnm_node26_candidates())
|
||||
for cand in candidates:
|
||||
ver = _node_version_tuple(cand)
|
||||
if ver is not None and ver >= NODE26_MIN_VERSION:
|
||||
|
|
@ -2122,6 +2175,14 @@ def _launch_tui(
|
|||
)
|
||||
env.setdefault("HERMES_PYTHON", sys.executable)
|
||||
env.setdefault("HERMES_CWD", os.getcwd())
|
||||
# The TUI subprocess is launched with cwd=<engine package dir> (so its
|
||||
# build/resolution works), which means the gateway it spawns would otherwise
|
||||
# auto-detect THAT dir as the workspace (chrome bar showed "ui-opentui" no
|
||||
# matter where you ran hermes). TERMINAL_CWD is the gateway's canonical
|
||||
# launch-dir channel (_completion_cwd) — set it to the real cwd here so the
|
||||
# session, chrome bar, and terminal tool all anchor to where you actually
|
||||
# are. Worktree mode overrides it to the worktree path below.
|
||||
env.setdefault("TERMINAL_CWD", os.getcwd())
|
||||
env.setdefault("NODE_ENV", "development" if tui_dev else "production")
|
||||
|
||||
wt_info = None
|
||||
|
|
|
|||
69
tests/hermes_cli/test_opentui_node_resolution.py
Normal file
69
tests/hermes_cli/test_opentui_node_resolution.py
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
"""Node-26 resolution for the OpenTUI engine + the launch-cwd channel.
|
||||
|
||||
Regression coverage for two ways the local TUI silently fell back to Ink /
|
||||
showed the wrong directory:
|
||||
|
||||
1. fnm's active/default node was on an older line (v25) while a usable v26.3
|
||||
sat installed-but-inactive — ``_node26_bin_or_none`` only checked
|
||||
``HERMES_NODE`` + ``which node`` and so reported "no node 26" → OpenTUI
|
||||
unavailable → Ink fallback.
|
||||
2. ``TERMINAL_CWD`` (the gateway's launch-dir channel) was only exported in
|
||||
worktree mode, so a normal launch let the gateway auto-detect the engine's
|
||||
own package dir as the workspace.
|
||||
"""
|
||||
|
||||
import os
|
||||
import stat
|
||||
|
||||
import pytest
|
||||
|
||||
import hermes_cli.main as main_mod
|
||||
|
||||
|
||||
def _fake_node(path, version: str) -> None:
|
||||
"""Write a stub `node` that prints `version` for `--version`."""
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(f'#!/bin/sh\necho "{version}"\n')
|
||||
path.chmod(path.stat().st_mode | stat.S_IEXEC | stat.S_IXGRP | stat.S_IXOTH)
|
||||
|
||||
|
||||
class TestFnmNode26Discovery:
|
||||
def test_discovers_inactive_v26_when_default_is_older(self, tmp_path, monkeypatch):
|
||||
"""A v26.3 installed under fnm is found even when PATH `node` is v25."""
|
||||
fnm_dir = tmp_path / "fnm"
|
||||
for ver in ("24.11.0", "25.9.0", "26.3.0"):
|
||||
_fake_node(fnm_dir / "node-versions" / f"v{ver}" / "installation" / "bin" / "node", f"v{ver}")
|
||||
monkeypatch.setenv("FNM_DIR", str(fnm_dir))
|
||||
monkeypatch.delenv("HERMES_NODE", raising=False)
|
||||
# PATH node is the too-old default (v25).
|
||||
monkeypatch.setattr(main_mod.shutil, "which", lambda _b: str(
|
||||
fnm_dir / "node-versions" / "v25.9.0" / "installation" / "bin" / "node"
|
||||
))
|
||||
|
||||
resolved = main_mod._node26_bin_or_none()
|
||||
assert resolved is not None
|
||||
assert "v26.3.0" in resolved # newest qualifying, not the v25 default
|
||||
|
||||
def test_candidates_sorted_newest_first(self, tmp_path, monkeypatch):
|
||||
fnm_dir = tmp_path / "fnm"
|
||||
for ver in ("26.1.0", "26.4.0", "25.0.0"):
|
||||
_fake_node(fnm_dir / "node-versions" / f"v{ver}" / "installation" / "bin" / "node", f"v{ver}")
|
||||
monkeypatch.setenv("FNM_DIR", str(fnm_dir))
|
||||
cands = main_mod._fnm_node26_candidates()
|
||||
# Directory-name order: 26.4 before 26.1 before 25.0.
|
||||
idx = [next(i for i, c in enumerate(cands) if f"v{v}" in c) for v in ("26.4.0", "26.1.0", "25.0.0")]
|
||||
assert idx == sorted(idx)
|
||||
|
||||
def test_no_fnm_dir_is_empty_not_error(self, tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("FNM_DIR", str(tmp_path / "does-not-exist"))
|
||||
monkeypatch.setenv("XDG_DATA_HOME", str(tmp_path / "xdg-none"))
|
||||
monkeypatch.setattr(main_mod.Path, "home", classmethod(lambda cls: tmp_path / "home-none"))
|
||||
assert main_mod._fnm_node26_candidates() == []
|
||||
|
||||
def test_hermes_node_still_wins(self, tmp_path, monkeypatch):
|
||||
"""An explicit HERMES_NODE >= 26.3 takes precedence over fnm discovery."""
|
||||
explicit = tmp_path / "explicit" / "node"
|
||||
_fake_node(explicit, "v26.5.0")
|
||||
monkeypatch.setenv("HERMES_NODE", str(explicit))
|
||||
monkeypatch.setattr(main_mod.shutil, "which", lambda _b: None)
|
||||
assert main_mod._node26_bin_or_none() == str(explicit)
|
||||
|
|
@ -33,7 +33,7 @@ import { registerVendoredParsers } from '../boundary/parsers.ts'
|
|||
import { acquireRenderer } from '../boundary/renderer.ts'
|
||||
import { makeAppLayer } from '../boundary/runtime.ts'
|
||||
import { nthAssistantResponse } from '../logic/copy.ts'
|
||||
import { envFlag } from '../logic/env.ts'
|
||||
import { envFlag, launchCwd } from '../logic/env.ts'
|
||||
import { createPromptHistory, dirHistoryPersister, loadDirHistory } from '../logic/history.ts'
|
||||
import { createPasteStore } from '../logic/pastes.ts'
|
||||
import { mapResumeHistory } from '../logic/resume.ts'
|
||||
|
|
@ -183,10 +183,15 @@ const createFreshSession = (gateway: GatewayServiceShape, store: SessionStore, i
|
|||
cols: input.cols,
|
||||
// The launch directory IS the workspace choice in a terminal (you cd'd
|
||||
// here) — passing it makes the gateway treat it as explicit, so the
|
||||
// session row gets a persisted cwd on first message and /sessions can
|
||||
// group this directory's sessions first. (The desktop deliberately
|
||||
// omits cwd — its launch dir is meaningless; see _ensure_session_db_row.)
|
||||
cwd: process.cwd()
|
||||
// session row gets a persisted cwd on first message, the chrome bar shows
|
||||
// the right dir, and /sessions groups this directory's sessions first.
|
||||
// NOT process.cwd(): the hermes launcher runs this engine with cwd set to
|
||||
// its own package dir (ui-opentui), so process.cwd() would be the engine
|
||||
// dir. The launcher exports the REAL launch dir as HERMES_CWD / the
|
||||
// gateway's TERMINAL_CWD; prefer those, falling back to process.cwd()
|
||||
// only when launched standalone (smokes/dev). (Desktop omits cwd — its
|
||||
// launch dir is meaningless; see _ensure_session_db_row.)
|
||||
cwd: launchCwd()
|
||||
})
|
||||
const sid = created?.session_id ?? gateway.sessionId()
|
||||
if (!sid) {
|
||||
|
|
|
|||
|
|
@ -61,3 +61,28 @@ export function envComposerRows(value: string | undefined): number {
|
|||
export function envOutputUnlimited(value: string | undefined): boolean {
|
||||
return envOutputLines(value) === Number.POSITIVE_INFINITY
|
||||
}
|
||||
|
||||
/**
|
||||
* The session's launch directory for `session.create`'s `cwd` param.
|
||||
*
|
||||
* The hermes launcher runs the OpenTUI engine with its process cwd set to the
|
||||
* engine's own package dir, so `process.cwd()` is NOT where the user ran
|
||||
* hermes. The launcher exports the real launch dir as `HERMES_CWD` (and the
|
||||
* gateway's `TERMINAL_CWD`); prefer those. Falls back to `process.cwd()` only
|
||||
* for standalone launches (smokes/dev) where no launcher set them, and returns
|
||||
* `undefined` when even that is empty so the gateway resolves its own default.
|
||||
*/
|
||||
export function launchCwd(env: { readonly [k: string]: string | undefined } = process.env): string | undefined {
|
||||
// First NON-BLANK of the launcher's vars (?? would keep a blank HERMES_CWD
|
||||
// and never reach TERMINAL_CWD).
|
||||
for (const value of [env.HERMES_CWD, env.TERMINAL_CWD]) {
|
||||
const trimmed = (value ?? '').trim()
|
||||
if (trimmed) return trimmed
|
||||
}
|
||||
try {
|
||||
const cwd = process.cwd().trim()
|
||||
return cwd || undefined
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { describe, expect, test } from 'vitest'
|
||||
|
||||
import { envFlag, envOutputLines, envOutputUnlimited } from '../logic/env.ts'
|
||||
import { envFlag, envOutputLines, envOutputUnlimited, launchCwd } from '../logic/env.ts'
|
||||
|
||||
describe('envFlag', () => {
|
||||
test('recognizes truthy values regardless of case/whitespace', () => {
|
||||
|
|
@ -66,3 +66,18 @@ describe('envOutputLines (HERMES_TUI_TOOL_OUTPUT_LINES)', () => {
|
|||
expect(envOutputUnlimited('200')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('launchCwd (session.create cwd)', () => {
|
||||
test('prefers HERMES_CWD (real launch dir the hermes launcher exports)', () => {
|
||||
expect(launchCwd({ HERMES_CWD: '/home/u/proj', TERMINAL_CWD: '/other' })).toBe('/home/u/proj')
|
||||
})
|
||||
|
||||
test('falls back to TERMINAL_CWD when HERMES_CWD is unset/blank', () => {
|
||||
expect(launchCwd({ TERMINAL_CWD: '/home/u/wt' })).toBe('/home/u/wt')
|
||||
expect(launchCwd({ HERMES_CWD: ' ', TERMINAL_CWD: '/home/u/wt' })).toBe('/home/u/wt')
|
||||
})
|
||||
|
||||
test('falls back to process.cwd() (non-empty) when no launcher env set', () => {
|
||||
expect(launchCwd({})).toBe(process.cwd())
|
||||
})
|
||||
})
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue