diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 95e79e905fd9..3f3d5680647d 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -1304,13 +1304,49 @@ def _session_browse_picker(sessions: list) -> Optional[str]: return None +def _resolve_workspace_key() -> Optional[str]: + """The current workspace identity for cwd-scoped resume. + + Git repo root when CWD is inside a repo (so all sessions across its + subdirs/worktrees group together), else the CWD itself. Returns None when + neither can be determined — callers fall back to the global MRU then. + """ + try: + import subprocess + + result = subprocess.run( + ["git", "rev-parse", "--show-toplevel"], + capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=5, + ) + if result.returncode == 0 and result.stdout.strip(): + return os.path.abspath(result.stdout.strip()) + except Exception: + pass + try: + return os.getcwd() + except Exception: + return None + + def _resolve_last_session(source: str = "cli") -> Optional[str]: - """Look up the most recently-used session ID for a source.""" + """Look up the most recently-used session ID for a source. + + Scoped to the current workspace first (git repo root, else cwd) so + ``hermes -c`` from repo A continues repo A's last session rather than the + global MRU. Falls back to the unscoped MRU when no session matches the + current workspace, preserving the old behaviour for fresh directories. + """ db = None try: from hermes_state import SessionDB db = SessionDB() + ws_key = _resolve_workspace_key() + if ws_key: + sessions = db.search_sessions(source=source, limit=1, workspace_key=ws_key) + if sessions: + return sessions[0]["id"] + # Fallback: global MRU for this source. sessions = db.search_sessions(source=source, limit=1) return sessions[0]["id"] if sessions else None except Exception: diff --git a/hermes_state.py b/hermes_state.py index 9ad8761670dc..f309acce1277 100644 --- a/hermes_state.py +++ b/hermes_state.py @@ -135,6 +135,24 @@ def _cwd_prefix_clause(cwd_prefix: str) -> Tuple[str, List[str]]: return "(s.cwd = ? OR s.cwd LIKE ? OR s.cwd LIKE ?)", [prefix, f"{prefix}/%", f"{prefix}\\%"] +def _workspace_key_clause(key: str) -> Tuple[str, List[str]]: + """Match sessions whose ``workspace_key(row)`` equals ``key``. + + Mirrors :func:`workspace_key`: a session belongs to workspace ``key`` + when its recorded ``git_repo_root`` equals ``key``, or — for rows that + predate per-session git metadata — when its ``cwd`` is at or under + ``key`` (so a session started in ``repo/src`` still groups with ``repo``). + Used by ``hermes -c``/``--resume`` to continue the most recent session in + the *current* workspace rather than the global MRU. + """ + prefix = key.rstrip("/\\") or key + cwd_clause, cwd_params = _cwd_prefix_clause(prefix) + return ( + f"(s.git_repo_root = ? OR (COALESCE(s.git_repo_root, '') = '' AND {cwd_clause}))", + [prefix, *cwd_params], + ) + + # Session preview = the head of the first user message, shown wherever a # session has no title (sidebar rows, pickers, exports, the desktop's # `sessionTitle` fallback). @@ -8656,12 +8674,18 @@ class SessionDB: source: str = None, limit: int = 20, offset: int = 0, + workspace_key: str = None, ) -> List[Dict[str, Any]]: """List sessions, optionally filtered by source. Returns rows enriched with a computed ``last_active`` column (latest message timestamp for the session, falling back to ``started_at``), ordered by most-recently-used first. + + Pass ``workspace_key`` to scope rows to one workspace — matching + :func:`workspace_key` semantics (git repo root, else cwd). Used by + ``hermes -c``/``--resume`` so the "last" session is the last one in + the *current* workspace, not the global MRU. """ select_with_last_active = ( "SELECT s.*, COALESCE(m.last_active, s.started_at) AS last_active " @@ -8671,20 +8695,24 @@ class SessionDB: "FROM messages GROUP BY session_id" ") m ON m.session_id = s.id " ) + where_clauses = [] + params: list = [] + if source: + where_clauses.append("s.source = ?") + params.append(source) + if workspace_key: + ws_clause, ws_params = _workspace_key_clause(workspace_key) + where_clauses.append(ws_clause) + params.extend(ws_params) + where_sql = f" WHERE {' AND '.join(where_clauses)}" if where_clauses else "" + params.extend([limit, offset]) with self._lock: - if source: - cursor = self._conn.execute( - f"{select_with_last_active}" - "WHERE s.source = ? " - "ORDER BY last_active DESC, s.started_at DESC, s.id DESC LIMIT ? OFFSET ?", - (source, limit, offset), - ) - else: - cursor = self._conn.execute( - f"{select_with_last_active}" - "ORDER BY last_active DESC, s.started_at DESC, s.id DESC LIMIT ? OFFSET ?", - (limit, offset), - ) + cursor = self._conn.execute( + f"{select_with_last_active}" + f"{where_sql} " + "ORDER BY last_active DESC, s.started_at DESC, s.id DESC LIMIT ? OFFSET ?", + params, + ) return [dict(row) for row in cursor.fetchall()] # ========================================================================= diff --git a/tests/hermes_cli/test_resolve_last_session.py b/tests/hermes_cli/test_resolve_last_session.py index 1a82d1a79923..acd54b9a075e 100644 --- a/tests/hermes_cli/test_resolve_last_session.py +++ b/tests/hermes_cli/test_resolve_last_session.py @@ -155,3 +155,198 @@ def test_resolve_last_session_not_limited_to_newest_started_20(tmp_path, monkeyp monkeypatch.setattr("hermes_state.SessionDB", lambda: real_session_db(db_path=state_db)) assert _resolve_last_session("cli") == target + + +# --------------------------------------------------------------------------- +# cwd-scoped resume: -c prefers the last session in the current workspace. +# --------------------------------------------------------------------------- + + +class _WorkspaceAwareDB: + """Fake SessionDB whose ``search_sessions`` honors ``workspace_key`` the + same way the real ``_workspace_key_clause`` does: a row matches the key + when its ``git_repo_root`` equals it, or (no repo root recorded) when its + ``cwd`` is at or under it.""" + + def __init__(self, rows): + self._rows = rows + self.closed = False + + def search_sessions(self, source=None, limit=20, workspace_key=None, **_kw): + rows = [r for r in self._rows if r.get("source") == source] if source else list(self._rows) + if workspace_key: + key = workspace_key.rstrip("/") + def _in_ws(r): + grr = (r.get("git_repo_root") or "").rstrip("/") + if grr: + return grr == key + cwd = (r.get("cwd") or "").rstrip("/") + return cwd == key or cwd.startswith(key + "/") + rows = [r for r in rows if _in_ws(r)] + rows.sort( + key=lambda r: float(r.get("last_active") or r.get("started_at") or 0), + reverse=True, + ) + return rows[:limit] + + def close(self): + self.closed = True + + +def test_resolve_last_session_prefers_workspace_session_over_global_mru(monkeypatch, tmp_path): + # Two sessions: a globally-recent one in repo B, an older one in repo A. + # CWD is repo A → -c must pick repo A's session, not the global MRU. + repo_a = tmp_path / "repo-a" + repo_b = tmp_path / "repo-b" + repo_a.mkdir() + repo_b.mkdir() + monkeypatch.chdir(repo_a) + # Pretend both are git repo roots so _resolve_workspace_key returns the dir. + monkeypatch.setattr( + "hermes_cli.main.subprocess.run", + lambda cmd, **kw: __import__("subprocess").CompletedProcess( + cmd, 0, stdout=str(repo_a), stderr="" + ), + ) + + rows = [ + {"id": "global_recent", "source": "cli", "started_at": 9000.0, + "last_active": 9000.0, "cwd": str(repo_b), "git_repo_root": str(repo_b)}, + {"id": "repo_a_older", "source": "cli", "started_at": 1000.0, + "last_active": 1000.0, "cwd": str(repo_a), "git_repo_root": str(repo_a)}, + ] + monkeypatch.setattr("hermes_state.SessionDB", lambda: _WorkspaceAwareDB(rows)) + assert _resolve_last_session("cli") == "repo_a_older" + + +def test_resolve_last_session_falls_back_to_global_when_workspace_empty(monkeypatch, tmp_path): + # CWD has no matching session → fall back to the global MRU. + repo_a = tmp_path / "repo-a" + repo_a.mkdir() + monkeypatch.chdir(repo_a) + monkeypatch.setattr( + "hermes_cli.main.subprocess.run", + lambda cmd, **kw: __import__("subprocess").CompletedProcess( + cmd, 0, stdout=str(repo_a), stderr="" + ), + ) + rows = [ + {"id": "elsewhere", "source": "cli", "started_at": 9000.0, + "last_active": 9000.0, "cwd": "/other/repo", "git_repo_root": "/other/repo"}, + ] + monkeypatch.setattr("hermes_state.SessionDB", lambda: _WorkspaceAwareDB(rows)) + assert _resolve_last_session("cli") == "elsewhere" + + +def test_resolve_last_session_workspace_matches_cwd_subdir_without_git_root(monkeypatch, tmp_path): + # No git repo root on the session (legacy row) — match via cwd prefix so a + # session started in repo/src still resumes from repo/. + repo = tmp_path / "repo" + repo.mkdir() + monkeypatch.chdir(repo) + monkeypatch.setattr( + "hermes_cli.main.subprocess.run", + lambda cmd, **kw: __import__("subprocess").CompletedProcess( + cmd, 1, stdout="", stderr="not a repo" + ), + ) + rows = [ + {"id": "subdir_session", "source": "cli", "started_at": 500.0, + "last_active": 500.0, "cwd": str(repo / "src"), "git_repo_root": ""}, + ] + monkeypatch.setattr("hermes_state.SessionDB", lambda: _WorkspaceAwareDB(rows)) + assert _resolve_last_session("cli") == "subdir_session" + + +def test_resolve_last_session_workspace_key_filters_by_source(monkeypatch, tmp_path): + # A TUI session in repo A is newer than a CLI session in repo A; asking for + # source="cli" must skip the TUI row even within the same workspace. + repo = tmp_path / "repo" + repo.mkdir() + monkeypatch.chdir(repo) + monkeypatch.setattr( + "hermes_cli.main.subprocess.run", + lambda cmd, **kw: __import__("subprocess").CompletedProcess( + cmd, 0, stdout=str(repo), stderr="" + ), + ) + rows = [ + {"id": "tui_recent", "source": "tui", "started_at": 9000.0, + "last_active": 9000.0, "cwd": str(repo), "git_repo_root": str(repo)}, + {"id": "cli_older", "source": "cli", "started_at": 1000.0, + "last_active": 1000.0, "cwd": str(repo), "git_repo_root": str(repo)}, + ] + monkeypatch.setattr("hermes_state.SessionDB", lambda: _WorkspaceAwareDB(rows)) + assert _resolve_last_session("cli") == "cli_older" + + +def test_search_sessions_workspace_key_filters_at_sql_level(tmp_path, monkeypatch): + # End-to-end: search_sessions(workspace_key=...) must filter by + # git_repo_root, and fall back to cwd-prefix for rows without one. + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + monkeypatch.setattr("pathlib.Path.home", lambda: tmp_path) + + import hermes_state + from pathlib import Path + + db = hermes_state.SessionDB(db_path=Path(tmp_path / "state.db")) + try: + # repo A: two sessions — one with git_repo_root, one legacy (cwd only). + db.create_session("repo_a_rooted", source="cli", cwd=str(tmp_path / "repo-a"), + git_repo_root=str(tmp_path / "repo-a")) + db.create_session("repo_a_legacy", source="cli", cwd=str(tmp_path / "repo-a" / "src")) + # repo B: one session, globally most recent. + db.create_session("repo_b", source="cli", cwd=str(tmp_path / "repo-b"), + git_repo_root=str(tmp_path / "repo-b")) + with db._lock: + db._conn.execute("UPDATE sessions SET started_at=? WHERE id=?", (100.0, "repo_a_rooted")) + db._conn.execute("UPDATE sessions SET started_at=? WHERE id=?", (50.0, "repo_a_legacy")) + db._conn.execute("UPDATE sessions SET started_at=? WHERE id=?", (9000.0, "repo_b")) + db._conn.commit() + + key = str(tmp_path / "repo-a") + rows = db.search_sessions(source="cli", limit=10, workspace_key=key) + ids = sorted(r["id"] for r in rows) + assert ids == ["repo_a_legacy", "repo_a_rooted"] + assert "repo_b" not in ids + + # No workspace_key → global MRU includes repo_b. + all_rows = db.search_sessions(source="cli", limit=10) + assert all_rows[0]["id"] == "repo_b" + finally: + db.close() + + +def test_resolve_last_session_real_db_prefers_workspace(monkeypatch, tmp_path): + # End-to-end through the real SessionDB + _resolve_last_session: -c from + # repo A picks repo A's session even though repo B is globally newer. + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + monkeypatch.setattr("pathlib.Path.home", lambda: tmp_path) + + import hermes_state + from pathlib import Path + + repo_a = tmp_path / "repo-a" + repo_a.mkdir() + state_db = Path(tmp_path / "state.db") + real_db = hermes_state.SessionDB + db = real_db(db_path=state_db) + try: + db.create_session("repo_a", source="cli", cwd=str(repo_a), git_repo_root=str(repo_a)) + db.create_session("repo_b", source="cli", cwd="/other/repo-b", git_repo_root="/other/repo-b") + with db._lock: + db._conn.execute("UPDATE sessions SET started_at=? WHERE id=?", (100.0, "repo_a")) + db._conn.execute("UPDATE sessions SET started_at=? WHERE id=?", (9000.0, "repo_b")) + db._conn.commit() + finally: + db.close() + + monkeypatch.chdir(repo_a) + monkeypatch.setattr( + "hermes_cli.main.subprocess.run", + lambda cmd, **kw: __import__("subprocess").CompletedProcess( + cmd, 0, stdout=str(repo_a), stderr="" + ), + ) + monkeypatch.setattr("hermes_state.SessionDB", lambda: real_db(db_path=state_db)) + assert _resolve_last_session("cli") == "repo_a"