{"repo":"NousResearch/hermes-agent","public_id":"26783","display_label":"Terminal CWD and path normalization bug fixes","summary":"A cluster of fixes addressing working directory handling edge cases: graceful fallbacks when CWD is deleted, normalization of relative and MSYS/Git Bash paths on Windows, and resolving errors when the current directory is already the target.","subsystem":"environments","counts":{"members":3,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":1,"merged_prs":1,"attempt_prs":1},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":65475,"merged_at":"2026-07-16T08:28:14+00:00"},"resolution":{"state":"resolved","by":"pr-65475"},"freshness":{"state":"verified","last_verified_at":"2026-07-16T08:28:14+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.6666666666666666},"fix_edges":[{"issue_number":26783,"pr_number":65475,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true,"pr_merged_at":"2026-07-16T08:28:14+00:00"}],"members":[{"kind":"issue","number":26783,"title":"terminal tool fails with `cd: hermes-agent: No such file or directory` when CWD is already the target directory","url":null,"state":"closed"},{"kind":"pr","number":26790,"title":"fix: normalize local terminal relative cwd","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"5458c7656","number":0},{"kind":"pr","value":"#65475","number":65475}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":65475,"title":"fix(local): normalize relative initial cwd to an absolute host path","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["scripts/release.py","tests/tools/test_local_env_relative_cwd.py","tools/environments/local.py"],"fix_hunks":[{"file":"scripts/release.py","hunks":["@@ -1990,6 +1990,7 @@\n     \"[redacted:email]\": \"dexhunter\",  # PR #60339 salvage (skills snapshot manifest speedup)\n     \"[redacted:email]\": \"uzaylisak\",  # PR #29988 salvage (detect_local_server_type process-lifetime cache)\n     \"[redacted:email]\": \"8294\",  # PR #50572 salvage (honor config context_length on banner)\n+    \"[redacted:email]\": \"ya-nsh\",  # PR #26790 salvage (normalize local terminal relative cwd; #26783)\n }\n \n "]},{"file":"tests/tools/test_local_env_relative_cwd.py","hunks":["@@ -0,0 +1,52 @@\n+\"\"\"Regression tests for local terminal initial cwd normalization.\"\"\"\n+\n+from pathlib import Path\n+\n+from tools.environments.local import LocalEnvironment, _resolve_local_initial_cwd\n+\n+\n+def test_relative_initial_cwd_resolves_from_parent(tmp_path, monkeypatch):\n+    project = tmp_path / \"hermes-agent\"\n+    project.mkdir()\n+    monkeypatch.chdir(tmp_path)\n+\n+    assert _resolve_local_initial_cwd(\"hermes-agent\") == str(project)\n+\n+\n+def test_relative_initial_cwd_matching_current_dir_uses_current_dir(tmp_path, monkeypatch):\n+    project = tmp_path / \"hermes-agent\"\n+    project.mkdir()\n+    monkeypatch.chdir(project)\n+\n+    assert _resolve_local_initial_cwd(\"hermes-agent\") == str(project)\n+\n+\n+def test_local_environment_does_not_cd_into_nested_matching_relative_cwd(tmp_path, monkeypatch):\n+    project = tmp_path / \"hermes-agent\"\n+    project.mkdir()\n+    monkeypatch.chdir(project)\n+\n+    env = LocalEnvironment(cwd=\"hermes-agent\", timeout=5)\n+    try:\n+        result = env.execute(\"pwd\", timeout=5)\n+    finally:\n+        env.cleanup()\n+\n+    assert result[\"returncode\"] == 0\n+    assert result[\"output\"].strip() == str(project)\n+    assert \"cd: hermes-agent\" not in result[\"output\"]\n+\n+\n+def test_local_environment_keeps_existing_relative_child_cwd(tmp_path, monkeypatch):\n+    project = tmp_path / \"hermes-agent\"\n+    project.mkdir()\n+    monkeypatch.chdir(tmp_path)\n+\n+    env = LocalEnvironment(cwd=\"hermes-agent\", timeout=5)\n+    try:\n+        result = env.execute(\"pwd\", timeout=5)\n+    finally:\n+        env.cleanup()\n+\n+    assert result[\"returncode\"] == 0\n+    assert result[\"output\"].strip() == str(project)"]},{"file":"tools/environments/local.py","hunks":["@@ -48,6 +48,46 @@ def _msys_to_windows_path(cwd: str) -> str:\n     return f\"{drive}:{tail or chr(92)}\"  # chr(92) = backslash, avoid raw-string escape\n \n \n+def _resolve_local_initial_cwd(cwd: str) -> str:\n+    \"\"\"Resolve the local backend's initial cwd to an absolute host path.\n+\n+    ``TERMINAL_CWD`` can be populated from config.yaml before the terminal\n+    backend is created.  If that value is relative and happens to match the\n+    directory Hermes was already launched from (for example ``hermes-agent``\n+    while the process cwd is ``~/.hermes/hermes-agent``), passing it through\n+    unchanged makes the wrapper run ``cd hermes-agent`` *inside* the project\n+    and fail with a confusing nested-path error.  Anchor relative local cwd\n+    values once, up front, so both ``subprocess.Popen(cwd=...)`` and the\n+    in-shell ``cd`` use the same absolute directory.\n+    \"\"\"\n+    expanded = os.path.expanduser(cwd) if cwd else os.getcwd()\n+    if _IS_WINDOWS:\n+        expanded = _msys_to_windows_path(expanded)\n+        # Use the Windows-aware check explicitly: when _IS_WINDOWS is\n+        # patched in tests on a POSIX host, os.path.isabs would reject\n+        # ``C:\\Users\\x`` and mangle it through the relative branch.\n+        import ntpath\n+        if ntpath.isabs(expanded):\n+            return expanded\n+    if os.path.isabs(expanded):\n+        return expanded\n+\n+    candidate = os.path.abspath(expanded)\n+    current = os.getcwd()\n+\n+    # Common recovery for config values like ``hermes-agent`` when Hermes was\n+    # launched from that directory already.  ``os.path.abspath`` would point at\n+    # a nonexistent nested ``./hermes-agent``; use the current directory instead.\n+    if not os.path.isdir(candidate):\n+        wanted_parts = Path(expanded).parts\n+        current_parts = Path(current).parts\n+        if wanted_parts and len(wanted_parts) <= len(current_parts):\n+            if current_parts[-len(wanted_parts):] == wanted_parts:\n+                return current\n+\n+    return candidate\n+\n+\n def _windows_to_msys_path(cwd: str) -> str:\n     \"\"\"Translate a native Windows path (``C:\\\\Users\\\\x``) to Git Bash /\n     MSYS form (``/c/Users/x``) so ``builtin cd`` resolves it reliably.","@@ -1102,9 +1142,8 @@ class LocalEnvironment(BaseEnvironment):\n     \"\"\"\n \n     def __init__(self, cwd: str = \"\", timeout: int = 60, env: dict = None):\n-        if cwd:\n-            cwd = os.path.expanduser(cwd)\n-        super().__init__(cwd=cwd or os.getcwd(), timeout=timeout, env=env)\n+        cwd = _resolve_local_initial_cwd(cwd)\n+        super().__init__(cwd=cwd, timeout=timeout, env=env)\n         self.init_session()\n \n     def get_temp_dir(self) -> str:\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}