diff --git a/hermes_cli/_parser.py b/hermes_cli/_parser.py index 521c5fcf91de..db8ca3d2ba2c 100644 --- a/hermes_cli/_parser.py +++ b/hermes_cli/_parser.py @@ -165,6 +165,12 @@ def build_top_level_parser(): default=False, help="Run in an isolated git worktree (for parallel agents)", ) + parser.add_argument( + "--no-restore-cwd", + action="store_true", + default=False, + help="Do not cd into a resumed session's recorded working directory.", + ) _inherited_flag( parser, "--accept-hooks", @@ -321,6 +327,12 @@ def build_top_level_parser(): default=argparse.SUPPRESS, help="Run in an isolated git worktree (for parallel agents on the same repo)", ) + chat_parser.add_argument( + "--no-restore-cwd", + action="store_true", + default=argparse.SUPPRESS, + help="Do not cd into a resumed session's recorded working directory.", + ) _inherited_flag( chat_parser, "--accept-hooks", diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 1e5ef8fc3eab..dda8e4cb1cd9 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -2170,6 +2170,29 @@ def cmd_chat(args): # If resolution fails, keep the original value — _init_agent will # report "Session not found" with the original input + # Workspace restore: cd back into the resumed session's recorded cwd so a + # session resumes in the repo it belonged to (the session<->workspace bind). + # Opt out with --no-restore-cwd. Best-effort: a missing dir warns and falls + # back to the current dir rather than failing the resume. + if getattr(args, "resume", None) and not getattr(args, "no_restore_cwd", False): + try: + from hermes_state import SessionDB as _SessionDB + _row = _SessionDB().get_session(args.resume) + _saved_cwd = (_row or {}).get("cwd") + if _saved_cwd and not getattr(args, "worktree", False): + if os.path.isdir(_saved_cwd): + if os.path.realpath(_saved_cwd) != os.path.realpath(os.getcwd()): + os.chdir(_saved_cwd) + print(f"↪ restored workspace dir: {_saved_cwd}") + else: + sys.stderr.write( + f"⚠ session's recorded dir is gone ({_saved_cwd}); " + f"staying in {os.getcwd()}\n" + ) + except Exception: + # Never let cwd-restore break a resume. + pass + # xAI retirement warning — one-shot, non-blocking, never fails startup try: from hermes_cli.xai_retirement import ( @@ -12056,6 +12079,11 @@ def cmd_computer_use(args): sessions_list.add_argument( "--limit", type=int, default=20, help="Max sessions to show" ) + sessions_list.add_argument( + "--workspace", + help="Filter to one workspace: a git remote (e.g. 'owner/repo') or a " + "project directory. Adds a Workspace column to the listing.", + ) sessions_export = sessions_subparsers.add_parser( "export", help="Export sessions to a JSONL file" @@ -12201,33 +12229,60 @@ def cmd_sessions(args): _exclude = None if _source else ["tool"] if action == "list": + _workspace = getattr(args, "workspace", None) sessions = db.list_sessions_rich( - source=args.source, exclude_sources=_exclude, limit=args.limit + source=args.source, exclude_sources=_exclude, limit=args.limit, + workspace=_workspace, ) if not sessions: print("No sessions found.") return + from hermes_state import workspace_key as _ws_key has_titles = any(s.get("title") for s in sessions) + # Short workspace label: repo basename (from remote or cwd), so the + # column stays narrow. Falls back to "—" for unbound sessions. + def _ws_label(s): + key = _ws_key(s) + if not key: + return "—" + base = key.rstrip("/").rsplit("/", 1)[-1] + br = s.get("git_branch") + return f"{base}@{br}" if br else base + has_ws = any(_ws_key(s) for s in sessions) if has_titles: - print(f"{'Title':<32} {'Preview':<40} {'Last Active':<13} {'ID'}") - print("─" * 110) + if has_ws: + print(f"{'Title':<26} {'Workspace':<22} {'Last Active':<13} {'ID'}") + print("─" * 110) + else: + print(f"{'Title':<32} {'Preview':<40} {'Last Active':<13} {'ID'}") + print("─" * 110) else: - print(f"{'Preview':<50} {'Last Active':<13} {'Src':<6} {'ID'}") - print("─" * 95) + if has_ws: + print(f"{'Preview':<40} {'Workspace':<22} {'Last Active':<13} {'ID'}") + print("─" * 100) + else: + print(f"{'Preview':<50} {'Last Active':<13} {'Src':<6} {'ID'}") + print("─" * 95) for s in sessions: last_active = _relative_time(s.get("last_active")) - preview = ( - s.get("preview", "")[:38] - if has_titles - else s.get("preview", "")[:48] - ) + sid = s["id"] if has_titles: - title = (s.get("title") or "—")[:30] - sid = s["id"] - print(f"{title:<32} {preview:<40} {last_active:<13} {sid}") + if has_ws: + title = (s.get("title") or "—")[:24] + ws = _ws_label(s)[:20] + print(f"{title:<26} {ws:<22} {last_active:<13} {sid}") + else: + title = (s.get("title") or "—")[:30] + preview = s.get("preview", "")[:38] + print(f"{title:<32} {preview:<40} {last_active:<13} {sid}") else: - sid = s["id"] - print(f"{preview:<50} {last_active:<13} {s['source']:<6} {sid}") + if has_ws: + preview = s.get("preview", "")[:38] + ws = _ws_label(s)[:20] + print(f"{preview:<40} {ws:<22} {last_active:<13} {sid}") + else: + preview = s.get("preview", "")[:48] + print(f"{preview:<50} {last_active:<13} {s['source']:<6} {sid}") elif action == "export": if args.session_id: diff --git a/hermes_state.py b/hermes_state.py index 9653eae017f7..e115813f9979 100644 --- a/hermes_state.py +++ b/hermes_state.py @@ -29,6 +29,76 @@ logger = logging.getLogger(__name__) + +def _git_value(cwd: str, args: list) -> Optional[str]: + """Run a git command in ``cwd`` and return stripped stdout, or None. + + Best-effort and never fatal: any failure (not a repo, git missing, detached + HEAD, timeout) yields None so session creation can't be broken by git. + """ + if not cwd: + return None + import subprocess + try: + out = subprocess.run( + ["git", "-C", str(cwd), *args], + capture_output=True, text=True, timeout=3, + ) + except (OSError, subprocess.SubprocessError): + return None + if out.returncode != 0: + return None + val = (out.stdout or "").strip() + return val or None + + +def _normalize_git_remote(url: Optional[str]) -> Optional[str]: + """Normalize an origin URL into a stable host/owner/repo key. + + Strips credentials, scheme, and the trailing .git so HTTPS and SSH clones of + the same repo collapse to one workspace key + (e.g. ``github.com/owner/repo``). Returns None when there's no remote. + """ + if not url: + return None + u = url.strip() + # scp-style: git@host:owner/repo.git -> host/owner/repo + m = re.match(r"^[^@]+@([^:]+):(.+)$", u) + if m: + u = f"{m.group(1)}/{m.group(2)}" + else: + u = re.sub(r"^[a-zA-Z][a-zA-Z0-9+.-]*://", "", u) # drop scheme + u = re.sub(r"^[^@/]+@", "", u) # drop user[:pass]@ + u = re.sub(r"\.git/?$", "", u) + return u.strip("/") or None + + +def _detect_git_remote(cwd: str) -> Optional[str]: + """Normalized origin remote for ``cwd``'s repo, or None (best-effort).""" + return _normalize_git_remote(_git_value(cwd, ["remote", "get-url", "origin"])) + + +def _detect_git_branch(cwd: str) -> Optional[str]: + """Current branch for ``cwd``'s repo, or None (best-effort).""" + return _git_value(cwd, ["rev-parse", "--abbrev-ref", "HEAD"]) + + +def workspace_key(row: Dict[str, Any]) -> Optional[str]: + """Derive a session's workspace grouping key from a rich row. + + The key is the git remote when present (so the same repo cloned to + different paths groups together), else the cwd (so non-git project dirs + still group). Branch is deliberately NOT part of the key — it's a + per-session attribute, so checking out a new branch doesn't fragment a + workspace's session history. Returns None when neither is recorded + (pre-existing/unbound sessions). + """ + remote = row.get("git_remote") + if remote: + return str(remote) + cwd = row.get("cwd") + return str(cwd) if cwd else None + def _delegate_from_json(col: str = "model_config") -> str: return f"json_extract(COALESCE({col}, '{{}}'), '$._delegate_from')" @@ -530,6 +600,8 @@ def repair_state_db_schema(db_path: Path, *, backup: bool = True) -> Dict[str, A cache_write_tokens INTEGER DEFAULT 0, reasoning_tokens INTEGER DEFAULT 0, cwd TEXT, + git_remote TEXT, + git_branch TEXT, billing_provider TEXT, billing_base_url TEXT, billing_mode TEXT, @@ -1287,13 +1359,15 @@ def _insert_session_row( user_id: str = None, parent_session_id: str = None, cwd: str = None, + git_remote: str = None, + git_branch: str = None, ) -> None: """Shared INSERT OR IGNORE for session rows.""" def _do(conn): conn.execute( """INSERT OR IGNORE INTO sessions (id, source, user_id, model, model_config, - system_prompt, parent_session_id, cwd, started_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)""", + system_prompt, parent_session_id, cwd, git_remote, git_branch, started_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""", ( session_id, source, @@ -1303,13 +1377,31 @@ def _do(conn): system_prompt, parent_session_id, cwd, + git_remote, + git_branch, time.time(), ), ) self._execute_write(_do) def create_session(self, session_id: str, source: str, **kwargs) -> str: - """Create a new session record. Returns the session_id.""" + """Create a new session record. Returns the session_id. + + When a ``cwd`` is supplied and ``git_remote``/``git_branch`` are not + explicitly passed, they are auto-derived from that directory's git repo + (best-effort, never fatal). This is the session<->workspace binding: + the grouping key is ``(cwd, git_remote)`` and the branch is recorded as + a per-session attribute (see ``workspace_key``). Auto-derivation is + skipped for child sessions (subagents/compression) so worktree byproduct + doesn't get its own workspace identity. + """ + cwd = kwargs.get("cwd") + is_child = bool(kwargs.get("parent_session_id")) + if cwd and not is_child: + if kwargs.get("git_remote") is None: + kwargs["git_remote"] = _detect_git_remote(cwd) + if kwargs.get("git_branch") is None: + kwargs["git_branch"] = _detect_git_branch(cwd) self._insert_session_row(session_id, source, **kwargs) return session_id def end_session(self, session_id: str, end_reason: str) -> None: @@ -1990,6 +2082,7 @@ def list_sessions_rich( include_archived: bool = False, archived_only: bool = False, id_query: str = None, + workspace: str = None, ) -> List[Dict[str, Any]]: """List sessions with preview (first user message) and last active timestamp. @@ -2042,6 +2135,17 @@ def list_sessions_rich( if source: where_clauses.append("s.source = ?") params.append(source) + if workspace: + # Match the workspace grouping key: a session belongs to + # if its normalized git_remote equals it (or contains it, so + # "owner/repo" matches "github.com/owner/repo"), or — for non-git + # dirs — its cwd equals it. + where_clauses.append( + "(s.git_remote = ? OR s.git_remote LIKE ? ESCAPE '\\' OR s.cwd = ?)" + ) + ws = str(workspace) + like = "%" + ws.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_") + "%" + params.extend([ws, like, ws]) if exclude_sources: placeholders = ",".join("?" for _ in exclude_sources) where_clauses.append(f"s.source NOT IN ({placeholders})") diff --git a/tests/test_session_workspace_binding.py b/tests/test_session_workspace_binding.py new file mode 100644 index 000000000000..f0bdafe4529b --- /dev/null +++ b/tests/test_session_workspace_binding.py @@ -0,0 +1,125 @@ +"""E2E tests for session<->workspace binding (issue #48190). + +Exercises the real SessionDB against a temp HERMES_HOME: schema auto-migration +of the new columns, git remote/branch capture at create, the --workspace filter, +and the workspace_key grouping derivation. No mocks. +""" +import os +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parents[1] +if str(REPO_ROOT) not in sys.path: + sys.path.insert(0, str(REPO_ROOT)) + + +def _git(cwd, *args): + subprocess.run(["git", "-C", str(cwd), *args], check=True, + capture_output=True, text=True) + + +def _make_repo(path, remote_url, branch="main"): + path.mkdir(parents=True, exist_ok=True) + _git(path, "init", "-q") + _git(path, "config", "user.email", "t@e.local") + _git(path, "config", "user.name", "t") + _git(path, "remote", "add", "origin", remote_url) + (path / "f.txt").write_text("x") + _git(path, "add", "-A") + _git(path, "commit", "-qm", "init") + if branch != "main": + _git(path, "checkout", "-qb", branch) + + +class SessionWorkspaceBindingTest(unittest.TestCase): + def setUp(self): + self._tmp = tempfile.TemporaryDirectory() + self.home = Path(self._tmp.name) + os.environ["HERMES_HOME"] = str(self.home) + # Fresh import of the state module against this HERMES_HOME. + import importlib + import hermes_state + importlib.reload(hermes_state) + self.hs = hermes_state + self.db = hermes_state.SessionDB() + + def tearDown(self): + self._tmp.cleanup() + os.environ.pop("HERMES_HOME", None) + + def test_columns_migrated(self): + cols = {r[1] for r in self.db._conn.execute( + "PRAGMA table_info(sessions)").fetchall()} + self.assertIn("git_remote", cols) + self.assertIn("git_branch", cols) + self.assertIn("cwd", cols) # pre-existing, still present + + def test_capture_from_git_repo(self): + repo = self.home / "repoA" + _make_repo(repo, "https://github.com/owner/repoA.git", branch="feat/x") + self.db.create_session("s1", "cli", cwd=str(repo)) + row = self.db.get_session("s1") + # remote normalized: scheme + .git stripped + self.assertEqual(row["git_remote"], "github.com/owner/repoA") + self.assertEqual(row["git_branch"], "feat/x") + # workspace_key prefers the remote + self.assertEqual(self.hs.workspace_key(row), "github.com/owner/repoA") + + def test_ssh_remote_normalizes_same_as_https(self): + self.assertEqual( + self.hs._normalize_git_remote("git@github.com:owner/repo.git"), + self.hs._normalize_git_remote("https://github.com/owner/repo.git"), + ) + self.assertEqual( + self.hs._normalize_git_remote("git@github.com:owner/repo.git"), + "github.com/owner/repo", + ) + + def test_non_git_dir_falls_back_to_cwd_key(self): + plain = self.home / "plain" + plain.mkdir() + self.db.create_session("s2", "cli", cwd=str(plain)) + row = self.db.get_session("s2") + self.assertIsNone(row["git_remote"]) + self.assertEqual(self.hs.workspace_key(row), str(plain)) + + def test_child_session_not_bound(self): + repo = self.home / "repoB" + _make_repo(repo, "https://github.com/owner/repoB.git") + self.db.create_session("parent", "cli", cwd=str(repo)) + self.db.create_session( + "child", "cli", cwd=str(repo), parent_session_id="parent") + child = self.db.get_session("child") + # child sessions (subagents/compression) must not get workspace identity + self.assertIsNone(child["git_remote"]) + self.assertIsNone(child["git_branch"]) + + def test_workspace_filter(self): + repo_a = self.home / "wsA" + repo_b = self.home / "wsB" + _make_repo(repo_a, "https://github.com/owner/aaa.git") + _make_repo(repo_b, "https://github.com/owner/bbb.git") + self.db.create_session("a1", "cli", cwd=str(repo_a)) + self.db.create_session("b1", "cli", cwd=str(repo_b)) + # add a user message so rows surface in rich listing + for sid in ("a1", "b1"): + self.db.append_message(sid, "user", "hello") + only_a = self.db.list_sessions_rich(workspace="owner/aaa") + ids = {r["id"] for r in only_a} + self.assertIn("a1", ids) + self.assertNotIn("b1", ids) + + def test_backwards_compat_unbound_session(self): + # A session created with no cwd has null workspace fields and renders + # as unbound (workspace_key None) — no crash. + self.db.create_session("legacy", "cli") + row = self.db.get_session("legacy") + self.assertIsNone(row.get("git_remote")) + self.assertIsNone(self.hs.workspace_key(row)) + + +if __name__ == "__main__": + unittest.main()