diff --git a/hermes_cli/main.py b/hermes_cli/main.py index f0c4611a1a6..ab1cd43ba2d 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -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 ``/node-versions/v/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= (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 diff --git a/tests/hermes_cli/test_opentui_node_resolution.py b/tests/hermes_cli/test_opentui_node_resolution.py new file mode 100644 index 00000000000..b9f844e88aa --- /dev/null +++ b/tests/hermes_cli/test_opentui_node_resolution.py @@ -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) diff --git a/ui-opentui/src/entry/main.tsx b/ui-opentui/src/entry/main.tsx index 742e5a4784d..2ed5d9a142b 100644 --- a/ui-opentui/src/entry/main.tsx +++ b/ui-opentui/src/entry/main.tsx @@ -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) { diff --git a/ui-opentui/src/logic/env.ts b/ui-opentui/src/logic/env.ts index 362d2704503..cfe0d16f249 100644 --- a/ui-opentui/src/logic/env.ts +++ b/ui-opentui/src/logic/env.ts @@ -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 + } +} diff --git a/ui-opentui/src/test/env.test.ts b/ui-opentui/src/test/env.test.ts index 4ecb5c65ad2..4a42cf2d841 100644 --- a/ui-opentui/src/test/env.test.ts +++ b/ui-opentui/src/test/env.test.ts @@ -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()) + }) +})