fix(tui_gateway): honor launch profile terminal.cwd for dashboard chat

Dashboard /chat for the default (launch) profile attaches to the
dashboard process's in-memory TUI gateway. The Node PTY child receives a
bridged TERMINAL_CWD env var, but the in-memory gateway process does not,
so cwd resolution fell through to os.getcwd() (wherever `hermes
dashboard` was launched) and ignored the configured terminal.cwd.

Read the launch profile's config.yaml directly in the in-memory cwd
resolution: a configured terminal.cwd now wins over a stale process env
and the launch directory. Widened to the resume/fallback session-cwd
sites (not just _completion_cwd) via a shared _default_session_cwd()
helper so fresh AND resumed sessions honor the config.

Co-authored-by: ygd58 <buraysandro9@gmail.com>
This commit is contained in:
mbac 2026-07-06 02:31:25 -07:00 committed by Teknium
parent 83f14b2f21
commit f3af7930c2
2 changed files with 97 additions and 9 deletions

View file

@ -188,13 +188,54 @@ def test_completion_cwd_prefers_profile_over_stale_env(monkeypatch, tmp_path):
stale.mkdir()
monkeypatch.setenv("TERMINAL_CWD", str(stale))
monkeypatch.setattr(server, "_load_cfg", lambda: {})
monkeypatch.setattr(server, "_profile_home", lambda name: home if name else None)
assert server._completion_cwd({"profile": "ef-design"}) == str(profile_b)
# No profile → unchanged fallback to the launch env var.
# No profile and no launch config → fallback to the launch env var.
assert server._completion_cwd({}) == str(stale)
def test_completion_cwd_prefers_launch_config_over_stale_env(monkeypatch, tmp_path):
"""Dashboard /chat's launch-profile in-memory gateway must honor config.
The embedded Node TUI child gets TERMINAL_CWD from the dashboard PTY bridge,
but the default-profile chat attaches to the dashboard process's already
running in-memory gateway. That process may not have TERMINAL_CWD in its own
environment (or has a stale one), so config.yaml is read directly and wins
over the process env before falling back to the launch directory.
"""
configured = tmp_path / "omni"
configured.mkdir()
stale = tmp_path / "hermes-agent"
stale.mkdir()
monkeypatch.setenv("TERMINAL_CWD", str(stale))
monkeypatch.setattr(server, "_load_cfg", lambda: {"terminal": {"cwd": str(configured)}})
monkeypatch.setattr(server, "_profile_home", lambda _name: None)
assert server._completion_cwd({}) == str(configured)
def test_default_session_cwd_prefers_launch_config(monkeypatch, tmp_path):
"""A freshly created / resumed session with no explicit cwd lands in the
configured terminal.cwd, not os.getcwd(), even when the in-memory gateway
process env carries a stale TERMINAL_CWD."""
configured = tmp_path / "workspace"
configured.mkdir()
stale = tmp_path / "launch-dir"
stale.mkdir()
monkeypatch.setenv("TERMINAL_CWD", str(stale))
monkeypatch.setattr(server, "_load_cfg", lambda: {"terminal": {"cwd": str(configured)}})
assert server._default_session_cwd() == str(configured)
# No launch config → fall back to the process env var.
monkeypatch.setattr(server, "_load_cfg", lambda: {})
assert server._default_session_cwd() == str(stale)
def test_completion_cwd_explicit_cwd_wins_over_profile(monkeypatch, tmp_path):
"""An explicit client-provided cwd still beats the profile config."""
explicit = tmp_path / "explicit"

View file

@ -955,6 +955,24 @@ def _profile_scoped(handler):
_CWD_PLACEHOLDERS = {".", "auto", "cwd"}
def _configured_cwd_from_cfg(cfg: dict | None) -> str | None:
"""Return an absolute, existing ``terminal.cwd`` from a config mapping.
Returns None for placeholders (``.``/``auto``/``cwd``), missing values, or
paths that don't resolve to a real directory.
"""
if not isinstance(cfg, dict):
return None
terminal_cfg = cfg.get("terminal")
if not isinstance(terminal_cfg, dict):
return None
raw = str(terminal_cfg.get("cwd") or "").strip()
if not raw or raw in _CWD_PLACEHOLDERS:
return None
resolved = os.path.abspath(os.path.expanduser(raw))
return resolved if os.path.isdir(resolved) else None
def _profile_configured_cwd(profile_home: Path | None) -> str | None:
"""Resolve a non-launch profile's ``terminal.cwd`` from its own config.yaml.
@ -974,15 +992,39 @@ def _profile_configured_cwd(profile_home: Path | None) -> str | None:
return None
with open(p, encoding="utf-8") as f:
data = yaml.safe_load(f) or {}
raw = str((data.get("terminal") or {}).get("cwd") or "").strip()
if not raw or raw in _CWD_PLACEHOLDERS:
return None
resolved = os.path.abspath(os.path.expanduser(raw))
return resolved if os.path.isdir(resolved) else None
return _configured_cwd_from_cfg(data)
except Exception:
return None
def _launch_configured_cwd() -> str | None:
"""Resolve the launch profile's ``terminal.cwd`` from config.yaml.
Dashboard ``/chat`` for the launch profile attaches to the dashboard
process's in-memory TUI gateway. The Node PTY child receives a bridged
``TERMINAL_CWD`` env var, but this in-memory process does not so reading
the process env alone leaves a fresh chat starting in ``os.getcwd()``
(wherever ``hermes dashboard`` was launched) instead of the configured
``terminal.cwd``. Read config directly so changing ``terminal.cwd`` affects
new in-memory TUI sessions too.
"""
try:
return _configured_cwd_from_cfg(_load_cfg())
except Exception:
return None
def _default_session_cwd() -> str:
"""Fallback cwd for a session with no explicit / stored / profile cwd.
Mirrors the launch-config-aware tail of :func:`_completion_cwd` so freshly
created AND resumed sessions land in the configured ``terminal.cwd`` rather
than ``os.getcwd()`` when the in-memory gateway's process env has no bridged
``TERMINAL_CWD``.
"""
return _launch_configured_cwd() or os.getenv("TERMINAL_CWD") or os.getcwd()
def write_json(obj: dict) -> bool:
"""Emit one JSON frame. Routes via the most-specific transport available.
@ -1373,6 +1415,11 @@ def _completion_cwd(params: dict | None = None) -> str:
# A session bound to another profile resolves its workspace from THAT
# profile's config before falling back to the launch profile's env var.
or _profile_configured_cwd(_profile_home(params.get("profile")))
# The launch profile's dashboard /chat attaches to the dashboard's
# in-memory gateway, which does NOT inherit the PTY child's bridged
# TERMINAL_CWD. Read the launch profile's config.yaml directly so a
# configured terminal.cwd wins over a stale process env / launch dir.
or _launch_configured_cwd()
or os.environ.get("TERMINAL_CWD")
or os.getcwd()
)
@ -5401,7 +5448,7 @@ def _(rid, params: dict) -> dict:
if lease is not None:
lease.release()
return _err(rid, 5000, f"resume failed: {e}")
cwd = profile_resume_cwd or os.getenv("TERMINAL_CWD", os.getcwd())
cwd = profile_resume_cwd or _default_session_cwd()
record = _deferred_session_record(
target,
cols=cols,
@ -5474,7 +5521,7 @@ def _(rid, params: dict) -> dict:
# the build drops the provider ("No LLM provider configured").
overrides = _stored_session_runtime_overrides(found) or {}
model_override = overrides.get("model_override") or {}
cwd = profile_resume_cwd or os.getenv("TERMINAL_CWD", os.getcwd())
cwd = profile_resume_cwd or _default_session_cwd()
record = _deferred_session_record(
target,
cols=cols,
@ -5760,7 +5807,7 @@ def _fallback_session_info(session: dict) -> dict:
if agent is not None:
return _session_info(agent)
return {
"cwd": os.getenv("TERMINAL_CWD", os.getcwd()),
"cwd": _default_session_cwd(),
"lazy": True,
"model": _resolve_model(),
"skills": {},