From 3172b8739eb482e308fcba9a6a11d34a44c75edf Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Sun, 26 Jul 2026 01:20:35 -0500 Subject: [PATCH] fix(gateway): record a terminal session's working directory MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sessions started from the terminal were stored with no `cwd` and no `git_repo_root`, so the sidebar had nothing to group them by and they never appeared under their project — they fell into "No workspace" instead. On one real profile this covered 690 of 1063 TUI sessions. The row write persisted a cwd only when `explicit_cwd` was set, which happens only if the user switches directory mid-session. The intent was to avoid filing chats under whatever folder the app launched in, and that reasoning holds for the desktop, whose launch directory is an artifact of how the bundle was opened. It does not hold for a terminal session: the user cd'd into that directory before running hermes, and it is where the agent's terminal runs. Split the two cases behind one helper. An explicit pick is always persisted; otherwise the launch directory is recorded for terminal-started sessions and left unset for the desktop, preserving the existing "No workspace" default there. Existing rows are unaffected: they carry neither cwd nor git_repo_root, so there is nothing to recover them from. --- tests/tui_gateway/test_projects_rpc.py | 24 ++++++++++++++++ tui_gateway/server.py | 40 +++++++++++++++++++++----- 2 files changed, 57 insertions(+), 7 deletions(-) diff --git a/tests/tui_gateway/test_projects_rpc.py b/tests/tui_gateway/test_projects_rpc.py index 1acb712dff2c..067a47586388 100644 --- a/tests/tui_gateway/test_projects_rpc.py +++ b/tests/tui_gateway/test_projects_rpc.py @@ -295,6 +295,30 @@ def test_scan_time_is_not_treated_as_session_activity(tmp_path): assert active["last_active"] > idle["last_active"] +def test_terminal_session_persists_its_launch_cwd(): + """A terminal session's cwd IS its workspace, so the row must record it. + + The user cd'd into that directory before running hermes. Dropping it left + the row with no cwd and no git_repo_root, so the sidebar could never place + the session under its project. + """ + for source in ("tui", "cli"): + assert server._persisted_session_cwd( + {"source": source, "cwd": "/somewhere/a-repo"} + ) == "/somewhere/a-repo" + + +def test_desktop_launch_cwd_is_not_persisted_as_a_workspace(): + # The desktop launches from wherever the bundle was opened, so an unpicked + # cwd is an artifact — those chats belong under "No workspace". + assert server._persisted_session_cwd({"source": "desktop", "cwd": "/opt/whatever"}) is None + + # An explicit pick is always honored, desktop included. + assert server._persisted_session_cwd( + {"source": "desktop", "cwd": "/picked/repo", "explicit_cwd": True} + ) == "/picked/repo" + + def test_disabled_discovery_clears_cache_and_rejects_new_scan(monkeypatch, tmp_path): repo = tmp_path / "cached-repo" repo.mkdir() diff --git a/tui_gateway/server.py b/tui_gateway/server.py index 7375ab542cf4..6cffc43e48ee 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -2086,6 +2086,25 @@ def _session_cwd(session: dict | None) -> str: return _completion_cwd() +# Sources whose launch directory is an artifact of how the app was started, not +# a workspace the user picked. Everything else is terminal-started: the process +# runs in a directory the user deliberately cd'd into. +_LAUNCH_CWD_NOT_A_WORKSPACE = {"desktop"} + + +def _persisted_session_cwd(session: dict) -> str | None: + """The cwd to stamp on the session's DB row, or None to leave it unset. + + See :func:`_ensure_session_db_row` for why the launch directory counts as a + workspace for terminal sessions but not for the desktop. + """ + if session.get("explicit_cwd"): + return _session_cwd(session) + if _session_source(session) in _LAUNCH_CWD_NOT_A_WORKSPACE: + return None + return _session_cwd(session) or None + + def _heal_dead_cwd(cwd: str) -> str: """Resolve a session cwd that points at a now-deleted directory. @@ -2184,12 +2203,19 @@ def _ensure_session_db_row(session: dict) -> None: Uses INSERT OR IGNORE under the hood, so re-calls (and the AIAgent's own lazy create) are no-ops. - Only an *explicitly chosen* workspace is persisted as the session's cwd. - The agent still runs in the auto-detected directory (session["cwd"]), but - we don't stamp that onto the row — otherwise every session the user never - picked a folder for gets grouped under whatever directory the desktop - happened to launch in (e.g. "desktop"). Leaving it null groups them under - "No workspace", which is the desired default. + A cwd the user *chose* is always persisted. When they made no explicit + choice the launch directory stands in, and whether that is meaningful + depends on how the session was started: + + * The desktop launches from wherever the app bundle was opened (often ``/`` + or the user's home), so stamping that would file every unpicked chat under + a folder the user never chose. Those stay null and group under "No + workspace", which is the desired default. + * A terminal session (``hermes`` / ``hermes --tui`` / CLI) is started from a + directory the user deliberately ``cd``'d into — that IS the workspace, and + it is also where the agent's terminal actually runs. Dropping it stranded + the session with no cwd AND no git_repo_root, so the sidebar could never + place it under its project. """ key = session.get("session_key") if not key: @@ -2278,7 +2304,7 @@ def _ensure_session_db_row(session: dict) -> None: model=row_model, model_config=model_config or None, parent_session_id=parent_session_id, - cwd=_session_cwd(session) if session.get("explicit_cwd") else None, + cwd=_persisted_session_cwd(session), # Self-describing rows: aggregators that merge multiple profile DBs # into one list can't rely on which file a row came from alone. NULL # means the launch/default profile (matches run_agent's convention).