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:
alt-glitch 2026-06-13 13:24:54 +05:30
parent 01669f2f12
commit 7b7ab279f2
5 changed files with 184 additions and 9 deletions

View file

@ -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

View 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)

View file

@ -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) {

View file

@ -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
}
}

View file

@ -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())
})
})