fix(gateway): record a terminal session's working directory

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.
This commit is contained in:
Brooklyn Nicholson 2026-07-26 01:20:35 -05:00
parent 19025eb7ce
commit 3172b8739e
2 changed files with 57 additions and 7 deletions

View file

@ -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()

View file

@ -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).