# NousResearch/hermes-agent · Working directory configuration fixes across components

## Open now

- Nothing open on GitHub.

These items address how the working directory is determined and respected in gateway sessions, agent resolution, and the local backend CLI. They span a feature request and several bug fixes across different parts of the system.

## TL;DR

- 3 member(s): 0 open issue(s), 1 closed issue(s), 0 open PR(s), 1 merged PR(s), 1 PR(s) closed without merging.
- A fix was merged to main in PR #35028. Verify it addresses your case.
- **Recommended:** read PR #35028 (and any competing PRs below) before filing. Confirm the merged change covers your symptom.

## Maintainer resolution verdict

- Internal-tracker verdict over this cluster's issue members: `unresolved`. This is distinct from the fix status above. Weigh it yourself.

## How this cluster was grouped

- Link evidence (`STRUCTURAL_SPANNING`): every member is connected by structural evidence: shared changed files, or a closing/superseded reference.
- Pairwise link density `0.67`: the share of member pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36. `1.00` means every pair is linked.

## Closed / duplicate issues

- #29265: \[Feature\]: Configurable default working directory for QQBot and Weixin gateway sessions

## Prior & competing PRs

- **#35028: fix(agent): resolve agent cwd from TERMINAL_CWD via one reader (closes #24882, #24969, #27383)**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`
  - files: `agent/prompt_builder.py`, `agent/runtime_cwd.py`, `agent/system_prompt.py`, `tests/agent/test_prompt_builder.py`, `tests/agent/test_runtime_cwd.py`, `tests/agent/test_system_prompt.py`, `tests/tools/test_gateway_cwd_contract.py`
  - fix hunks:
  - `agent/prompt_builder.py`

    ```diff
    @@ -14,6 +14,7 @@
     from hermes_constants import get_hermes_home, get_skills_dir, is_wsl
     from typing import Optional
     
    +from agent.runtime_cwd import resolve_agent_cwd
     from agent.skill_utils import (
         extract_skill_conditions,
         extract_skill_description,
    ```

    ```diff
    @@ -802,7 +803,7 @@ def build_environment_hints() -> str:
     
             host_lines.append(f"User home directory: {os.path.expanduser('~')}")
             try:
    -            host_lines.append(f"Current working directory: {os.getcwd()}")
    +            host_lines.append(f"Current working directory: {resolve_agent_cwd()}")
             except OSError:
                 pass
     
    ```
  - `agent/runtime_cwd.py`

    ```diff
    @@ -0,0 +1,33 @@
    +"""Single source of truth for the agent working directory.
    +
    +`TERMINAL_CWD` is the runtime carrier for the configured working directory
    +(design #19214/#19242: `terminal.cwd` is bridged once to `TERMINAL_CWD` at
    +gateway/cron startup). The local-CLI backend deliberately leaves it unset and
    +relies on the launch dir. Reading it in one place keeps the system prompt, the
    +tool surfaces, and context-file discovery agreeing on where the agent lives.
    +
    +The #29531 per-session extension point is this function: a future PR adds a
    +contextvar arm inside `resolve_agent_cwd` and `.set()`s it at the
    +`set_session_vars` seam — by design, not a reopening hazard.
    +"""
    +
    +import os
    +from pathlib import Path
    +
    +
    +def resolve_agent_cwd() -> Path:
    +    raw = os.environ.get("TERMINAL_CWD", "").strip()
    +    if raw:
    +        p = Path(raw).expanduser()
    +        if p.is_dir():
    +            return p
    +    return Path(os.getcwd())
    +
    +
    +def resolve_context_cwd() -> Path | None:
    +    # None means "no configured cwd": build_context_files_prompt then falls back
    +    # to the launch dir (os.getcwd()) — correct for the local CLI. The gateway
    +    # avoids slurping its install dir by setting TERMINAL_CWD (see system_prompt.py).
    +    # No getcwd arm here: that fallback is owned by the caller, not this resolver.
    +    raw = os.environ.get("TERMINAL_CWD", "").strip()
    +    return Path(raw).expanduser() if raw else None
    ```
  - `agent/system_prompt.py`

    ```diff
    @@ -24,7 +24,6 @@
     from __future__ import annotations
     
     import json
    -import os
     from typing import Any, Dict, List, Optional
     
     from agent.prompt_builder import (
    ```

    ```diff
    @@ -41,6 +40,7 @@
         TOOL_USE_ENFORCEMENT_GUIDANCE,
         TOOL_USE_ENFORCEMENT_MODELS,
     )
    +from agent.runtime_cwd import resolve_context_cwd
     
     
     def _ra():
    ```

    ```diff
    @@ -288,13 +288,12 @@ def build_system_prompt_parts(agent: Any, system_message: Optional[str] = None)
             context_parts.append(system_message)
     
         if not agent.skip_context_files:
    -        # Use TERMINAL_CWD for context file discovery when set (gateway
    -        # mode).  The gateway process runs from the hermes-agent install
    -        # dir, so os.getcwd() would pick up the repo's AGENTS.md and
    -        # other dev files — inflating token usage by ~10k for no benefit.
    -        _context_cwd = os.getenv("TERMINAL_CWD") or None
    +        # Prefer the configured TERMINAL_CWD (gateway mode). When unset (local
    +        # CLI), None lets build_context_files_prompt fall back to the launch
    +        # dir — the user's real cwd there, but the install dir for the gateway
    +        # daemon, which is why the gateway sets TERMINAL_CWD.
             context_files_prompt = _r.build_context_files_prompt(
    -            cwd=_context_cwd, skip_soul=_soul_loaded)
    +            cwd=resolve_context_cwd(), skip_soul=_soul_loaded)
             if context_files_prompt:
                 context_parts.append(context_files_prompt)
     
    ```
  - `tests/agent/test_prompt_builder.py`

    ```diff
    @@ -927,6 +927,29 @@ def test_build_environment_hints_suppresses_host_on_docker_backend(self, monkeyp
             assert "Terminal backend: docker" in result
             assert "inside" in result.lower()
     
    +    def test_build_environment_hints_uses_terminal_cwd_over_launch_dir(self, monkeypatch, tmp_path):
    +        """THE BUG: gateway/cron set TERMINAL_CWD but the prompt emitted os.getcwd()
    +        (the daemon launch dir). Regression for #24882/#24969/#27383/#29265."""
    +        import agent.prompt_builder as _pb
    +        monkeypatch.setattr(_pb, "is_wsl", lambda: False)
    +        monkeypatch.delenv("TERMINAL_ENV", raising=False)
    +        configured = tmp_path / "workspace"
    +        configured.mkdir()
    +        monkeypatch.setenv("TERMINAL_CWD", str(configured))
    +        monkeypatch.chdir(tmp_path)
    +        _pb._clear_backend_probe_cache()
    +        assert f"Current working directory: {configured}" in _pb.build_environment_hints()
    +
    +    def test_build_environment_hints_falls_back_to_launch_dir(self, monkeypatch, tmp_path):
    +        """The #19242 local-CLI contract: no TERMINAL_CWD → the launch dir."""
    +        import agent.prompt_builder as _pb
    +        monkeypatch.setattr(_pb, "is_wsl", lambda: False)
    +        monkeypatch.delenv("TERMINAL_ENV", raising=False)
    +        monkeypatch.delenv("TERMINAL_CWD", raising=False)
    +        monkeypatch.chdir(tmp_path)
    +        _pb._clear_backend_probe_cache()
    +        assert f"Current working directory: {tmp_path}" in _pb.build_environment_hints()
    +
         def test_build_environment_hints_uses_live_probe_when_available(self, monkeypatch):
             """When the probe succeeds, its output must appear in the hint block."""
             import agent.prompt_builder as _pb
    ```

    ```diff
    @@ -1247,4 +1270,3 @@ def test_guidance_is_string(self):
     # =========================================================================
     
     
    -
    ```
  - `tests/agent/test_runtime_cwd.py`

    ```diff
    @@ -0,0 +1,79 @@
    +"""Tests for agent/runtime_cwd.py — the single source of truth for the agent working directory."""
    +
    +import os
    +from pathlib import Path
    +
    +import pytest
    +
    +import agent.runtime_cwd as rt
    +from agent.runtime_cwd import resolve_agent_cwd, resolve_context_cwd
    +
    +
    +def _raise_oserror(*args, **kwargs):
    +    raise OSError("cwd gone")
    +
    +
    +class TestResolveAgentCwd:
    +    def test_prefers_terminal_cwd_over_getcwd(self, monkeypatch, tmp_path):
    +        monkeypatch.setenv("TERMINAL_CWD", str(tmp_path))
    +        monkeypatch.chdir(os.path.expanduser("~"))
    +        assert resolve_agent_cwd() == tmp_path
    +
    +    def test_falls_back_to_getcwd_when_unset(self, monkeypatch, tmp_path):
    +        # The #19242 local-CLI contract: TERMINAL_CWD is unset, so the launch dir wins.
    +        monkeypatch.delenv("TERMINAL_CWD", raising=False)
    +        monkeypatch.chdir(tmp_path)
    +        assert resolve_agent_cwd() == tmp_path
    +
    +    def test_skips_nonexistent_terminal_cwd(self, monkeypatch, tmp_path):
    +        monkeypatch.setenv("TERMINAL_CWD", str(tmp_path / "gone"))
    +        monkeypatch.chdir(tmp_path)
    +        assert resolve_agent_cwd() == tmp_path
    +
    +    def test_expands_leading_tilde(self, monkeypatch):
    +        monkeypatch.setenv("TERMINAL_CWD", "~")
    +        assert resolve_agent_cwd() == Path(os.path.expanduser("~"))
    +
    +    def test_whitespace_only_terminal_cwd_falls_back_to_getcwd(self, monkeypatch, tmp_path):
    +        # "   ".strip() → "" → falsy, so the launch dir wins (not a "   " path).
    +        monkeypatch.setenv("TERMINAL_CWD", "   ")
    +        monkeypatch.chdir(tmp_path)
    +        assert resolve_agent_cwd() == tmp_path
    +
    +    def test_propagates_oserror_from_getcwd(self, monkeypatch):
    +        # The fallback arm calls os.getcwd(), which can raise OSError (deleted cwd).
    +        # The resolver must NOT swallow it — build_environment_hints owns the
    +        # try/except OSError guard at the call site (prompt_builder.py:805).
    +        monkeypatch.delenv("TERMINAL_CWD", raising=False)
    +        monkeypatch.setattr(rt.os, "getcwd", _raise_oserror)
    +        with pytest.raises(OSError):
    +            resolve_agent_cwd()
    +
    +
    +class TestResolveContextCwd:
    +    def test_returns_dir_when_set(self, monkeypatch, tmp_path):
    +        monkeypatch.setenv("TERMINAL_CWD", str(tmp_path))
    +        assert resolve_context_cwd() == tmp_path
    +
    +    def test_returns_none_when_unset(self, monkeypatch):
    +        # Unset → None; the caller (build_context_files_prompt) then getcwds —
    +        # the local-CLI #19242 contract. Discovery still runs; it is NOT skipped.
    +        monkeypatch.delenv("TERMINAL_CWD", raising=False)
    +        assert resolve_context_cwd() is None
    +
    +    def test_returns_nonexistent_dir_unguarded(self, monkeypatch, tmp_path):
    +        # Deliberate asymmetry vs resolve_agent_cwd: context discovery has no isdir
    +        # guard, so a missing dir is returned (not None) — discovery just finds nothing.
    +        missing = tmp_path / "gone"
    +        monkeypatch.setenv("TERMINAL_CWD", str(missing))
    +        assert resolve_context_cwd() == missing
    +
    +    def test_expands_leading_tilde(self, monkeypatch):
    +        monkeypatch.setenv("TERMINAL_CWD", "~")
    +        assert resolve_context_cwd() == Path(os.path.expanduser("~"))
    +
    +    def test_whitespace_only_terminal_cwd_returns_none(self, monkeypatch):
    +        # "   ".strip() → "" → None, so the caller getcwds for discovery rather
    +        # than building Path("   ") and resolving garbage under the launch dir.
    +        monkeypatch.setenv("TERMINAL_CWD", "   ")
    +        assert resolve_context_cwd() is None
    ```
  - `tests/agent/test_system_prompt.py`

    ```diff
    @@ -0,0 +1,57 @@
    +"""Tests for agent/system_prompt.py — context-file cwd wiring."""
    +
    +from types import SimpleNamespace
    +from unittest.mock import patch
    +
    +from agent.system_prompt import build_system_prompt_parts
    +
    +
    +def _make_agent(**overrides):
    +    base = dict(
    +        load_soul_identity=False,
    +        skip_context_files=False,
    +        valid_tool_names=[],
    +        _task_completion_guidance=False,
    +        _tool_use_enforcement=False,
    +        _environment_probe=False,
    +        _kanban_worker_guidance="",
    +        _memory_store=None,
    +        _memory_manager=None,
    +        model="",
    +        provider="",
    +        platform="",
    +        pass_session_id=False,
    +        session_id="",
    +    )
    +    base.update(overrides)
    +    return SimpleNamespace(**base)
    +
    +
    +def _captured_context_cwd(agent):
    +    """The cwd build_system_prompt_parts hands to build_context_files_prompt."""
    +    captured = {}
    +
    +    def fake_context_files(cwd=None, skip_soul=False):
    +        captured["cwd"] = cwd
    +        return ""
    +
    +    with (
    +        patch("run_agent.load_soul_md", return_value=""),
    +        patch("run_agent.build_nous_subscription_prompt", return_value=""),
    +        patch("run_agent.build_environment_hints", return_value=""),
    +        patch("run_agent.build_context_files_prompt", side_effect=fake_context_files),
    +    ):
    +        build_system_prompt_parts(agent)
    +    return captured["cwd"]
    +
    +
    +class TestContextFileCwd:
    +    def test_none_when_terminal_cwd_unset(self, monkeypatch):
    +        # Unset → None, so discovery falls back to the launch dir inside
    +        # build_context_files_prompt (the local-CLI #19242 contract).
    +        monkeypatch.delenv("TERMINAL_CWD", raising=False)
    +        assert _captured_context_cwd(_make_agent()) is None
    +
    +    def test_configured_dir_when_terminal_cwd_set(self, monkeypatch, tmp_path):
    +        monkeypatch.setenv("TERMINAL_CWD", str(tmp_path))
    +        assert _captured_context_cwd(_make_agent()) == tmp_path
    ```
  - `tests/tools/test_gateway_cwd_contract.py`

    ```diff
    @@ -0,0 +1,69 @@
    +"""Tool-surface cwd contract tests for gateway workspaces.
    +
    +These cover the platform-neutral part of #29265: once the gateway has resolved
    +``TERMINAL_CWD``, the user-visible tool surfaces should agree on that workspace.
    +
    +Unlike the system-prompt readers fixed in the gateway-cwd-resolver cluster
    +(agent/runtime_cwd.py), these tool sites already read ``TERMINAL_CWD``-first and
    +were deliberately left out of scope. This file is a *characterization* guard: it
    +pins the already-correct behavior so the supersession of PR #29365 is airtight
    +and a future refactor of these sites can't silently regress the contract.
    +"""
    +
    +from __future__ import annotations
    +
    +from pathlib import Path
    +
    +from tools import code_execution_tool, file_tools, terminal_tool
    +
    +
    +def test_terminal_env_config_uses_terminal_cwd(monkeypatch, tmp_path):
    +    """The terminal tool's default cwd should come from TERMINAL_CWD."""
    +    workspace = tmp_path / "workspace"
    +    workspace.mkdir()
    +
    +    monkeypatch.setenv("TERMINAL_ENV", "local")
    +    monkeypatch.setenv("TERMINAL_CWD", str(workspace))
    +
    +    config = terminal_tool._get_env_config()
    +
    +    assert config["cwd"] == str(workspace)
    +
    +
    +def test_file_tool_relative_paths_use_terminal_cwd(monkeypatch, tmp_path):
    +    """Relative file/search/patch paths resolve under TERMINAL_CWD."""
    +    workspace = tmp_path / "workspace"
    +    workspace.mkdir()
    +
    +    monkeypatch.setenv("TERMINAL_CWD", str(workspace))
    +
    +    resolved = file_tools._resolve_path_for_task("notes/today.md", task_id="cwd-contract")
    +
    +    assert resolved == (workspace / "notes" / "today.md").resolve()
    +
    +
    +def test_execute_code_project_mode_uses_terminal_cwd(monkeypatch, tmp_path):
    +    """Project-mode execute_code should run scripts from TERMINAL_CWD."""
    +    workspace = tmp_path / "workspace"
    +    staging = tmp_path / "staging"
    +    workspace.mkdir()
    +    staging.mkdir()
    +
    +    monkeypatch.setenv("TERMINAL_CWD", str(workspace))
    +
    +    resolved = code_execution_tool._resolve_child_cwd("project", str(staging))
    +
    +    assert Path(resolved) == workspace
    +
    +
    +def test_execute_code_project_mode_falls_back_when_terminal_cwd_missing(monkeypatch, tmp_path):
    +    """Invalid TERMINAL_CWD should not break execute_code project mode startup."""
    +    staging = tmp_path / "staging"
    +    staging.mkdir()
    +
    +    monkeypatch.setenv("TERMINAL_CWD", str(tmp_path / "missing"))
    +
    +    resolved = code_execution_tool._resolve_child_cwd("project", str(staging))
    +
    +    assert Path(resolved).is_dir()
    +    assert Path(resolved) != tmp_path / "missing"
    ```
- **#29365: fix(gateway): honor configured cwd in gateway sessions**. closed without merge
  - maintainer direction: category `implemented_elsewhere`; implication `superseded`; refs `#29265`, `#35028`

## Fix status

- Merged to main in PR #35028 (cited above). No release data is tracked. Verify downstream.
- Fix last verified (merged) `2026-06-01`.
- Subsystem: `agent`
