diff --git a/hermes_cli/_parser.py b/hermes_cli/_parser.py index 9cc3c2779006..a4027221de4a 100644 --- a/hermes_cli/_parser.py +++ b/hermes_cli/_parser.py @@ -160,6 +160,12 @@ def build_top_level_parser(): default=None, help="Resume a previous session by ID or title", ) + parser.add_argument( + "--no-restore-cwd", + action="store_true", + default=False, + help="Don't cd into a resumed session's recorded working directory.", + ) parser.add_argument( "--continue", "-c", @@ -331,6 +337,12 @@ def build_top_level_parser(): default=argparse.SUPPRESS, help="Resume a previous session by ID (shown on exit)", ) + chat_parser.add_argument( + "--no-restore-cwd", + action="store_true", + default=argparse.SUPPRESS, + help="Don't cd into a resumed session's recorded working directory.", + ) chat_parser.add_argument( "--continue", "-c", diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 2dc6f05e1b4f..f10fef3fd958 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -2286,6 +2286,27 @@ def cmd_chat(args): # If resolution fails, keep the original value — _init_agent will # report "Session not found" with the original input + # Session<->workspace binding: cd back into a resumed session's recorded cwd + # so it resumes in the repo it belonged to. Opt out with --no-restore-cwd; + # skipped under --worktree (that path owns its own dir). Best-effort — a + # missing dir warns and stays put rather than failing the resume. + if ( + getattr(args, "resume", None) + and not getattr(args, "no_restore_cwd", False) + and not getattr(args, "worktree", False) + ): + try: + from hermes_state import SessionDB + + _saved_cwd = ((SessionDB().get_session(args.resume) or {}).get("cwd") or "").strip() + if _saved_cwd and not os.path.isdir(_saved_cwd): + print(f"⚠ session's recorded dir is gone ({_saved_cwd}); staying in {os.getcwd()}") + elif _saved_cwd and os.path.realpath(_saved_cwd) != os.path.realpath(os.getcwd()): + os.chdir(_saved_cwd) + print(f"↪ restored workspace dir: {_saved_cwd}") + except Exception: + pass # never let cwd-restore break a resume + # xAI retirement warning — one-shot, non-blocking, never fails startup try: from hermes_cli.xai_retirement import ( @@ -13514,6 +13535,12 @@ def main(): sessions_list.add_argument( "--limit", type=int, default=20, help="Max sessions to show" ) + sessions_list.add_argument( + "--workspace", + metavar="NEEDLE", + help="Only sessions in one workspace: a git repo root or project dir " + "(matched by path substring or basename).", + ) def _add_session_filter_args(p, default_older_help): p.add_argument( @@ -13840,13 +13867,58 @@ def main(): _exclude = None if _source else ["tool"] if action == "list": + from hermes_state import workspace_key as _ws_key + sessions = db.list_sessions_rich( source=args.source, exclude_sources=_exclude, limit=args.limit ) + + # Workspace filter: match a session by its workspace key (git repo + # root, else cwd) — path substring or exact basename. + _ws_filter = (getattr(args, "workspace", None) or "").strip() + if _ws_filter: + _needle = _ws_filter.lower() + + def _in_workspace(s): + key = (_ws_key(s) or "").lower() + return bool(key) and ( + _needle in key or _needle == os.path.basename(key.rstrip("/\\")) + ) + + sessions = [s for s in sessions if _in_workspace(s)] + if not sessions: print("No sessions found.") return + + # Short workspace label: the repo/dir basename, "—" when unbound. The + # Workspace column only appears once at least one session carries one + # (or when filtering), so all-unbound listings read as before. + def _ws_label(s): + key = _ws_key(s) + return (os.path.basename(key.rstrip("/\\")) or key) if key else "—" + + has_ws = bool(_ws_filter) or any(_ws_key(s) for s in sessions) has_titles = any(s.get("title") for s in sessions) + + if has_ws: + if has_titles: + print(f"{'Title':<28} {'Workspace':<18} {'Last Active':<13} {'ID'}") + print("─" * 110) + else: + print(f"{'Preview':<38} {'Workspace':<18} {'Last Active':<13} {'Src':<6} {'ID'}") + print("─" * 100) + for s in sessions: + last_active = _relative_time(s.get("last_active")) + ws = _ws_label(s)[:16] + if has_titles: + title = (s.get("title") or "—")[:26] + print(f"{title:<28} {ws:<18} {last_active:<13} {s['id']}") + else: + preview = s.get("preview", "")[:36] + print(f"{preview:<38} {ws:<18} {last_active:<13} {s['source']:<6} {s['id']}") + return + if has_titles: print(f"{'Title':<32} {'Preview':<40} {'Last Active':<13} {'ID'}") print("─" * 110) diff --git a/hermes_state.py b/hermes_state.py index ff53abe1be05..80ab6dc10115 100644 --- a/hermes_state.py +++ b/hermes_state.py @@ -31,6 +31,24 @@ from typing import Any, Callable, Dict, List, Optional, Tuple, TypeVar logger = logging.getLogger(__name__) + +def workspace_key(row: Dict[str, Any]) -> Optional[str]: + """A session's workspace grouping key: its git repo root when known, else + its cwd. + + Branch is deliberately excluded so checking out a new branch doesn't + fragment a workspace's session history. Returns None for cwd-less (unbound) + sessions. Both fields are already recorded on ``sessions`` — this just picks + the coarser identity for grouping/filtering. + """ + root = (row.get("git_repo_root") or "").strip() + if root: + return root + + cwd = (row.get("cwd") or "").strip() + return cwd or None + + def _delegate_from_json(col: str = "model_config") -> str: return f"json_extract(COALESCE({col}, '{{}}'), '$._delegate_from')" diff --git a/scripts/release.py b/scripts/release.py index 2c308eb5e62a..89b4da4d7493 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -45,6 +45,7 @@ ACP_REGISTRY_MANIFEST = REPO_ROOT / "acp_registry" / "agent.json" # Auto-extracted from noreply emails + manual overrides AUTHOR_MAP = { + "palmer@dugoutfantasy.com": "professorpalmer", # PR #48591 salvage (sessions: CLI workspace filter + restore-cwd-on-resume) "true@supersynergy.de": "Supersynergy", # PR #59241 salvage (desktop: workspace path status-bar action) "esthon@gmail.com": "esthonjr", # PR #61950 salvage (desktop: legacy non-git workspace grouping + Windows path identity) "iganapolsky@gmail.com": "IgorGanapolsky", # PR #62125 salvage (compaction anti-thrash threshold verification) diff --git a/tests/test_session_workspace_binding.py b/tests/test_session_workspace_binding.py new file mode 100644 index 000000000000..36e7f2ec3db9 --- /dev/null +++ b/tests/test_session_workspace_binding.py @@ -0,0 +1,38 @@ +"""Session <-> workspace grouping key (hermes_state.workspace_key). + +The key is what `hermes sessions list --workspace` groups/filters on. It is a +coarse workspace identity derived from fields already recorded on sessions +(git_repo_root, cwd) — no git shelling, no new columns. Branch is deliberately +NOT part of the key. +""" + +from hermes_state import workspace_key + + +def test_repo_root_is_the_key_when_known(): + row = {"git_repo_root": "/www/app", "cwd": "/www/app/src", "git_branch": "feat"} + assert workspace_key(row) == "/www/app" + + +def test_falls_back_to_cwd_for_non_git_sessions(): + assert workspace_key({"cwd": "/work/notes"}) == "/work/notes" + assert workspace_key({"git_repo_root": "", "cwd": "/work/notes"}) == "/work/notes" + + +def test_none_when_unbound(): + assert workspace_key({}) is None + assert workspace_key({"cwd": "", "git_repo_root": ""}) is None + assert workspace_key({"cwd": " "}) is None + + +def test_branch_does_not_affect_the_key(): + # Two sessions on the same repo, different branches, group together. + a = {"git_repo_root": "/www/app", "git_branch": "main"} + b = {"git_repo_root": "/www/app", "git_branch": "feature-x"} + assert workspace_key(a) == workspace_key(b) == "/www/app" + + +def test_repo_root_wins_over_a_differing_cwd(): + # A worktree/subdir session still groups under its repo root, not its cwd. + row = {"git_repo_root": "/www/app", "cwd": "/www/app/.worktrees/x"} + assert workspace_key(row) == "/www/app"