Session-scoped working directory tracking and fixes

NousResearch/hermes-agent · cluster 10309 · agent

Data as of

9 open items. Fix merged in #65213. Check it covers your case.

The short answer

5open issues
4open PRs
23items grouped
Known?
Yes. 23 related items grouped here, 3 issues already closed.
Fix event
A fix was merged to main in PR #65213. Check it covers your case. No release data is tracked.
Tracker
The maintainers' tracker marks this partially resolved, deciding PR #65487. That is separate from the fix event.
Still open
5 issues and 4 PRs.

These issues and PRs center on per-session working directory management: fixing stale CWD values in CLI, TUI, runtime footer, and code execution, and adding features to set or switch the working directory per session across CLI, desktop, and gateway contexts. A small number of members touch unrelated skills/asset lookup behavior.

Open issues5

  • Issue#10309Feature: session-scoped repo pinning to preserve repository identity across compression/resume
  • Issue#29531Per-session working directory for gateway (OpenAI-compatible API) sessions
  • Issue#35745[Bug]: Runtime footer displays stale TERMINAL_CWD instead of the agent's live working directory
  • Issue#37277[Feature]: Add session-scoped workspace binding for gateway chats
  • Issue#50195feat: Add ability to switch working directory during session

Open PRs4

  • PR#35767fix(gateway): show live cwd in runtime footer
  • PR#104615fix(gateway): persist per-session workspaces
  • PR#105478feat(api-server): bind per-run workspace cwd on /v1/runs
  • PR#105991feat(tui): add /cd slash command and scope path completion to session cwd

Fixes on record

Merged to main. A stamp counts the issues a fix is linked to.

  • #65213 refactor(terminal): per-session cwd records replace shared-env cwd tracking

    merged to main

    files: tests/agent/test_file_safety_credentials.py, tests/tools/test_container_cwd_sanitize.py, tests/tools/test_file_staleness.py, tests/tools/test_file_tools.py, tests/tools/test_file_tools_cwd_resolution.py, tests/tools/test_file_tools_tilde_profile.py, tests/tools/test_resolve_path.py, tests/tools/test_session_cwd_store.py, tests/tools/test_terminal_task_cwd.py, tools/delegate_tool.py, tools/file_tools.py, tools/terminal_tool.py

    Show the diff
    • tests/agent/test_file_safety_credentials.py
      @@ -144,6 +144,7 @@ def test_read_file_tool_blocks_relative_path_under_terminal_cwd(
           import json
       
           import tools.file_tools as ft
      +    import tools.terminal_tool as terminal_tool
       
           _create(fake_home, "auth.json")
           # Force the file_tools resolver to anchor relative paths at HERMES_HOME
      @@ -151,7 +152,7 @@ def test_read_file_tool_blocks_relative_path_under_terminal_cwd(
           monkeypatch.setenv("TERMINAL_CWD", str(fake_home))
           monkeypatch.chdir(tmp_path)
           monkeypatch.setattr(
      -        ft, "_get_live_tracking_cwd", lambda task_id="default": None
      +        terminal_tool, "_session_cwd", {}
           )
       
           out = json.loads(ft.read_file_tool("auth.json"))
      @@ -166,6 +167,7 @@ def test_read_file_tool_blocks_nested_google_oauth_path(
           import json
       
           import tools.file_tools as ft
      +    import tools.terminal_tool as terminal_tool
       
           oauth = _create(fake_home, Path("auth") / "google_oauth.json")
           oauth.write_text(
      @@ -180,7 +182,7 @@ def test_read_file_tool_blocks_nested_google_oauth_path(
           )
           monkeypatch.chdir(tmp_path)
           monkeypatch.setattr(
      -        ft, "_get_live_tracking_cwd", lambda task_id="default": None
      +        terminal_tool, "_session_cwd", {}
           )
       
           out = json.loads(ft.read_file_tool(str(oauth), task_id="google-oauth-test"))
      @@ -195,6 +197,7 @@ def test_search_tool_blocks_direct_auth_json_path(fake_home, monkeypatch):
           import json
       
           import tools.file_tools as ft
      +    import tools.terminal_tool as terminal_tool
       
           auth = _create(fake_home, "auth.json")
           auth.write_text("SEARCH_DIRECT_AUTH_SECRET", encoding="utf-8")
      @@ -223,6 +226,7 @@ def test_search_tool_filters_credential_results(fake_home, tmp_path, monkeypatch
       
           from tools.file_operations import SearchMatch, SearchResult
           import tools.file_tools as ft
      +    import tools.terminal_tool as terminal_tool
       
           auth = _create(fake_home, "auth.json")
           token = [redacted:secret-assignment] Path("mcp-tokens") / "provider.json")
      @@ -256,7 +260,7 @@ def search(self, **kwargs):
           monkeypatch.chdir(tmp_path)
           monkeypatch.setattr(ft, "_get_file_ops", lambda task_id="default": FakeFileOps())
           monkeypatch.setattr(
      -        ft, "_get_live_tracking_cwd", lambda task_id="default": None
      +        terminal_tool, "_session_cwd", {}
           )
       
           search_response = ft.search_tool(
    • tests/tools/test_container_cwd_sanitize.py
      @@ -211,7 +211,7 @@ def fake_create_environment(env_type, image, cwd, timeout, **kwargs):
               monkeypatch.setattr(tt, "_active_environments", {})
               monkeypatch.setattr(tt, "_last_activity", {})
               monkeypatch.setattr(ft, "_file_ops_cache", {})
      -        monkeypatch.setattr(ft, "_last_known_cwd", {})
      +        monkeypatch.setattr(tt, "_session_cwd", {})
       
               task_id = "sess-fileops-host-cwd"
               tt.register_task_env_overrides(task_id, {"cwd": override_cwd})
    • tests/tools/test_file_staleness.py
      @@ -150,8 +150,8 @@ def test_different_task_isolated(self, mock_ops):
               self.assertNotIn("_warning", result)
       
           @patch("tools.file_tools._get_file_ops")
      -    def test_relative_path_uses_live_cwd_for_staleness_tracking(self, mock_ops):
      -        """Relative-path stale tracking must follow the live terminal cwd."""
      +    def test_relative_path_uses_recorded_session_cwd_for_staleness_tracking(self, mock_ops):
      +        """Relative-path stale tracking must follow the session's recorded cwd."""
               start_dir = os.path.join(self._tmpdir, "start")
               live_dir = os.path.join(self._tmpdir, "worktree")
               os.makedirs(start_dir, exist_ok=True)
      @@ -165,15 +165,12 @@ def test_relative_path_uses_live_cwd_for_staleness_tracking(self, mock_ops):
                   f.write("live copy\n")
       
               fake_ops = _make_fake_ops("live copy\n", 10)
      -        fake_ops.env = SimpleNamespace(cwd=live_dir)
      -        fake_ops.cwd = start_dir
               mock_ops.return_value = fake_ops
       
      -        from tools import file_tools
      +        from tools import terminal_tool
       
      -        with file_tools._file_ops_lock:
      -            previous = file_tools._file_ops_cache.get("live_task")
      -            file_tools._file_ops_cache["live_task"] = fake_ops
      +        # The session cd'd into the worktree (recorded by the completed command).
      +        terminal_tool.record_session_cwd("live_task", live_dir)
       
               try:
                   with patch.dict(os.environ, {"TERMINAL_CWD": start_dir}, clear=False):
      @@ -187,11 +184,7 @@ def test_relative_path_uses_live_cwd_for_staleness_tracking(self, mock_ops):
                           write_file_tool("shared.txt", "replacement", task_id="live_task")
                       )
               finally:
      -            with file_tools._file_ops_lock:
      -                if previous is None:
      -                    file_tools._file_ops_cache.pop("live_task", None)
      -                else:
      -                    file_tools._file_ops_cache["live_task"] = previous
      +            terminal_tool.clear_session_cwd("live_task")
       
               self.assertIn("_warning", result)
               self.assertIn("modified since you last read", result["_warning"])
    • tests/tools/test_file_tools.py
      @@ -677,13 +677,16 @@ def test_no_anyof_required_stays_mode_only(self):
       
       
       # ---------------------------------------------------------------------------
      -# _last_known_cwd tests (#26211: silent file creation failure in long conversations)
      +# Session-cwd persistence across env recreation (#26211: silent file creation
      +# failure in long conversations). The durable anchor is the per-session cwd
      +# record in terminal_tool; env cleanup cannot lose it because it never lived
      +# on the env.
       # ---------------------------------------------------------------------------
       
      -class TestLastKnownCwd:
      +class TestSessionCwdSurvivesEnvRecreation:
           """
           When the terminal environment is cleaned up and re-created during a long
      -    conversation, _last_known_cwd preserves the old environment's CWD so
      +    conversation, the session's cwd record preserves the working directory so
           subsequent file writes with relative paths land in the right directory.
       
           Regression guard for issue #26211.
      @@ -693,12 +696,12 @@ class TestLastKnownCwd:
           @patch("tools.file_tools._file_ops_cache", new_callable=dict)
           @patch("tools.terminal_tool._get_env_config")
           @patch("tools.terminal_tool._create_environment")
      -    def test_last_known_cwd_preserved_across_env_recreation(
      +    def test_recorded_cwd_used_for_recreated_env(
               self, mock_create_env, mock_config, mock_cache, mock_active
           ):
      -        from tools.file_tools import _get_file_ops, _last_known_cwd
      +        import tools.terminal_tool as tt
      +        from tools.file_tools import _get_file_ops
       
      -        # Setup: create a mock env with a known CWD
               mock_env = MagicMock()
               mock_env.cwd = "/Users/user/project"
               mock_create_env.return_value = mock_env
      @@ -709,42 +712,35 @@ def test_last_known_cwd_preserved_across_env_recreation(
               }
       
               task_id = "default"
      +        # The session's record holds the directory (written by the last
      +        # completed terminal command before the env was cleaned up).
      +        tt.record_session_cwd(task_id, "/Users/user/project")
      +        try:
      +            _get_file_ops(task_id)
      +
      +            create_call = mock_create_env.call_args
      +            assert create_call is not None, "_create_environment was not called"
      +            kwargs = create_call.kwargs if create_call.kwargs else {}
      +            cwd_passed = kwargs.get("cwd", None)
      +            if cwd_passed is None:
      +                args = create_call.args if create_call.args else []
      +                if len(args) >= 3:
      +                    cwd_passed = args[2]
      +
      +            assert cwd_passed == "/Users/user/project", \
      +                f"Expected cwd='/Users/user/project', got {cwd_passed!r}"
      +        finally:
      +            tt.clear_session_cwd(task_id)
       
      -        # Preset _last_known_cwd to simulate a previous env's CWD
      -        _last_known_cwd[task_id] = "/Users/user/project"
      -
      -        # Call _get_file_ops - should use _last_known_cwd for the new env
      -        result = _get_file_ops(task_id)
      -
      -        # Verify the env was created with the saved CWD, not the default
      -        create_call = mock_create_env.call_args
      -        assert create_call is not None, "_create_environment was not called"
      -        
      -        # Find cwd in the kwargs
      -        kwargs = create_call.kwargs if create_call.kwargs else {}
      -        # cwd is passed as positional or keyword
      -        cwd_passed = kwargs.get("cwd", None)
      -        if cwd_passed is None:
      -            # Try positional args
      -            args = create_call.args if create_call.args else []
      -            # Position: (env_type, image, cwd, timeout, ...)
      -            if len(args) >= 3:
      -                cwd_passed = args[2]
      -        
      -        assert cwd_passed == "/Users/user/project", \
      -            f"Expected cwd='/Users/user/project', got {cwd_passed!r}"
      -        
      -        # Cleanup
      -        _last_known_cwd.pop(task_id, None)
      -        
           @patch("tools.terminal_tool._active_environments", new_callable=dict)
           @patch("tools.file_tools._file_ops_cache", new_callable=dict)
           @patch("tools.terminal_tool._get_env_config")
           @patch("tools.terminal_tool._create_environment")
      -    def test_last_known_cwd_falls_back_to_config_default_when_not_set(
      +    def test_falls_back_to_config_default_when_no_record(
               self, mock_create_env, mock_config, mock_cache, mock_active
           ):
      -        from tools.file_tools import _get_file_ops, _last_known_cwd
      +        import tools.terminal_tool as tt
      +        from tools.file_tools import _get_file_ops
       
               mock_env = MagicMock()
               mock_env.cwd = "/default/path"
      @@ -755,85 +751,44 @@ def test_last_known_cwd_falls_back_to_config_default_when_not_set(
                   "timeout": 30,
               }
       
      -        # _get_file_ops resolves to "default"
               task_id = "default"
      -        
      -        # Ensure _last_known_cwd is empty for this task
      -        _last_known_cwd.pop(task_id, None)
      +        tt.clear_session_cwd(task_id)
      +
      +        _get_file_ops(task_id)
       
      -        result = _get_file_ops(task_id)
      -        
               create_call = mock_create_env.call_args
               assert create_call is not None, "_create_environment was not called"
      -        
               kwargs = create_call.kwargs if create_call.kwargs else {}
               cwd_passed = kwargs.get("cwd", None)
               if cwd_passed is None:
                   args = create_call.args if create_call.args else []
                   if len(args) >= 3:
                       cwd_passed = args[2]
      -        
      -        # Should fall back to config default
      +
               assert cwd_passed == "/config/default/path", \
                   f"Expected cwd='/config/default/path', got {cwd_passed!r}"
       
      -    @patch("tools.terminal_tool._active_environments", new_callable=dict)
      -    @patch("tools.file_tools._file_ops_cache", new_callable=dict)
      -    def test_live_cwd_read_mirrors_into_last_known_cwd(self, mock_cache, mock_active):
      -        """Belt-and-suspenders (#26211): every successful live-cwd read records
      -        the cwd in _last_known_cwd, so the durable anchor doesn't depend on the
      -        cleanup-detection branch of _get_file_ops firing."""
      -        from tools.file_tools import _get_live_tracking_cwd, _last_known_cwd
      -
      -        task_id = "default"
      -        _last_known_cwd.pop(task_id, None)
      -
      -        cached = MagicMock()
      -        cached.env = MagicMock()
      -        cached.env.cwd = "/Users/user/project"
      -        cached.env.cwd_owner = "default"
      -        mock_cache[task_id] = cached
      -
      -        live = _get_live_tracking_cwd(task_id)
      -
      -        assert live == "/Users/user/project"
      -        # The read mirrored the live cwd into the durable registry.
      -        assert _last_known_cwd.get(task_id) == "/Users/user/project"
      -        _last_known_cwd.pop(task_id, None)
      -
           @patch("tools.terminal_tool._active_environments", new_callable=dict)
           @patch("tools.file_tools._file_ops_cache", new_callable=dict)
           @patch("tools.terminal_tool._get_env_config")
           @patch("tools.terminal_tool._create_environment")
      -    def test_mirrored_cwd_survives_when_cache_already_cleared(
      +    def test_stale_cache_cwd_rescued_into_record_on_cleanup_detection(
               self, mock_create_env, mock_config, mock_cache, mock_active
           ):
      -        """The original save-old-cwd path only fires when _file_ops_cache still
      -        holds the stale entry. If the cleanup thread popped BOTH dicts first,
      -        _get_file_ops sees cached=None and never saves — but the proactive
      -        mirror from an earlier live read already populated _last_known_cwd, so
      -        the rebuilt env still restores the user's directory."""
      -        from tools.file_tools import (
      -            _get_file_ops, _get_live_tracking_cwd, _last_known_cwd,
      -        )
      +        """If the env died but the file-ops cache entry survived, its cwd is
      +        rescued into the session record before the cache entry is dropped —
      +        the recreated env starts where the user left off."""
      +        import tools.terminal_tool as tt
      +        from tools.file_tools import _get_file_ops
       
               task_id = "default"
      -        _last_known_cwd.pop(task_id, None)
      +        tt.clear_session_cwd(task_id)
       
      -        # 1) Env is alive and the agent has cd'd into the project. A live read
      -        #    (happens on every relative-path resolution) mirrors the cwd.
      +        # Stale cache entry: env was cleaned up, cache still holds the old cwd.
               cached = MagicMock()
      -        cached.env = MagicMock()
      -        cached.env.cwd = "/Users/user/project"
      -        cached.env.cwd_owner = "default"
      +        cached.env = None
      +        cached.cwd = "/Users/user/project"
               mock_cache[task_id] = cached
      -        assert _get_live_tracking_cwd(task_id) == "/Users/user/project"
      -        assert _last_known_cwd.get(task_id) == "/Users/user/project"
      -
      -        # 2) Cleanup thread kills the env AND clears the cache before the next
      -        #    file write — so _get_file_ops' save-old-cwd branch never runs.
      -        mock_cache.pop(task_id, None)
      -        mock_active.clear()
       
               mock_env = MagicMock()
               mock_env.cwd = "/Users/user/project"
      @@ -844,21 +799,23 @@ def test_mirrored_cwd_survives_when_cache_already_cleared(
                   "timeout": 30,
               }
       
      -        _get_file_ops(task_id)
      +        try:
      +            _get_file_ops(task_id)
       
      -        create_call = mock_create_env.call_args
      -        assert create_call is not None, "_create_environment was not called"
      -        kwargs = create_call.kwargs if create_call.kwargs else {}
      -        cwd_passed = kwargs.get("cwd", None)
      -        if cwd_passed is None:
      -            args = create_call.args if create_call.args else []
      -            if len(args) >= 3:
      -                cwd_passed = args[2]
      +            create_call = mock_create_env.call_args
      +            assert create_call is not None, "_create_environment was not called"
      +            kwargs = create_call.kwargs if create_call.kwargs else {}
      +            cwd_passed = kwargs.get("cwd", None)
      +            if cwd_passed is None:
      +                args = create_call.args if create_call.args else []
      +                if len(args) >= 3:
      +                    cwd_passed = args[2]
       
      -        # Rebuilt env restored the mirrored cwd, NOT the config default.
      -        assert cwd_passed == "/Users/user/project", \
      -            f"Expected restored cwd='/Users/user/project', got {cwd_passed!r}"
      -        _last_known_cwd.pop(task_id, None)
      +            # Rebuilt env restored the rescued cwd, NOT the config default.
      +            assert cwd_passed == "/Users/user/project", \
      +                f"Expected restored cwd='/Users/user/project', got {cwd_passed!r}"
      +        finally:
      +            tt.clear_session_cwd(task_id)
       
       
       class TestSilentFileMisplacementE2E:
      @@ -868,8 +825,8 @@ class TestSilentFileMisplacementE2E:
           agent cd's into a project, the cleanup thread kills the env, and a later
           relative-path write must land in the project dir (not the config default).
           Mocks miss this because resolution (_resolve_path_for_task) runs BEFORE
      -    _get_file_ops rebuilds the env — only the durable _last_known_cwd fallback
      -    in _authoritative_workspace_root makes the resolved path correct.
      +    _get_file_ops rebuilds the env — only the durable session-cwd record
      +    makes the resolved path correct.
           """
       
           def test_relative_write_after_env_cleanup_lands_in_user_cwd(self, tmp_path, monkeypatch):
      @@ -889,13 +846,13 @@ def test_relative_write_after_env_cleanup_lands_in_user_cwd(self, tmp_path, monk
               )
       
               task_id = "default"
      -        ft._last_known_cwd.pop(task_id, None)
      +        tt.clear_session_cwd(task_id)
       
      -        # 1) Env alive; agent has cd'd into the project. A relative write
      -        #    while alive mirrors the live cwd into the durable registry.
      +        # 1) Env alive; agent has cd'd into the project (the completed command
      +        #    recorded the session cwd — simulate that write here).
               fo = ft._get_file_ops(task_id)
               fo.env.cwd = str(project)
      -        fo.env.cwd_owner = "default"
      +        tt.record_session_cwd(task_id, str(project))
               ft.write_file_tool("alive.txt", "1\n", task_id)
               assert (project / "alive.txt").exists()
       
      @@ -913,4 +870,4 @@ def test_relative_write_after_env_cleanup_lands_in_user_cwd(self, tmp_path, monk
               assert not (config_default / "report.txt").exists(), \
                   "file silently misplaced into config default (the #26211 bug)"
       
      -        ft._last_known_cwd.pop(task_id, None)
      +        tt.clear_session_cwd(task_id)
    • tests/tools/test_file_tools_cwd_resolution.py
      @@ -36,8 +36,8 @@ def _isolated_cwd(tmp_path, monkeypatch):
           # Process cwd = decoy, analogous to "main repo" while the terminal is in
           # the worktree.
           monkeypatch.chdir(decoy)
      -    # No live-terminal-cwd tracking recorded yet (fresh-session condition).
      -    monkeypatch.setattr(ft, "_get_live_tracking_cwd", lambda task_id="default": None)
      +    # No session cwd recorded yet (fresh-session condition).
      +    monkeypatch.setattr(terminal_tool, "_session_cwd", {})
           return workspace, decoy
       
       
      @@ -72,7 +72,7 @@ def test_live_tracking_cwd_wins_over_relative_terminal_cwd(_isolated_cwd, monkey
           """
           workspace, decoy = _isolated_cwd
           monkeypatch.setenv("TERMINAL_CWD", ".")
      -    monkeypatch.setattr(ft, "_get_live_tracking_cwd", lambda task_id="default": str(workspace))
      +    terminal_tool.record_session_cwd("default", str(workspace))
       
           resolved = ft._resolve_path_for_task("target.py", task_id="default")
       
      @@ -137,7 +137,7 @@ def test_container_relative_path_keeps_container_cwd_symlink(tmp_path, monkeypat
           container_mount.symlink_to(host_project, target_is_directory=True)
           monkeypatch.setattr(terminal_tool, "_get_env_config", lambda: {"env_type": "docker"})
           monkeypatch.setattr(terminal_tool, "_active_environments", {})
      -    monkeypatch.setattr(ft, "_get_live_tracking_cwd", lambda task_id="default": str(container_mount))
      +    terminal_tool.record_session_cwd("default", str(container_mount))
       
           resolved = ft._resolve_path_for_task("oilsands-sim/README.md", task_id="default")
       
      @@ -185,9 +185,9 @@ def test_warning_fires_when_relative_path_escapes_workspace(_isolated_cwd, monke
           """Relative path resolving outside the live workspace must warn."""
           workspace, decoy = _isolated_cwd
           # Live cwd = workspace, but the relative path resolves to decoy (process cwd)
      -    # because TERMINAL_CWD is the poison '.'.  Simulate by pointing live tracking
      -    # at workspace while the resolved path is under decoy.
      -    monkeypatch.setattr(ft, "_get_live_tracking_cwd", lambda task_id="default": str(workspace))
      +    # because TERMINAL_CWD is the poison '.'.  Simulate by recording workspace
      +    # as the session cwd while the resolved path is under decoy.
      +    terminal_tool.record_session_cwd("default", str(workspace))
           resolved_in_decoy = decoy / "target.py"
       
           warn = ft._path_resolution_warning("target.py", resolved_in_decoy, task_id="default")
      @@ -200,7 +200,7 @@ def test_warning_fires_when_relative_path_escapes_workspace(_isolated_cwd, monke
       
       def test_no_warning_when_relative_path_inside_workspace(_isolated_cwd, monkeypatch):
           workspace, decoy = _isolated_cwd
      -    monkeypatch.setattr(ft, "_get_live_tracking_cwd", lambda task_id="default": str(workspace))
      +    terminal_tool.record_session_cwd("default", str(workspace))
           resolved_in_workspace = workspace / "target.py"
       
           warn = ft._path_resolution_warning("target.py", resolved_in_workspace, task_id="default")
      @@ -210,7 +210,7 @@ def test_no_warning_when_relative_path_inside_workspace(_isolated_cwd, monkeypat
       
       def test_no_warning_for_absolute_input(_isolated_cwd, monkeypatch):
           workspace, decoy = _isolated_cwd
      -    monkeypatch.setattr(ft, "_get_live_tracking_cwd", lambda task_id="default": str(workspace))
      +    terminal_tool.record_session_cwd("default", str(workspace))
       
           warn = ft._path_resolution_warning(str(decoy / "target.py"), decoy / "target.py", task_id="default")
       
      @@ -219,7 +219,7 @@ def test_no_warning_for_absolute_input(_isolated_cwd, monkeypatch):
       
       def test_no_warning_when_no_live_cwd(_isolated_cwd, monkeypatch):
           workspace, decoy = _isolated_cwd
      -    monkeypatch.setattr(ft, "_get_live_tracking_cwd", lambda task_id="default": None)
      +    monkeypatch.setattr(terminal_tool, "_session_cwd", {})
           monkeypatch.delenv("TERMINAL_CWD", raising=False)
       
           warn = ft._path_resolution_warning("target.py", decoy / "target.py", task_id="default")
      @@ -244,7 +244,7 @@ def test_sentinel_terminal_cwd_is_treated_as_unset(_isolated_cwd, monkeypatch, s
           never resolved as a literal relative directory.
           """
           workspace, decoy = _isolated_cwd
      -    monkeypatch.setattr(ft, "_get_live_tracking_cwd", lambda task_id="default": None)
      +    monkeypatch.setattr(terminal_tool, "_session_cwd", {})
           monkeypatch.setenv("TERMINAL_CWD", sentinel)
       
           assert ft._configured_terminal_cwd() is None
      @@ -261,7 +261,7 @@ def test_relative_nonsentinel_terminal_cwd_rejected(_isolated_cwd, monkeypatch):
           be joined onto it as a literal subdir.
           """
           workspace, decoy = _isolated_cwd
      -    monkeypatch.setattr(ft, "_get_live_tracking_cwd", lambda task_id="default": None)
      +    monkeypatch.setattr(terminal_tool, "_session_cwd", {})
           monkeypatch.setenv("TERMINAL_CWD", "some/rel/path")
       
           assert ft._configured_terminal_cwd() is None
      @@ -277,7 +277,7 @@ def test_absolute_terminal_cwd_anchors_with_empty_registry(_isolated_cwd, monkey
           worktree — not the process cwd (main repo).
           """
           workspace, decoy = _isolated_cwd
      -    monkeypatch.setattr(ft, "_get_live_tracking_cwd", lambda task_id="default": None)
      +    monkeypatch.setattr(terminal_tool, "_session_cwd", {})
           monkeypatch.setenv("TERMINAL_CWD", str(workspace))
       
           resolved = ft._resolve_path_for_task("target.py", task_id="default")
      @@ -295,7 +295,7 @@ def test_registered_task_cwd_override_anchors_before_terminal_env_exists(_isolat
           """
           workspace, decoy = _isolated_cwd
           task_id = "desktop-session-cwd"
      -    monkeypatch.setattr(ft, "_get_live_tracking_cwd", lambda task_id="default": None)
      +    monkeypatch.setattr(terminal_tool, "_session_cwd", {})
           monkeypatch.delenv("TERMINAL_CWD", raising=False)
           monkeypatch.setattr(terminal_tool, "_task_env_overrides", {})
       
      @@ -317,7 +317,7 @@ def test_warning_fires_from_terminal_cwd_when_registry_empty(_isolated_cwd, monk
           worktree is flagged on the very first write.
           """
           workspace, decoy = _isolated_cwd
      -    monkeypatch.setattr(ft, "_get_live_tracking_cwd", lambda task_id="default": None)
      +    monkeypatch.setattr(terminal_tool, "_session_cwd", {})
           monkeypatch.setenv("TERMINAL_CWD", str(workspace))
       
           # Relative path that escapes the worktree into the decoy/main checkout.
      @@ -336,8 +336,8 @@ def test_live_cwd_still_wins_over_absolute_terminal_cwd(_isolated_cwd, monkeypat
           workspace, decoy = _isolated_cwd
           other = decoy.parent / "other"
           other.mkdir()
      -    # Live cwd = workspace; TERMINAL_CWD points elsewhere — live must win.
      -    monkeypatch.setattr(ft, "_get_live_tracking_cwd", lambda task_id="default": str(workspace))
      +    # Recorded session cwd = workspace; TERMINAL_CWD points elsewhere — record wins.
      +    terminal_tool.record_session_cwd("default", str(workspace))
           monkeypatch.setenv("TERMINAL_CWD", str(other))
       
           resolved = ft._resolve_path_for_task("target.py", task_id="default")
      @@ -351,7 +351,7 @@ def test_live_cwd_still_wins_over_absolute_terminal_cwd(_isolated_cwd, monkeypat
       def test_write_file_reports_resolved_absolute_path(_isolated_cwd, monkeypatch):
           """write_file_tool must put the absolute on-disk path in files_modified."""
           workspace, decoy = _isolated_cwd
      -    monkeypatch.setattr(ft, "_get_live_tracking_cwd", lambda task_id="default": str(workspace))
      +    terminal_tool.record_session_cwd("t1", str(workspace))
       
           import json
           out = json.loads(ft.write_file_tool("newfile.txt", "hello\n", task_id="t1"))
      @@ -365,7 +365,7 @@ def test_write_file_reports_resolved_absolute_path(_isolated_cwd, monkeypatch):
       def test_patch_reports_resolved_absolute_path(_isolated_cwd, monkeypatch):
           """patch_tool (replace mode) must put the absolute on-disk path in files_modified."""
           workspace, decoy = _isolated_cwd
      -    monkeypatch.setattr(ft, "_get_live_tracking_cwd", lambda task_id="default": str(workspace))
      +    terminal_tool.record_session_cwd("t1", str(workspace))
       
           import json
           out = json.loads(ft.patch_tool(
      @@ -383,24 +383,16 @@ def test_patch_reports_resolved_absolute_path(_isolated_cwd, monkeypatch):
           assert (decoy / "target.py").read_text() == "DECOY_ORIGINAL\n"
       
       
      -# ── Fix D: shared terminal env must not leak its cwd across worktree sessions ─
      -# (June 2026: two desktop sessions, each on its own worktree, share the single
      -# "default" terminal environment. Its `cwd` tracks whichever session ran the
      -# last command, so a file edit from the OTHER session resolved against that
      -# foreign cwd and silently landed in the wrong worktree. terminal_tool now
      -# stamps env.cwd_owner with the driving session; file tools trust the shared
      -# env's live cwd only when the resolving session owns it.)
      -
      -
      -class _FakeOwnedEnv:
      -    def __init__(self, cwd: str, cwd_owner: str):
      -        self.cwd = cwd
      -        self.cwd_owner = cwd_owner
      +# ── Cross-session isolation: one session's cwd never leaks into another ──────
      +# (June 2026 bug class: two desktop sessions, each on its own worktree, shared
      +# the single "default" terminal environment and could inherit each other's cwd.
      +# The per-session record store solves this structurally: each session's cd
      +# state lives in its own record, keyed by the raw session id.)
       
       
       @pytest.fixture
       def _two_worktree_sessions(tmp_path, monkeypatch):
      -    """Two worktree sessions sharing one terminal env owned by session B."""
      +    """Two worktree sessions: B has cd'd (record), both registered overrides."""
           wt_a = tmp_path / "wt_a"
           wt_b = tmp_path / "wt_b"
           main = tmp_path / "main"
      @@ -410,78 +402,60 @@ def _two_worktree_sessions(tmp_path, monkeypatch):
           monkeypatch.chdir(main)
           monkeypatch.delenv("TERMINAL_CWD", raising=False)
           monkeypatch.setattr(terminal_tool, "_task_env_overrides", {})
      +    monkeypatch.setattr(terminal_tool, "_session_cwd", {})
           monkeypatch.setattr(ft, "_file_ops_cache", {})
      -    # Both sessions register their worktree cwd (TUI/desktop registration path).
      +    # Both sessions register their worktree cwd (TUI/desktop registration path;
      +    # registration seeds each session's record).
           terminal_tool.register_task_env_overrides("sess-a", {"cwd": str(wt_a)})
           terminal_tool.register_task_env_overrides("sess-b", {"cwd": str(wt_b)})
      -    # The shared "default" env: session B ran the last command, so its live cwd
      -    # is wt_b and B owns it.
      +    # Session B ran the last command; the shared env's live cwd is wt_b but
      +    # only B's RECORD carries it.
           monkeypatch.setattr(
               terminal_tool,
               "_active_environments",
      -        {"default": _FakeOwnedEnv(str(wt_b), "sess-b")},
      +        {"default": _FakeEnv(str(wt_b))},
           )
           return wt_a, wt_b, main
       
       
      -def test_live_cwd_ignored_for_non_owning_session(_two_worktree_sessions):
      -    wt_a, wt_b, _main = _two_worktree_sessions
      -    # Owner sees the live cwd; the other session must NOT inherit it.
      -    assert ft._get_live_tracking_cwd("sess-b") == str(wt_b)
      -    assert ft._get_live_tracking_cwd("sess-a") is None
      +class _FakeEnv:
      +    def __init__(self, cwd: str):
      +        self.cwd = cwd
       
       
       def test_resolution_routes_to_resolving_sessions_worktree(_two_worktree_sessions):
           """The wrong-worktree fix: A resolves into wt_a, not the shared env's wt_b."""
           wt_a, wt_b, _main = _two_worktree_sessions
      -    # Session A does not own the shared env → falls back to its own registered
      -    # worktree cwd instead of B's live cwd.
           resolved_a = ft._resolve_path_for_task("target.py", task_id="sess-a")
           assert resolved_a == (wt_a / "target.py")
           assert not str(resolved_a).startswith(str(wt_b))
       
       
      -def test_owning_session_still_resolves_against_live_cwd(_two_worktree_sessions):
      -    """No regression: the owner keeps resolving against the live cwd."""
      +def test_session_with_cd_record_resolves_against_it(_two_worktree_sessions):
      +    """B's record (its own cd state) is authoritative for B."""
           wt_a, wt_b, _main = _two_worktree_sessions
           resolved_b = ft._resolve_path_for_task("target.py", task_id="sess-b")
           assert resolved_b == (wt_b / "target.py")
           assert not str(resolved_b).startswith(str(wt_a))
       
       
      -def test_unknown_owner_keeps_prior_single_session_behavior(tmp_path, monkeypatch):
      -    """An env with no owner (CLI / legacy) still yields its live cwd."""
      -    ws = tmp_path / "ws"
      -    ws.mkdir()
      -    monkeypatch.setattr(ft, "_file_ops_cache", {})
      -    monkeypatch.setattr(
      -        terminal_tool,
      -        "_active_environments",
      -        {"default": _FakeOwnedEnv(str(ws), "")},
      -    )
      -    assert ft._get_live_tracking_cwd("default") == str(ws)
      -    assert ft._get_live_tracking_cwd("any-session") == str(ws)
      +def test_sessions_cd_updates_only_its_own_resolution(_two_worktree_sessions, tmp_path):
      +    """B cd's elsewhere → B's resolution follows, A's is untouched."""
      +    wt_a, wt_b, _main = _two_worktree_sessions
      +    elsewhere = tmp_path / "elsewhere"
      +    elsewhere.mkdir()
      +    terminal_tool.record_session_cwd("sess-b", str(elsewhere))
      +
      +    assert ft._resolve_path_for_task("f.py", task_id="sess-b") == (elsewhere / "f.py")
      +    assert ft._resolve_path_for_task("f.py", task_id="sess-a") == (wt_a / "f.py")
       
       
      -def test_preserved_cwd_does_not_override_non_owning_sessions_worktree(
      +def test_unregistered_session_never_inherits_another_sessions_record(
           _two_worktree_sessions, monkeypatch
       ):
      -    """#26211 belt-and-suspenders must not break worktree isolation.
      -
      -    The owner (session B) doing an owned live read mirrors wt_b into the shared
      -    _last_known_cwd['default'] registry. Session A — which does NOT own the env
      -    but HAS its own registered worktree (wt_a) — must still resolve into wt_a,
      -    not inherit B's preserved cwd through the shared-container key. The
      -    session-specific registered override must beat the durable shared anchor.
      -    """
      -    wt_a, wt_b, _main = _two_worktree_sessions
      -    monkeypatch.setattr(ft, "_last_known_cwd", {})
      -
      -    # Owner B resolves first — this mirrors wt_b into _last_known_cwd['default'].
      -    assert ft._resolve_path_for_task("target.py", task_id="sess-b") == (wt_b / "target.py")
      -    assert ft._last_known_cwd.get("default") == str(wt_b)
      -
      -    # A still routes to its own registered worktree despite the shared anchor.
      -    resolved_a = ft._resolve_path_for_task("target.py", task_id="sess-a")
      -    assert resolved_a == (wt_a / "target.py")
      -    assert not str(resolved_a).startswith(str(wt_b))
      +    """Session C: no record, no override. Must NOT inherit A's or B's cwd."""
      +    wt_a, wt_b, main = _two_worktree_sessions
      +    resolved = ft._resolve_path_for_task("target.py", task_id="sess-c")
      +    assert not str(resolved).startswith(str(wt_a))
      +    assert not str(resolved).startswith(str(wt_b))
      +    assert resolved == (main / "target.py").resolve()
    • tests/tools/test_file_tools_tilde_profile.py
      @@ -21,6 +21,7 @@
       import pytest
       
       import tools.file_tools as ft
      +import tools.terminal_tool as terminal_tool
       
       
       # ---------------------------------------------------------------------------
      @@ -82,7 +83,7 @@ def test_relative_tilde_resolves_to_profile_home(self, tmp_path, monkeypatch):
               process_home.mkdir()
       
               monkeypatch.setenv("HOME", str(process_home))
      -        monkeypatch.setattr(ft, "_get_live_tracking_cwd", lambda task_id="default": None)
      +        monkeypatch.setattr(terminal_tool, "_session_cwd", {})
       
               with patch("hermes_constants.get_subprocess_home", return_value=str(profile_home)):
                   resolved = ft._resolve_path_for_task("~/test_file.txt", task_id="test")
      @@ -98,7 +99,7 @@ def test_absolute_tilde_in_workspace_root(self, tmp_path, monkeypatch):
               process_home.mkdir()
       
               monkeypatch.setenv("HOME", str(process_home))
      -        monkeypatch.setattr(ft, "_get_live_tracking_cwd", lambda task_id="default": None)
      +        monkeypatch.setattr(terminal_tool, "_session_cwd", {})
       
               with patch("hermes_constants.get_subprocess_home", return_value=str(profile_home)):
                   # _resolve_base_dir uses the workspace root from config; if it contains ~,
    • tests/tools/test_resolve_path.py
      @@ -52,33 +52,23 @@ def test_result_is_resolved(self, monkeypatch, tmp_path):
               assert ".." not in str(result)
               assert result == (tmp_path / "b" / "file.txt")
       
      -    def test_relative_path_prefers_live_file_ops_cwd(self, monkeypatch, tmp_path):
      -        """Live env.cwd must win after the terminal session changes directory."""
      +    def test_relative_path_prefers_recorded_session_cwd(self, monkeypatch, tmp_path):
      +        """The session's recorded cwd must win after the terminal changes directory."""
               start_dir = tmp_path / "start"
               live_dir = tmp_path / "worktree"
               start_dir.mkdir()
               live_dir.mkdir()
               monkeypatch.setenv("TERMINAL_CWD", str(start_dir))
       
      -        from tools import file_tools
      +        from tools import file_tools, terminal_tool
       
               task_id = "live-cwd"
      -        fake_ops = SimpleNamespace(
      -            env=SimpleNamespace(cwd=str(live_dir)),
      -            cwd=str(start_dir),
      -        )
      -
      -        with file_tools._file_ops_lock:
      -            previous = file_tools._file_ops_cache.get(task_id)
      -            file_tools._file_ops_cache[task_id] = fake_ops
      +        # The session's completed `cd` recorded the new directory.
      +        terminal_tool.record_session_cwd(task_id, str(live_dir))
       
               try:
                   result = file_tools._resolve_path("nested/file.txt", task_id=task_id)
               finally:
      -            with file_tools._file_ops_lock:
      -                if previous is None:
      -                    file_tools._file_ops_cache.pop(task_id, None)
      -                else:
      -                    file_tools._file_ops_cache[task_id] = previous
      +            terminal_tool.clear_session_cwd(task_id)
       
               assert result == live_dir / "nested" / "file.txt"
    • tests/tools/test_session_cwd_store.py
      @@ -0,0 +1,244 @@
      +"""Session-cwd record store (cwd rearchitecture, step 1: dual-write).
      +
      +The store is the future single source of truth for per-session working
      +directories. Step 1 only guarantees the WRITE side: every path that learns a
      +session's live cwd must record it under the raw session key. Readers still
      +use the legacy env.cwd ladder; these tests pin the invariants the later
      +read-side flip will rely on.
      +"""
      +
      +import pytest
      +
      +import tools.terminal_tool as tt
      +
      +
      [redacted:email](autouse=True)
      +def _clean_store(monkeypatch):
      +    monkeypatch.setattr(tt, "_session_cwd", {})
      +    monkeypatch.setattr(tt, "_task_env_overrides", {})
      +
      +
      +class TestRecordSemantics:
      +    def test_records_are_keyed_by_raw_session_key(self):
      +        tt.record_session_cwd("sess-a", "/wt/a")
      +        tt.record_session_cwd("sess-b", "/wt/b")
      +        # No cross-talk: each session reads back exactly its own record.
      +        assert tt.get_session_cwd("sess-a") == "/wt/a"
      +        assert tt.get_session_cwd("sess-b") == "/wt/b"
      +        assert tt.get_session_cwd("sess-c") is None
      +
      +    def test_none_and_empty_keys_collapse_to_default(self):
      +        tt.record_session_cwd(None, "/somewhere")
      +        assert tt.get_session_cwd(None) == "/somewhere"
      +        assert tt.get_session_cwd("") == "/somewhere"
      +        assert tt.get_session_cwd("default") == "/somewhere"
      +
      +    def test_invalid_cwd_values_are_ignored(self):
      +        tt.record_session_cwd("sess-a", None)
      +        tt.record_session_cwd("sess-a", "")
      +        tt.record_session_cwd("sess-a", "   ")
      +        tt.record_session_cwd("sess-a", 123)  # type: ignore[arg-type]
      +        assert tt.get_session_cwd("sess-a") is None
      +
      +    def test_clear_drops_only_the_named_session(self):
      +        tt.record_session_cwd("sess-a", "/wt/a")
      +        tt.record_session_cwd("sess-b", "/wt/b")
      +        tt.clear_session_cwd("sess-a")
      +        assert tt.get_session_cwd("sess-a") is None
      +        assert tt.get_session_cwd("sess-b") == "/wt/b"
      +
      +
      +class TestDualWriteSites:
      +    def test_register_cwd_override_seeds_the_session_record(self):
      +        """A registered workspace cwd IS the session's cwd until a `cd`."""
      +        tt.register_task_env_overrides("desktop-sess", {"cwd": "/wt/desktop"})
      +        assert tt.get_session_cwd("desktop-sess") == "/wt/desktop"
      +
      +    def test_register_without_cwd_does_not_touch_the_record(self):
      +        tt.register_task_env_overrides("rl-42", {"docker_image": "x:y"})
      +        assert tt.get_session_cwd("rl-42") is None
      +
      +    def test_clear_task_env_overrides_drops_the_record(self):
      +        tt.register_task_env_overrides("desktop-sess", {"cwd": "/wt/desktop"})
      +        tt.clear_task_env_overrides("desktop-sess")
      +        assert tt.get_session_cwd("desktop-sess") is None
      +
      +    def test_reregistration_updates_the_record(self):
      +        """ACP session/load switching project roots mid-session."""
      +        tt.register_task_env_overrides("acp-sess", {"cwd": "/proj/one"})
      +        tt.register_task_env_overrides("acp-sess", {"cwd": "/proj/two"})
      +        assert tt.get_session_cwd("acp-sess") == "/proj/two"
      +
      +
      +class TestPostCommandDualWrite:
      +    """The env's post-command cwd tracking must mirror into the session record."""
      +
      +    def _run(self, monkeypatch, task_id, env):
      +        import json
      +        monkeypatch.setattr(tt, "_active_environments", {task_id: env})
      +        monkeypatch.setattr(tt, "_last_activity", {})
      +        monkeypatch.setattr(
      +            tt, "_get_env_config",
      +            lambda: {"env_type": "local", "cwd": "/default", "timeout": 60,
      +                     "lifetime_seconds": 3600},
      +        )
      +        monkeypatch.setattr(
      +            tt, "_check_all_guards",
      +            lambda command, env_type, **kwargs: {"approved": True},
      +        )
      +        return json.loads(tt.terminal_tool(command="cd /new/dir", task_id=task_id))
      +
      +    def test_cd_result_is_recorded_under_the_session_key(self, monkeypatch):
      +        class FakeEnv:
      +            env = {}
      +            cwd = "/start"
      +            def execute(self, command, **kwargs):
      +                # Simulate the env's own post-command tracking (marker parse).
      +                self.cwd = "/new/dir"
      +                return {"output": "", "returncode": 0}
      +
      +        result = self._run(monkeypatch, "sess-a", FakeEnv())
      +        assert result["exit_code"] == 0
      +        assert tt.get_session_cwd("sess-a") == "/new/dir"
      +        # And ONLY that session's record was touched.
      +        assert tt.get_session_cwd("sess-b") is None
      +
      +    def test_envs_without_cwd_tracking_record_nothing(self, monkeypatch):
      +        class FakeEnv:
      +            env = {}
      +            def execute(self, command, **kwargs):
      +                return {"output": "", "returncode": 0}
      +
      +        result = self._run(monkeypatch, "sess-a", FakeEnv())
      +        assert result["exit_code"] == 0
      +        assert tt.get_session_cwd("sess-a") is None
      +
      +
      +class TestFileToolsReadTheRecord:
      +    """Step 2: file-tool path resolution prefers the session's own record."""
      +
      +    def test_two_sessions_resolve_into_their_own_recorded_cwds(self, tmp_path, monkeypatch):
      +        import tools.file_tools as ft
      +
      +        wt_a = tmp_path / "wt_a"
      +        wt_b = tmp_path / "wt_b"
      +        for d in (wt_a, wt_b):
      +            d.mkdir()
      +        monkeypatch.chdir(tmp_path)
      +        monkeypatch.delenv("TERMINAL_CWD", raising=False)
      +        monkeypatch.setattr(ft, "_file_ops_cache", {})
      +        monkeypatch.setattr(tt, "_active_environments", {})
      +
      +        # Each session ran commands that recorded its own cwd. No env alive,
      +        # no registered overrides — just the records.
      +        tt.record_session_cwd("sess-a", str(wt_a))
      +        tt.record_session_cwd("sess-b", str(wt_b))
      +
      +        assert ft._resolve_path_for_task("f.py", task_id="sess-a") == (wt_a / "f.py")
      +        assert ft._resolve_path_for_task("f.py", task_id="sess-b") == (wt_b / "f.py")
      +
      +    def test_record_beats_foreign_env_cwd_without_ownership_metadata(self, tmp_path, monkeypatch):
      +        """The leak-A scenario, solved structurally: the shared env's cwd is
      +        never consulted for path resolution — only the session's own record."""
      +        import tools.file_tools as ft
      +
      +        wt_a = tmp_path / "wt_a"
      +        wt_b = tmp_path / "wt_b"
      +        for d in (wt_a, wt_b):
      +            d.mkdir()
      +        monkeypatch.chdir(tmp_path)
      +        monkeypatch.delenv("TERMINAL_CWD", raising=False)
      +        monkeypatch.setattr(ft, "_file_ops_cache", {})
      +
      +        class _Env:
      +            cwd = str(wt_b)  # another session's leftover cd on the shared env
      +
      +        monkeypatch.setattr(tt, "_active_environments", {"default": _Env()})
      +        tt.record_session_cwd("sess-a", str(wt_a))
      +
      +        resolved = ft._resolve_path_for_task("f.py", task_id="sess-a")
      +        assert resolved == (wt_a / "f.py")
      +        assert not str(resolved).startswith(str(wt_b))
      +
      +
      +class TestDelegateSeedsChildRecord:
      +    def test_child_record_seeded_from_parent_then_isolated(self):
      +        tt.record_session_cwd("parent-task", "/parent/worktree")
      +        # what delegate_tool does at spawn:
      +        tt.record_session_cwd("child-1", tt.get_session_cwd("parent-task"))
      +
      +        assert tt.get_session_cwd("child-1") == "/parent/worktree"
      +        # child cds somewhere; parent record must be untouched.
      +        tt.record_session_cwd("child-1", "/child/scratch")
      +        assert tt.get_session_cwd("parent-task") == "/parent/worktree"
      +        assert tt.get_session_cwd("child-1") == "/child/scratch"
      +
      +
      +class TestCommandCwdReadsTheRecord:
      +    """_resolve_command_cwd: workdir > session record > default. Nothing else."""
      +
      +    def test_record_beats_default(self):
      +        tt.record_session_cwd("sess-a", "/my/worktree")
      +        resolved = tt._resolve_command_cwd(
      +            workdir=None,
      +            default_cwd="/config/default",
      +            session_key="sess-a",
      +        )
      +        assert resolved == "/my/worktree"
      +
      +    def test_workdir_still_beats_the_record(self):
      +        tt.record_session_cwd("sess-a", "/my/worktree")
      +        resolved = tt._resolve_command_cwd(
      +            workdir="/explicit/place",
      +            default_cwd="/config/default",
      +            session_key="sess-a",
      +        )
      +        assert resolved == "/explicit/place"
      +
      +    def test_no_record_falls_back_to_default(self):
      +        resolved = tt._resolve_command_cwd(
      +            workdir=None,
      +            default_cwd="/config/default",
      +            session_key="sess-a",
      +        )
      +        assert resolved == "/config/default"
      +
      +    def test_other_sessions_record_is_not_consulted(self):
      +        tt.record_session_cwd("sess-b", "/other/worktree")
      +        resolved = tt._resolve_command_cwd(
      +            workdir=None,
      +            default_cwd="/config/default",
      +            session_key="sess-a",
      +        )
      +        assert resolved == "/config/default"
      +
      +    def test_cd_then_next_command_runs_in_the_new_dir(self, monkeypatch):
      +        """E2E through terminal_tool: the record round-trips cd state."""
      +        import json
      +
      +        class FakeEnv:
      +            env = {}
      +            cwd = "/start"
      +            def execute(self, command, **kwargs):
      +                self.last_cwd_arg = kwargs.get("cwd")
      +                if command.startswith("cd "):
      +                    self.cwd = command[3:]
      +                return {"output": "", "returncode": 0}
      +
      +        fake = FakeEnv()
      +        monkeypatch.setattr(tt, "_active_environments", {"sess-a": fake})
      +        monkeypatch.setattr(tt, "_last_activity", {})
      +        monkeypatch.setattr(
      +            tt, "_get_env_config",
      +            lambda: {"env_type": "local", "cwd": "/default", "timeout": 60,
      +                     "lifetime_seconds": 3600},
      +        )
      +        monkeypatch.setattr(
      +            tt, "_check_all_guards",
      +            lambda command, env_type, **kwargs: {"approved": True},
      +        )
      +
      +        json.loads(tt.terminal_tool(command="cd /project", task_id="sess-a"))
      +        assert tt.get_session_cwd("sess-a") == "/project"
      +        json.loads(tt.terminal_tool(command="pwd", task_id="sess-a"))
      +        assert fake.last_cwd_arg == "/project"
    • tests/tools/test_terminal_task_cwd.py
      @@ -76,8 +76,8 @@ def execute(self, command, **kwargs):
           assert calls == [{"timeout": 60, "cwd": "/explicit/workdir", "bounded_capture": True}]
       
       
      -def test_foreground_command_prefers_live_env_cwd_over_init_time_cwd(monkeypatch):
      -    """A prior `cd` updates env.cwd; terminal_tool must honor that live cwd."""
      +def test_foreground_command_prefers_recorded_session_cwd_over_init_time_cwd(monkeypatch):
      +    """A prior `cd` records the session cwd; terminal_tool must honor it."""
           calls = []
       
           class FakeEnv:
      @@ -91,6 +91,7 @@ def execute(self, command, **kwargs):
           task_id = "session-live-cwd"
           monkeypatch.setattr(terminal_tool, "_active_environments", {task_id: FakeEnv()})
           monkeypatch.setattr(terminal_tool, "_last_activity", {})
      +    monkeypatch.setattr(terminal_tool, "_session_cwd", {})
           monkeypatch.setattr(terminal_tool, "_task_env_overrides", {task_id: {"cwd": "/workspace/init"}})
           monkeypatch.setattr(terminal_tool, "_get_env_config", lambda: _minimal_terminal_config(cwd="/workspace/init"))
           monkeypatch.setattr(terminal_tool, "_start_cleanup_thread", lambda: None)
      @@ -100,6 +101,8 @@ def execute(self, command, **kwargs):
               "_check_all_guards",
               lambda command, env_type, **kwargs: {"approved": True},
           )
      +    # The prior command's completed `cd` recorded the session cwd.
      +    terminal_tool.record_session_cwd(task_id, "/workspace/live")
       
           result = json.loads(terminal_tool.terminal_tool(command="pwd", task_id=task_id))
       
      @@ -107,8 +110,8 @@ def execute(self, command, **kwargs):
           assert calls == [("pwd", {"timeout": 60, "cwd": "/workspace/live", "bounded_capture": True})]
       
       
      -def test_background_command_prefers_live_env_cwd_over_init_time_cwd(monkeypatch):
      -    """Background process launches must also use the live session cwd."""
      +def test_background_command_prefers_recorded_session_cwd_over_init_time_cwd(monkeypatch):
      +    """Background process launches must also use the recorded session cwd."""
       
           class FakeEnv:
               env = {}
      @@ -129,6 +132,7 @@ def spawn_local(self, **kwargs):
           task_id = "session-live-cwd-bg"
           monkeypatch.setattr(terminal_tool, "_active_environments", {task_id: FakeEnv()})
           monkeypatch.setattr(terminal_tool, "_last_activity", {})
      +    monkeypatch.setattr(terminal_tool, "_session_cwd", {})
           monkeypatch.setattr(terminal_tool, "_task_env_overrides", {task_id: {"cwd": "/workspace/init"}})
           monkeypatch.setattr(terminal_tool, "_get_env_config", lambda: _minimal_terminal_config(cwd="/workspace/init"))
           monkeypatch.setattr(terminal_tool, "_start_cleanup_thread", lambda: None)
      @@ -139,6 +143,7 @@ def spawn_local(self, **kwargs):
               lambda command, env_type, **kwargs: {"approved": True},
           )
           monkeypatch.setattr(process_registry_mod, "process_registry", registry)
      +    terminal_tool.record_session_cwd(task_id, "/workspace/live")
       
           result = json.loads(
               terminal_tool.terminal_tool(
      @@ -162,15 +167,13 @@ def spawn_local(self, **kwargs):
           }]
       
       
      -def test_registering_cwd_override_updates_live_env_cwd(monkeypatch):
      +def test_registering_cwd_override_updates_session_record(monkeypatch):
           """An ACP ``update_cwd`` (re-)registered mid-session must win over a
      -    previously ``cd``-ed live ``env.cwd``.
      +    previously ``cd``-ed session cwd.
       
      -    Preferring live ``env.cwd`` (so session-local ``cd`` survives) means a
      -    freshly registered ``cwd`` override would otherwise sit *below* the
      -    already-set ``env.cwd`` and be silently ignored. ``register_task_env_overrides``
      -    syncs the new cwd onto the live cached env so an explicit ACP project-root
      -    change takes effect, as the editor client expects.
      +    Registration writes the session record directly, so an explicit ACP
      +    project-root change takes effect on the next command, as the editor
      +    client expects.
           """
       
           class FakeEnv:
      @@ -181,15 +184,18 @@ class FakeEnv:
           fake_env = FakeEnv()
           monkeypatch.setattr(terminal_tool, "_active_environments", {task_id: fake_env})
           monkeypatch.setattr(terminal_tool, "_task_env_overrides", {})
      +    monkeypatch.setattr(terminal_tool, "_session_cwd", {})
      +    # The session had cd'd somewhere before the editor switched project roots.
      +    terminal_tool.record_session_cwd(task_id, "/workspace/old")
       
           terminal_tool.register_task_env_overrides(task_id, {"cwd": "/workspace/new"})
       
      -    # The live env now reflects the editor's new project root.
      +    # The live env mirror still updates (legacy env seeding) …
           assert fake_env.cwd == "/workspace/new"
      -
      -    # A subsequent command resolves to the new cwd (env.cwd precedence).
      +    # … and the session record — what commands actually resolve against — too.
      +    assert terminal_tool.get_session_cwd(task_id) == "/workspace/new"
           assert terminal_tool._resolve_command_cwd(
      -        workdir=None, env=fake_env, default_cwd="/workspace/config"
      +        workdir=None, default_cwd="/workspace/config", session_key=task_id
           ) == "/workspace/new"
       
       
      @@ -264,20 +270,14 @@ def execute(self, command, **kwargs):
           assert calls == [("pwd", {"timeout": 60, "cwd": "/home/user/src/hermes-agent", "bounded_capture": True})]
       
       
      -def test_same_session_env_cwd_is_trusted_after_first_claim(monkeypatch):
      -    """Once a session has claimed the env, subsequent commands trust env.cwd.
      -
      -    The prev_owner check only rejects env.cwd when a DIFFERENT session owned it
      -    before this call.  After the first command (which claims ownership),
      -    subsequent calls in the same session should trust the live env.cwd so that
      -    in-session `cd` state survives.
      -    """
      +def test_same_session_recorded_cwd_survives_across_commands(monkeypatch):
      +    """In-session `cd` state survives: the record written by one command is
      +    used by the next command in the same session."""
           calls = []
       
           class FakeEnv:
               env = {}
               cwd = "/workspace/deep"
      -        cwd_owner = "session-X"
       
               def execute(self, command, **kwargs):
                   calls.append((command, kwargs))
      @@ -288,6 +288,7 @@ def execute(self, command, **kwargs):
           monkeypatch.setattr(terminal_tool, "_active_environments", {"default": env})
           monkeypatch.setattr(terminal_tool, "_last_activity", {})
           monkeypatch.setattr(terminal_tool, "_task_env_overrides", {})
      +    monkeypatch.setattr(terminal_tool, "_session_cwd", {})
           monkeypatch.setattr(terminal_tool, "_get_env_config", lambda: _minimal_terminal_config(cwd="/workspace/config"))
           monkeypatch.setattr(terminal_tool, "_start_cleanup_thread", lambda: None)
           monkeypatch.setattr(terminal_tool, "_resolve_container_task_id", lambda value: "default")
      @@ -297,13 +298,17 @@ def execute(self, command, **kwargs):
               lambda command, env_type, **kwargs: {"approved": True},
           )
       
      -    # First call: env was owned by "session-X" (same session_key since
      -    # get_current_session_key falls back to task_id). prev_owner == current
      -    # session, so env.cwd is trusted.
      +    # First command runs in the config cwd (no record yet) and afterwards
      +    # mirrors the env's post-command cwd into the session record.
           result = json.loads(terminal_tool.terminal_tool(command="pwd", task_id=task_id))
      +    assert result["exit_code"] == 0
      +    assert calls[0] == ("pwd", {"timeout": 60, "cwd": "/workspace/config", "bounded_capture": True})
      +    assert terminal_tool.get_session_cwd(task_id) == "/workspace/deep"
       
      +    # Second command in the same session trusts the record.
      +    result = json.loads(terminal_tool.terminal_tool(command="pwd", task_id=task_id))
           assert result["exit_code"] == 0
      -    assert calls == [("pwd", {"timeout": 60, "cwd": "/workspace/deep", "bounded_capture": True})]
      +    assert calls[1] == ("pwd", {"timeout": 60, "cwd": "/workspace/deep", "bounded_capture": True})
       
       
       def test_safe_getcwd_returns_real_cwd(monkeypatch):
    • tools/delegate_tool.py
      @@ -1906,6 +1906,18 @@ def _heartbeat_loop():
       
               child_task_id = _subagent_id or f"subagent-{task_index}-{_uuid.uuid4().hex[:8]}"
               parent_task_id = getattr(parent_agent, "_current_task_id", None)
      +        # Seed the child's session-cwd record from the parent's (cwd rearch):
      +        # children share the parent's container, and today they inherit the
      +        # parent's live env.cwd implicitly. Seeding at spawn preserves that
      +        # starting directory while keeping the child's subsequent `cd`s
      +        # isolated in its own record (a child's cd no longer bleeds back into
      +        # the parent once readers flip to the record store).
      +        try:
      +            from tools.terminal_tool import get_session_cwd, record_session_cwd
      +
      +            record_session_cwd(child_task_id, get_session_cwd(parent_task_id))
      +        except Exception as e:
      +            logger.debug("Child cwd seed failed: %s", e)
               wall_start = time.time()
               parent_reads_snapshot = (
                   list(file_state.known_reads(parent_task_id)) if parent_task_id else []
    • tools/file_tools.py
      @@ -269,103 +269,37 @@ def _registered_task_cwd_override(task_id: str = "default") -> str | None:
           return _sentinel_free_abs_cwd(overrides.get("cwd"))
       
       
      -def _live_cwd_if_owned(env, task_id: str) -> str | None:
      -    """The env's live cwd, but only when THIS session owns it.
      -
      -    The terminal env is shared (collapsed to the ``"default"`` container), so its
      -    ``cwd`` tracks the LAST session that ran a command. With two worktree
      -    sessions open, trusting it blindly routes one session's edits into the other
      -    session's checkout (the wrong-worktree-patch bug). ``terminal_tool`` stamps
      -    ``env.cwd_owner`` with the session that last drove the env; return its cwd
      -    only when that owner matches the resolving session, else ``None`` so the
      -    caller falls through to this session's own registered cwd override. Unknown
      -    owner / ``default`` keys keep the prior behavior (single-session / CLI).
      -    """
      -    if env is None:
      -        return None
      -    live = getattr(env, "cwd", None)
      -    if not live:
      -        return None
      -    owner = str(getattr(env, "cwd_owner", "") or "")
      -    tid = str(task_id or "")
      -    if owner and tid and owner != "default" and tid != "default" and owner != tid:
      -        return None
      -    return live
      -
      -
      -def _get_live_tracking_cwd(task_id: str = "default") -> str | None:
      -    """Return the task's live terminal cwd for bookkeeping when available."""
      -    try:
      -        from tools.terminal_tool import _resolve_container_task_id
      -        container_key = _resolve_container_task_id(task_id)
      -    except Exception:
      -        container_key = task_id
      -
      -    with _file_ops_lock:
      -        cached = _file_ops_cache.get(container_key) or _file_ops_cache.get(task_id)
      -    if cached is not None:
      -        env = getattr(cached, "env", None)
      -        live_cwd = _live_cwd_if_owned(env, task_id)
      -        if live_cwd:
      -            _remember_last_known_cwd(container_key, live_cwd)
      -            return live_cwd
      -        # Legacy: a cache entry carrying its own cwd with no env to own it.
      -        if env is None and getattr(cached, "cwd", None):
      -            legacy_cwd = getattr(cached, "cwd", None)
      -            _remember_last_known_cwd(container_key, legacy_cwd)
      -            return legacy_cwd
      -
      -    try:
      -        from tools.terminal_tool import _active_environments, _env_lock
      -
      -        with _env_lock:
      -            env = _active_environments.get(container_key) or _active_environments.get(task_id)
      -        live_cwd = _live_cwd_if_owned(env, task_id)
      -        if live_cwd:
      -            _remember_last_known_cwd(container_key, live_cwd)
      -            return live_cwd
      -    except Exception:
      -        pass
      -
      -    return None
      -
      -
       def _authoritative_workspace_root(task_id: str = "default") -> str | None:
           """Best-effort absolute workspace root for divergence checks.
       
      -    Prefers the live terminal cwd (the directory the agent is actually working
      -    in). When no terminal command has run yet — so the live registry is empty —
      -    falls back to a registered task/session cwd override (TUI/Desktop/ACP
      -    sessions register a raw-keyed cwd before any tool runs), then to a
      -    sentinel-free absolute ``$TERMINAL_CWD``. This is what lets a worktree or
      -    Desktop session warn about (and resolve into) its workspace from the very
      -    first ``write_file``/``patch``, before any ``cd`` has populated the live cwd.
      +    Resolution:
      +
      +      1. The session's own cwd RECORD (``terminal_tool.get_session_cwd``) —
      +         written on every completed terminal command and seeded by workspace
      +         registration, keyed by the raw session id. Because the record is
      +         per-session, one session's ``cd`` can never leak into another
      +         session's resolution.
      +      2. A registered task/session cwd override (TUI/Desktop/ACP sessions
      +         register a raw-keyed cwd before any tool runs). Normally already
      +         mirrored into the record at registration; kept as a direct fallback
      +         so a cleared/never-written record still resolves the workspace.
      +      3. A sentinel-free absolute ``$TERMINAL_CWD`` (the worktree path set by
      +         ``cli.py``/``main.py`` for ``-w`` sessions).
       
           Returns ``None`` only when there is genuinely no reliable anchor, in which
           case callers fall back to the process cwd.
           """
      -    live = _get_live_tracking_cwd(task_id)
      -    if live:
      -        return live
      -    # A session-specific registered override (TUI/Desktop/ACP workspace cwd)
      -    # is more authoritative than the shared last-known anchor: it is keyed by
      -    # the raw session id, so when two worktree sessions share the single
      -    # "default" terminal env, a NON-owning session must resolve against its OWN
      -    # registered worktree — never the other session's leftover cwd. (Checked
      -    # before _last_known_cwd, which is keyed by the shared container id.)
      +    try:
      +        from tools.terminal_tool import get_session_cwd
      +
      +        recorded = get_session_cwd(task_id)
      +    except Exception:
      +        recorded = None
      +    if recorded:
      +        return recorded
           registered = _registered_task_cwd_override(task_id)
           if registered:
               return registered
      -    # When the terminal env was cleaned up mid-conversation, the live cwd is
      -    # gone but the directory the agent navigated to is still recorded in the
      -    # durable _last_known_cwd registry. Prefer it over the config/process
      -    # fallback so a relative-path write resolved BEFORE the env is rebuilt
      -    # still lands in the user's directory (root cause of #26211: write happens
      -    # via _resolve_path_for_task -> here, which runs before _get_file_ops
      -    # rebuilds the env). Keyed by the resolved container id, same as the save.
      -    preserved = _last_known_cwd_for(task_id)
      -    if preserved:
      -        return preserved
           return _configured_terminal_cwd()
       
       
      @@ -794,45 +728,6 @@ def _is_expected_write_exception(exc: Exception) -> bool:
       
       _file_ops_lock = threading.Lock()
       _file_ops_cache: dict = {}
      -# Per-task last-known CWD — preserved across env re-creation so
      -# relative-path file writes land in the right directory after the
      -# terminal environment is cleaned up and rebuilt (root cause of #26211).
      -_last_known_cwd: dict = {}
      -
      -
      -def _remember_last_known_cwd(task_id: str, cwd: str | None) -> None:
      -    """Mirror a live terminal cwd into the durable ``_last_known_cwd`` registry.
      -
      -    Belt-and-suspenders for #26211: the cleanup thread can pop BOTH
      -    ``_file_ops_cache`` and ``_active_environments`` before ``_get_file_ops``
      -    reaches its stale-cache detection branch, in which case the old cwd is
      -    never saved and the rebuilt env falls back to the config default — exactly
      -    the silent-misplacement bug. By recording the cwd on every successful live
      -    read (which happens on every relative-path file resolution while the env is
      -    alive), the durable anchor no longer depends on the cleanup-detection
      -    branch firing, so it survives recreation regardless of pop ordering.
      -    """
      -    if not cwd:
      -        return
      -    with _file_ops_lock:
      -        if _last_known_cwd.get(task_id) != cwd:
      -            _last_known_cwd[task_id] = cwd
      -
      -
      -def _last_known_cwd_for(task_id: str = "default") -> str | None:
      -    """Read the durable last-known cwd for *task_id*, container-key aware.
      -
      -    The registry is keyed by the resolved container id (the same key used by
      -    the save sites in ``_get_file_ops`` / ``_get_live_tracking_cwd``), so look
      -    up the resolved key first and fall back to the raw task id.
      -    """
      -    try:
      -        from tools.terminal_tool import _resolve_container_task_id
      -        container_key = _resolve_container_task_id(task_id)
      -    except Exception:
      -        container_key = task_id
      -    with _file_ops_lock:
      -        return _last_known_cwd.get(container_key) or _last_known_cwd.get(task_id)
       
       # Track files read per task to detect re-read loops and deduplicate reads.
       # Per task_id we store:
      @@ -1069,13 +964,18 @@ def _get_file_ops(task_id: str = "default") -> ShellFileOperations:
                       _last_activity[task_id] = time.time()
                       return cached
                   else:
      -                # Environment was cleaned up -- preserve the old cwd before
      -                # invalidating the stale cache entry (fixes #26211: silent
      -                # file-creation failures in long-running conversations).
      +                # Environment was cleaned up -- preserve the old cwd in the
      +                # session record before invalidating the stale cache entry
      +                # (fixes #26211: silent file-creation failures in long-running
      +                # conversations). Usually a no-op: every completed command
      +                # already recorded its cwd.
                       old_cwd = getattr(cached, "cwd", None)
                       if old_cwd:
      -                    with _file_ops_lock:
      -                        _last_known_cwd[task_id] = old_cwd
      +                    try:
      +                        from tools.terminal_tool import record_session_cwd
      +                        record_session_cwd(raw_task_id, old_cwd)
      +                    except Exception:
      +                        pass
                       with _file_ops_lock:
                           _file_ops_cache.pop(task_id, None)
       
      @@ -1113,7 +1013,12 @@ def _get_file_ops(task_id: str = "default") -> ShellFileOperations:
                   else:
                       image = ""
       
      -            cwd = overrides.get("cwd") or _last_known_cwd.get(task_id) or config["cwd"]
      +            try:
      +                from tools.terminal_tool import get_session_cwd
      +                recorded_cwd = get_session_cwd(raw_task_id)
      +            except Exception:
      +                recorded_cwd = None
      +            cwd = overrides.get("cwd") or recorded_cwd or config["cwd"]
                   # Re-apply the container cwd guard that _get_env_config() already
                   # ran on config["cwd"] (see #50636).  A per-task cwd override
                   # registered by the gateway/TUI/ACP for workspace tracking is a
    • tools/terminal_tool.py
      @@ -1069,6 +1069,58 @@ def _maybe_reap_docker_orphans(container_config: Dict[str, Any]) -> None:
       # Thread-safe because each task_id is unique per rollout.
       _task_env_overrides: Dict[str, Dict[str, Any]] = {}
       
      +# ── Per-session cwd records (cwd rearchitecture, step 1) ────────────────────
      +#
      +# The durable source of truth for "which directory is THIS session working
      +# in". Keyed by the raw session/task key (NOT the collapsed container id):
      +# the terminal env is shared across sessions, so any cwd state stored on the
      +# env is a global mutable timeshared between sessions — the root cause of the
      +# wrong-worktree bug class (env.cwd_owner stamping, _last_known_cwd, and the
      +# ownership ladder in file_tools are all patches over that misplacement).
      +#
      +# Step 1 (this change): dual-write only. Every site that learns a session's
      +# live cwd (post-command tracking, cwd-override registration) also records it
      +# here. Readers still use the legacy env.cwd ladder. Later steps flip
      +# file_tools and _resolve_command_cwd to read this store, then delete the
      +# env-side tracking + ownership guards.
      +_session_cwd: Dict[str, str] = {}
      +_session_cwd_lock = threading.Lock()
      +
      +
      +def record_session_cwd(session_key: Optional[str], cwd: Optional[str]) -> None:
      +    """Record *cwd* as the working directory of *session_key*.
      +
      +    Called wherever a session's live cwd becomes known: after a terminal
      +    command completes (the env's post-command tracking has just parsed the
      +    resulting cwd) and when a surface registers a workspace cwd override.
      +    Empty/None session keys collapse to ``"default"`` (single-session CLI).
      +    Non-string / empty cwds are ignored.
      +    """
      +    if not isinstance(cwd, str) or not cwd.strip():
      +        return
      +    key = str(session_key or "default")
      +    with _session_cwd_lock:
      +        if _session_cwd.get(key) != cwd:
      +            _session_cwd[key] = cwd
      +
      +
      +def get_session_cwd(session_key: Optional[str]) -> Optional[str]:
      +    """Return the recorded working directory for *session_key*, if any.
      +
      +    No fallback chain here on purpose: callers decide what an absent record
      +    means (config default, TERMINAL_CWD seed, process cwd). ``None``/empty
      +    keys read the ``"default"`` record.
      +    """
      +    key = str(session_key or "default")
      +    with _session_cwd_lock:
      +        return _session_cwd.get(key)
      +
      +
      +def clear_session_cwd(session_key: str) -> None:
      +    """Drop a session's cwd record (session teardown)."""
      +    with _session_cwd_lock:
      +        _session_cwd.pop(session_key, None)
      +
       
       def register_task_env_overrides(task_id: str, overrides: Dict[str, Any]):
           """
      @@ -1090,15 +1142,14 @@ def register_task_env_overrides(task_id: str, overrides: Dict[str, Any]):
       
           # If a live environment already exists for this task, a freshly registered
           # ``cwd`` override (e.g. the ACP client switching the editor's project root
      -    # mid-session via ``session/load`` / ``session/resume``) must take effect on
      -    # the cached env too. ``terminal_tool`` resolves the per-command cwd as
      -    # ``workdir > env.cwd > config/override cwd`` so that ordinary in-session
      -    # ``cd`` state is preserved; without syncing here the override would sit
      -    # below the (already-set) ``env.cwd`` and be silently ignored once any
      -    # command has run. Pushing it onto the live env keeps ``cd`` tracking intact
      -    # while letting an explicit ACP cwd change win, as the client expects.
      +    # mid-session via ``session/load`` / ``session/resume``) must take effect
      +    # immediately. The session record is what commands resolve against;
      +    # the live env's cwd is also updated so env-side seeding stays consistent.
           new_cwd = overrides.get("cwd")
           if isinstance(new_cwd, str) and new_cwd.strip():
      +        # A registered workspace cwd IS the session's working directory until
      +        # a `cd` changes it.
      +        record_session_cwd(task_id, new_cwd)
               # The live env is cached under the raw task_id for per-session surfaces
               # (ACP/gateway/dashboard) and under the collapsed container id for
               # isolation-keyed rollouts. Try the raw id first, then the container id,
      @@ -1118,6 +1169,7 @@ def clear_task_env_overrides(task_id: str):
           Called during cleanup to avoid stale entries accumulating.
           """
           _task_env_overrides.pop(task_id, None)
      +    clear_session_cwd(task_id)
       
       
       def _resolve_container_task_id(task_id: Optional[str]) -> str:
      @@ -1986,43 +2038,21 @@ def _resolve_notification_flag_conflict(
       def _resolve_command_cwd(
           *,
           workdir: Optional[str],
      -    env: Any,
           default_cwd: str,
      -    prev_owner: Optional[str] = None,
      +    session_key: Optional[str] = None,
       ) -> str:
      -    """Return the cwd for a command, preferring the live session cwd.
      -
      -    ``terminal_tool`` historically re-sent the init-time/config cwd on every
      -    call. That broke session-local ``cd`` state: the environment tracked the
      -    new directory in ``env.cwd``, but foreground/background calls kept forcing
      -    the old cwd back through ``env.execute(..., cwd=...)``. Explicit
      -    ``workdir=`` must still override everything.
      -
      -    When ``prev_owner`` is provided and differs from the current session,
      -    ``env.cwd`` was mutated by a *different* session's ``cd`` and must NOT be
      -    trusted — fall through to ``default_cwd`` (the config/override cwd) so
      -    the command runs in this session's own workspace, not the previous
      -    session's leftover checkout.  This mirrors the ``_live_cwd_if_owned``
      -    guard file_tools uses for the same shared-env problem.
      +    """Return the cwd for a command. Explicit ``workdir=`` overrides everything.
      +
      +    Otherwise the session's own cwd RECORD (``get_session_cwd``) wins — it is
      +    written after every completed command for this session, so it IS the
      +    session's ``cd`` state, with no shared-env ambiguity: another session's
      +    ``cd`` lands in another record and can't affect us. A session with no
      +    record yet (first command) runs in ``default_cwd`` (config/override cwd),
      +    which is also what seeds a fresh environment.
           """
           if workdir:
               return workdir
      -
      -    live_cwd = getattr(env, "cwd", None)
      -    if isinstance(live_cwd, str) and live_cwd.strip():
      -        # The env is shared (collapsed to "default"); its cwd tracks the LAST
      -        # session that ran a command.  If a different session owned the env
      -        # before this call claimed it, env.cwd is that session's leftover `cd`
      -        # — not ours.  Don't use it.
      -        if prev_owner is not None:
      -            session_key = getattr(env, "cwd_owner", "")
      -            # cwd_owner was already overwritten to the current session at the
      -            # call site, so compare against the captured previous owner.
      -            if prev_owner and prev_owner != "default" and session_key != prev_owner:
      -                return default_cwd
      -        return live_cwd
      -
      -    return default_cwd
      +    return get_session_cwd(session_key) or default_cwd
       
       
       def terminal_tool(
      @@ -2111,7 +2141,7 @@ def terminal_tool(
               else:
                   image = ""
       
      -        cwd = overrides.get("cwd") or config["cwd"]
      +        cwd = overrides.get("cwd") or get_session_cwd(task_id) or config["cwd"]
               # A per-task cwd override (registered by the gateway/TUI for workspace
               # tracking, or by RL/benchmark envs) wins over config["cwd"] — but
               # config["cwd"] was already sanitized for container backends in
      @@ -2164,6 +2194,7 @@ def terminal_tool(
               # Use a per-task creation lock so concurrent tool calls for the same
               # task_id wait for the first one to finish creating the sandbox,
               # instead of each creating their own (wasting Modal resources).
      +        env = None
               with _env_lock:
                   # Prefer the collapsed container id, but fall back to an env cached
                   # under the raw task_id. Per-session surfaces (ACP/gateway/dashboard)
      @@ -2265,6 +2296,16 @@ def terminal_tool(
                               env = new_env
                           logger.info("%s environment ready for task %s", env_type, effective_task_id[:8])
       
      +        if env is None:
      +            # Unreachable in practice (either the cached branch or the creation
      +            # branch assigned env above); guard for type-safety and so a future
      +            # refactor of the branches can't fall through to an AttributeError.
      +            return json.dumps({
      +                "output": "",
      +                "exit_code": -1,
      +                "error": "Terminal environment unavailable (creation raced cleanup)",
      +            }, ensure_ascii=False)
      +
               # Hard-block: gateway lifecycle commands (systemctl/launchctl/hermes
               # restart|stop targeting hermes-gateway) must never run inside the
               # gateway process itself. The restart would SIGTERM the gateway, which
      @@ -2362,24 +2403,13 @@ def terminal_tool(
                       "EOF."
                   )
       
      -        # Claim the (shared "default") terminal env for the session driving this
      -        # command. File tools read env.cwd_owner to decide whether the env's live
      -        # cwd is THIS session's `cd` or a different worktree session's — without
      -        # it, two open worktree sessions sharing the env route each other's edits
      -        # to the wrong checkout. get_current_session_key()'s contextvar doesn't
      -        # cross tool-worker threads, so fall back to the raw task_id (which IS the
      -        # session_key for the top-level agent) — a stable, thread-safe anchor.
      +        # The session key that drives cwd records: get_current_session_key()'s
      +        # contextvar doesn't cross tool-worker threads, so fall back to the raw
      +        # task_id (which IS the session_key for the top-level agent) — a
      +        # stable, thread-safe anchor.
               from tools.approval import get_current_session_key
       
               session_key = get_current_session_key(default="") or (task_id or "")
      -        # Capture the env's previous owner BEFORE claiming it — _resolve_command_cwd
      -        # needs to know whether env.cwd was left by a *different* session's `cd`
      -        # (in which case it's stale for this session and must be ignored).
      -        prev_cwd_owner = getattr(env, "cwd_owner", "") or ""
      -        try:
      -            env.cwd_owner = session_key
      -        except Exception:
      -            pass
       
               if background:
                   # Spawn a tracked background process via the process registry.
      @@ -2389,9 +2419,8 @@ def terminal_tool(
       
                   effective_cwd = _resolve_command_cwd(
                       workdir=workdir,
      -                env=env,
                       default_cwd=cwd,
      -                prev_owner=prev_cwd_owner,
      +                session_key=session_key,
                   )
                   try:
                       if env_type == "local":
      @@ -2650,9 +2679,8 @@ def terminal_tool(
                       try:
                           command_cwd = _resolve_command_cwd(
                               workdir=workdir,
      -                        env=env,
                               default_cwd=cwd,
      -                        prev_owner=prev_cwd_owner,
      +                        session_key=session_key,
                           )
                           execute_kwargs = {
                               "timeout": effective_timeout,
      @@ -2693,7 +2721,15 @@ def terminal_tool(
                       
                       # Got a result
                       break
      -            
      +
      +            # Dual-write (cwd rearch step 1): the env's post-command tracking
      +            # (marker parse / local sync) has just updated env.cwd with the
      +            # directory this command finished in. That cwd belongs to THIS
      +            # session — record it under the session key so the durable record
      +            # never depends on the shared env surviving or on who drives the
      +            # env next.
      +            record_session_cwd(session_key, getattr(env, "cwd", None))
      +
                   # Extract output
                   output = result.get("output", "")
                   returncode = result.get("returncode", 0)
  • #65487 fix(execute-code): resolve project-mode cwd from the session's own directory

    merged to main

    files: tests/tools/test_code_execution_modes.py, tools/code_execution_tool.py

    Show the diff
    • tests/tools/test_code_execution_modes.py
      @@ -220,6 +220,69 @@ def test_project_expands_tilde(self):
               with patch.dict(os.environ, {"TERMINAL_CWD": "~"}):
                   self.assertEqual(_resolve_child_cwd("project", "/tmp/staging"), home)
       
      +    def test_project_prefers_registered_task_cwd_override(self):
      +        import tempfile
      +        import tools.terminal_tool as terminal_tool
      +
      +        with tempfile.TemporaryDirectory() as td:
      +            task_id = "session-cwd-test"
      +            with patch.dict(os.environ, {"TERMINAL_CWD": "/does/not/exist"}):
      +                with patch.object(terminal_tool, "_task_env_overrides", {}, create=False):
      +                    terminal_tool.register_task_env_overrides(task_id, {"cwd": td})
      +                    self.assertEqual(_resolve_child_cwd("project", "/tmp/staging", task_id=task_id), td)
      +
      +    def test_project_prefers_session_cwd_record_over_override(self):
      +        """The session's cwd RECORD (its live `cd` state) outranks the
      +        registration-time workspace override — same ladder as file tools
      +        and the terminal, so a `cd` before execute_code is honored."""
      +        import tempfile
      +        import tools.terminal_tool as terminal_tool
      +
      +        with tempfile.TemporaryDirectory() as reg, tempfile.TemporaryDirectory() as cded:
      +            task_id = "session-record-test"
      +            with patch.dict(os.environ, {"TERMINAL_CWD": "/does/not/exist"}):
      +                with patch.object(terminal_tool, "_task_env_overrides", {}, create=False), \
      +                     patch.object(terminal_tool, "_session_cwd", {}, create=False):
      +                    terminal_tool.register_task_env_overrides(task_id, {"cwd": reg})
      +                    # Simulate a later `cd`: post-command tracking rewrites the record.
      +                    terminal_tool.record_session_cwd(task_id, cded)
      +                    self.assertEqual(
      +                        _resolve_child_cwd("project", "/tmp/staging", task_id=task_id), cded
      +                    )
      +
      +    def test_project_uses_session_cwd_record_without_any_override(self):
      +        """A session that only `cd`'d (no session.cwd.set registration) still
      +        resolves to its recorded directory."""
      +        import tempfile
      +        import tools.terminal_tool as terminal_tool
      +
      +        with tempfile.TemporaryDirectory() as cded:
      +            task_id = "record-only-test"
      +            with patch.dict(os.environ, {"TERMINAL_CWD": "/does/not/exist"}):
      +                with patch.object(terminal_tool, "_task_env_overrides", {}, create=False), \
      +                     patch.object(terminal_tool, "_session_cwd", {}, create=False):
      +                    terminal_tool.record_session_cwd(task_id, cded)
      +                    self.assertEqual(
      +                        _resolve_child_cwd("project", "/tmp/staging", task_id=task_id), cded
      +                    )
      +
      +    def test_project_stale_record_falls_through_to_override(self):
      +        """A recorded directory that no longer exists is skipped; the
      +        registered override is the next rung."""
      +        import tempfile
      +        import tools.terminal_tool as terminal_tool
      +
      +        with tempfile.TemporaryDirectory() as reg:
      +            task_id = "stale-record-test"
      +            with patch.dict(os.environ, {"TERMINAL_CWD": "/does/not/exist"}):
      +                with patch.object(terminal_tool, "_task_env_overrides", {}, create=False), \
      +                     patch.object(terminal_tool, "_session_cwd", {}, create=False):
      +                    terminal_tool.register_task_env_overrides(task_id, {"cwd": reg})
      +                    terminal_tool.record_session_cwd(task_id, "/deleted/dir/gone")
      +                    self.assertEqual(
      +                        _resolve_child_cwd("project", "/tmp/staging", task_id=task_id), reg
      +                    )
      +
       
       # ---------------------------------------------------------------------------
       # Schema description
      @@ -316,6 +379,28 @@ def test_project_mode_runs_in_session_cwd(self):
                       os.path.realpath(td),
                   )
       
      +    def test_project_mode_uses_registered_session_cwd_override(self):
      +        """Project mode must honor session.cwd.set-style overrides even when
      +        TERMINAL_CWD is absent or points elsewhere."""
      +        import tempfile
      +        import tools.terminal_tool as terminal_tool
      +
      +        with tempfile.TemporaryDirectory() as td:
      +            task_id = "session-cwd-test"
      +            with patch.dict(os.environ, {"TERMINAL_CWD": "/does/not/exist"}):
      +                with patch.object(terminal_tool, "_task_env_overrides", {}, create=False):
      +                    terminal_tool.register_task_env_overrides(task_id, {"cwd": td})
      +                    with _mock_mode("project"):
      +                        with patch("model_tools.handle_function_call", side_effect=_mock_handle_function_call):
      +                            raw = execute_code(
      +                                code="import os; print(os.getcwd())",
      +                                task_id=task_id,
      +                                enabled_tools=list(SANDBOX_ALLOWED_TOOLS),
      +                            )
      +            result = json.loads(raw)
      +            self.assertEqual(result["status"], "success")
      +            self.assertEqual(os.path.realpath(result["output"].strip()), os.path.realpath(td))
      +
           def test_project_mode_interpreter_is_venv_python(self):
               """Project mode: sys.executable inside the child is the venv's python
               when VIRTUAL_ENV is set to a real venv."""
    • tools/code_execution_tool.py
      @@ -1339,7 +1339,7 @@ def execute_code(
               # Env scrubbing and tool whitelist apply identically in both modes.
               _mode = _get_execution_mode()
               _child_python = _resolve_child_python(_mode)
      -        _child_cwd = _resolve_child_cwd(_mode, tmpdir)
      +        _child_cwd = _resolve_child_cwd(_mode, tmpdir, task_id=task_id or "")
               _script_path = os.path.join(tmpdir, "script.py")
       
               proc = subprocess.Popen(
      @@ -1745,17 +1745,43 @@ def _resolve_child_python(mode: str) -> str:
           return sys.executable
       
       
      -def _resolve_child_cwd(mode: str, staging_dir: str) -> str:
      +def _resolve_child_cwd(mode: str, staging_dir: str, task_id: str = "") -> str:
           """Resolve the working directory for the execute_code subprocess.
       
           - ``strict``: the staging tmpdir (today's behavior).
      -    - ``project``: the session's TERMINAL_CWD (same as the terminal tool), or
      -      ``os.getcwd()`` if TERMINAL_CWD is unset or doesn't point at a real dir.
      -      Falls back to the staging tmpdir as a last resort so we never invoke
      -      Popen with a nonexistent cwd.
      +    - ``project``: the session's own cwd — its per-session cwd record
      +      (written after every completed terminal command), then the raw
      +      per-session cwd override registered via ``session.cwd.set`` /
      +      ``register_task_env_overrides``, then the session's TERMINAL_CWD
      +      (same as the terminal tool), or ``os.getcwd()`` if none points at a
      +      real dir. Falls back to the staging tmpdir as a last resort so we
      +      never invoke Popen with a nonexistent cwd.
      +
      +    This mirrors the resolution ladder file tools and the terminal use
      +    (record → registered override → TERMINAL_CWD), so all file-writing
      +    paths within a session agree on the working directory. (#56047)
           """
           if mode != "project":
               return staging_dir
      +    if task_id:
      +        # 1. The session's cwd record — IS the session's `cd` state.
      +        try:
      +            from tools.terminal_tool import get_session_cwd
      +
      +            recorded = get_session_cwd(task_id)
      +        except Exception:
      +            recorded = None
      +        if recorded and os.path.isdir(recorded):
      +            return recorded
      +        # 2. Registered workspace override (session.cwd.set → gateway/TUI/ACP).
      +        try:
      +            from tools.file_tools import _registered_task_cwd_override
      +
      +            session_cwd = _registered_task_cwd_override(task_id)
      +        except Exception:
      +            session_cwd = None
      +        if session_cwd and os.path.isdir(session_cwd):
      +            return session_cwd
           raw = os.environ.get("TERMINAL_CWD", "").strip()
           if raw:
               expanded = os.path.expanduser(raw)

9 PRs closed without merging

From the close comments: merged via another change 3 · superseded by another PR 2 · done elsewhere 1 · duplicate 1 · other 2

  • #35768 fix(skills): ignore nested assets in skill lookup · other
  • #36895 fix(skills): ignore asset markdown in skill_view collision scan · done elsewhere → 9137b86, #37164
  • #49658 fix(tui): always capture current CWD instead of inheriting stale HERMES_CWD · other → #49662, #49637
  • #49662 fix(cli): use live launch cwd for HERMES_CWD instead of stale env · superseded by another PR → #49658
  • #56055 fix(agent): resolve execute_code cwd from per-session override (#56047) · merged via another change → 298a949, #65487, #56803, #65213
Show 4 more
  • #56069 fix(code-exec): honor session cwd override · duplicate → #35513, #56055, #46843, #56047
  • #56406 fix(tools): honor per-session cwd override in execute_code project mode · superseded by another PR → #35513
  • #56803 fix(execute-code): honor session cwd overrides · merged via another change → 4d6686c, #65487, #56055, #65213
  • #109839 fix(tui): make the launch cwd win over an inherited HERMES_CWD · merged via another change
Closed issues · 3
  • Closed#42525feat(desktop): allow changing workspace/directory from Hermes Desktop UI
  • Closed#49637[Bug]: TUI reuses stale HERMES_CWD instead of current launch cwd
  • Closed#56047Bug: execute_code sandbox ignores the per-session cwd override, diverges from write_file/terminal
How this cluster was grouped
  • Link evidence (partly structural links): some members are connected by structural evidence (shared changed files, or a closing/superseded reference); the rest are joined by an automated relevance judgement over their text.
  • Members outside that structural core may belong to a different underlying problem.
  • Pairwise link density 0.07: the share of item 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.

Pick up a Lab task

Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.

Open the Lab

At a glance

Subsystem
agent
Merged PRs
#65213, #65487
Closed issues
3
Attempts
9 PRs closed without merging
Fix link checked
16 Jul 2026

For your agent

The same facts, typed.