{"repo":"NousResearch/hermes-agent","public_id":"42707","display_label":"Session workspace binding, grouping, and cwd restore","summary":"These issues and PRs concern capturing working directory and git context for sessions, grouping sessions by workspace, filtering via CLI, and restoring cwd on resume. A recurring concern is unstable or incorrect workspace assignment for CLI-imported and desktop sessions.","subsystem":"apps/desktop","counts":{"members":5,"open_issues":2,"open_issues_distinct":2,"open_prs":0,"closed_issues":1,"merged_prs":1,"attempt_prs":1},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":63091},"resolution":{"state":"unresolved","by":null},"freshness":{"state":"verified","last_verified_at":"2026-07-12T09:14:29+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_PARTIAL","link_density":0.27199999999999996},"fix_edges":[{"issue_number":48190,"pr_number":63091,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false}],"members":[{"kind":"issue","number":42707,"title":"Desktop session grouping scatters sessions across multiple workspace groups based on unstable cwd","url":null,"state":"closed"},{"kind":"issue","number":48190,"title":"Session ↔ Workspace binding: record cwd + repo, group in list/browse, restore on resume","url":null,"state":"open"},{"kind":"issue","number":48782,"title":"WebUI: CLI sessions imported from state.db ignore original cwd, always use get_last_workspace()","url":null,"state":"open"},{"kind":"pr","number":48591,"title":"feat(sessions): workspace binding — capture git remote/branch, group + filter, restore cwd on resume (#48190)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#63091","number":63091}],"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"kind":"pr","number":63091,"title":"feat(sessions): CLI workspace filter + restore-cwd-on-resume (supersedes #48591)","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["hermes_cli/_parser.py","hermes_cli/main.py","hermes_state.py","scripts/release.py","tests/test_session_workspace_binding.py"],"fix_hunks":[{"file":"hermes_cli/_parser.py","hunks":["@@ -160,6 +160,12 @@ def build_top_level_parser():\n         default=None,\n         help=\"Resume a previous session by ID or title\",\n     )\n+    parser.add_argument(\n+        \"--no-restore-cwd\",\n+        action=\"store_true\",\n+        default=False,\n+        help=\"Don't cd into a resumed session's recorded working directory.\",\n+    )\n     parser.add_argument(\n         \"--continue\",\n         \"-c\",","@@ -331,6 +337,12 @@ def build_top_level_parser():\n         default=argparse.SUPPRESS,\n         help=\"Resume a previous session by ID (shown on exit)\",\n     )\n+    chat_parser.add_argument(\n+        \"--no-restore-cwd\",\n+        action=\"store_true\",\n+        default=argparse.SUPPRESS,\n+        help=\"Don't cd into a resumed session's recorded working directory.\",\n+    )\n     chat_parser.add_argument(\n         \"--continue\",\n         \"-c\","]},{"file":"hermes_cli/main.py","hunks":["@@ -2286,6 +2286,27 @@ def cmd_chat(args):\n         # If resolution fails, keep the original value — _init_agent will\n         # report \"Session not found\" with the original input\n \n+    # Session<->workspace binding: cd back into a resumed session's recorded cwd\n+    # so it resumes in the repo it belonged to. Opt out with --no-restore-cwd;\n+    # skipped under --worktree (that path owns its own dir). Best-effort — a\n+    # missing dir warns and stays put rather than failing the resume.\n+    if (\n+        getattr(args, \"resume\", None)\n+        and not getattr(args, \"no_restore_cwd\", False)\n+        and not getattr(args, \"worktree\", False)\n+    ):\n+        try:\n+            from hermes_state import SessionDB\n+\n+            _saved_cwd = ((SessionDB().get_session(args.resume) or {}).get(\"cwd\") or \"\").strip()\n+            if _saved_cwd and not os.path.isdir(_saved_cwd):\n+                print(f\"⚠ session's recorded dir is gone ({_saved_cwd}); staying in {os.getcwd()}\")\n+            elif _saved_cwd and os.path.realpath(_saved_cwd) != os.path.realpath(os.getcwd()):\n+                os.chdir(_saved_cwd)\n+                print(f\"↪ restored workspace dir: {_saved_cwd}\")\n+        except Exception:\n+            pass  # never let cwd-restore break a resume\n+\n     # xAI retirement warning — one-shot, non-blocking, never fails startup\n     try:\n         from hermes_cli.xai_retirement import (","@@ -13514,6 +13535,12 @@ def cmd_computer_use(args):\n     sessions_list.add_argument(\n         \"--limit\", type=int, default=20, help=\"Max sessions to show\"\n     )\n+    sessions_list.add_argument(\n+        \"--workspace\",\n+        metavar=\"NEEDLE\",\n+        help=\"Only sessions in one workspace: a git repo root or project dir \"\n+        \"(matched by path substring or basename).\",\n+    )\n \n     def _add_session_filter_args(p, default_older_help):\n         p.add_argument(","@@ -13840,13 +13867,58 @@ def cmd_sessions(args):\n         _exclude = None if _source else [\"tool\"]\n \n         if action == \"list\":\n+            from hermes_state import workspace_key as _ws_key\n+\n             sessions = db.list_sessions_rich(\n                 source=args.source, exclude_sources=_exclude, limit=args.limit\n             )\n+\n+            # Workspace filter: match a session by its workspace key (git repo\n+            # root, else cwd) — path substring or exact basename.\n+            _ws_filter = (getattr(args, \"workspace\", None) or \"\").strip()\n+            if _ws_filter:\n+                _needle = _ws_filter.lower()\n+\n+                def _in_workspace(s):\n+                    key = (_ws_key(s) or \"\").lower()\n+                    return bool(key) and (\n+                        _needle in key or _needle == os.path.basename(key.rstrip(\"/\\\\\"))\n+                    )\n+\n+                sessions = [s for s in sessions if _in_workspace(s)]\n+\n             if not sessions:\n                 print(\"No sessions found.\")\n                 return\n+\n+            # Short workspace label: the repo/dir basename, \"—\" when unbound. The\n+            # Workspace column only appears once at least one session carries one\n+            # (or when filtering), so all-unbound listings read as before.\n+            def _ws_label(s):\n+                key = _ws_key(s)\n+                return (os.path.basename(key.rstrip(\"/\\\\\")) or key) if key else \"—\"\n+\n+            has_ws = bool(_ws_filter) or any(_ws_key(s) for s in sessions)\n             has_titles = any(s.get(\"title\") for s in sessions)\n+\n+            if has_ws:\n+                if has_titles:\n+                    print(f\"{'Title':<28} {'Workspace':<18} {'Last Active':<13} {'ID'}\")\n+                    print(\"─\" * 110)\n+                else:\n+                    print(f\"{'Preview':<38} {'Workspace':<18} {'Last Active':<13} {'Src':<6} {'ID'}\")\n+                    print(\"─\" * 100)\n+                for s in sessions:\n+                    last_active = _relative_time(s.get(\"last_active\"))\n+                    ws = _ws_label(s)[:16]\n+                    if has_titles:\n+                        title = (s.get(\"title\") or \"—\")[:26]\n+                        print(f\"{title:<28} {ws:<18} {last_active:<13} {s['id']}\")\n+                    else:\n+                        preview = s.get(\"preview\", \"\")[:36]\n+                        print(f\"{preview:<38} {ws:<18} {last_active:<13} {s['source']:<6} {s['id']}\")\n+                return\n+\n             if has_titles:\n                 print(f\"{'Title':<32} {'Preview':<40} {'Last Active':<13} {'ID'}\")\n                 print(\"─\" * 110)"]},{"file":"hermes_state.py","hunks":["@@ -31,6 +31,24 @@\n \n logger = logging.getLogger(__name__)\n \n+\n+def workspace_key(row: Dict[str, Any]) -> Optional[str]:\n+    \"\"\"A session's workspace grouping key: its git repo root when known, else\n+    its cwd.\n+\n+    Branch is deliberately excluded so checking out a new branch doesn't\n+    fragment a workspace's session history. Returns None for cwd-less (unbound)\n+    sessions. Both fields are already recorded on ``sessions`` — this just picks\n+    the coarser identity for grouping/filtering.\n+    \"\"\"\n+    root = (row.get(\"git_repo_root\") or \"\").strip()\n+    if root:\n+        return root\n+\n+    cwd = (row.get(\"cwd\") or \"\").strip()\n+    return cwd or None\n+\n+\n def _delegate_from_json(col: str = \"model_config\") -> str:\n     return f\"json_extract(COALESCE({col}, '{{}}'), '$._delegate_from')\"\n "]},{"file":"scripts/release.py","hunks":["@@ -45,6 +45,7 @@\n \n # Auto-extracted from noreply emails + manual overrides\n AUTHOR_MAP = {\n+    \"[redacted:email]\": \"professorpalmer\",  # PR #48591 salvage (sessions: CLI workspace filter + restore-cwd-on-resume)\n     \"[redacted:email]\": \"Supersynergy\",  # PR #59241 salvage (desktop: workspace path status-bar action)\n     \"[redacted:email]\": \"esthonjr\",  # PR #61950 salvage (desktop: legacy non-git workspace grouping + Windows path identity)\n     \"[redacted:email]\": \"IgorGanapolsky\",  # PR #62125 salvage (compaction anti-thrash threshold verification)"]},{"file":"tests/test_session_workspace_binding.py","hunks":["@@ -0,0 +1,38 @@\n+\"\"\"Session <-> workspace grouping key (hermes_state.workspace_key).\n+\n+The key is what `hermes sessions list --workspace` groups/filters on. It is a\n+coarse workspace identity derived from fields already recorded on sessions\n+(git_repo_root, cwd) — no git shelling, no new columns. Branch is deliberately\n+NOT part of the key.\n+\"\"\"\n+\n+from hermes_state import workspace_key\n+\n+\n+def test_repo_root_is_the_key_when_known():\n+    row = {\"git_repo_root\": \"/www/app\", \"cwd\": \"/www/app/src\", \"git_branch\": \"feat\"}\n+    assert workspace_key(row) == \"/www/app\"\n+\n+\n+def test_falls_back_to_cwd_for_non_git_sessions():\n+    assert workspace_key({\"cwd\": \"/work/notes\"}) == \"/work/notes\"\n+    assert workspace_key({\"git_repo_root\": \"\", \"cwd\": \"/work/notes\"}) == \"/work/notes\"\n+\n+\n+def test_none_when_unbound():\n+    assert workspace_key({}) is None\n+    assert workspace_key({\"cwd\": \"\", \"git_repo_root\": \"\"}) is None\n+    assert workspace_key({\"cwd\": \"   \"}) is None\n+\n+\n+def test_branch_does_not_affect_the_key():\n+    # Two sessions on the same repo, different branches, group together.\n+    a = {\"git_repo_root\": \"/www/app\", \"git_branch\": \"main\"}\n+    b = {\"git_repo_root\": \"/www/app\", \"git_branch\": \"feature-x\"}\n+    assert workspace_key(a) == workspace_key(b) == \"/www/app\"\n+\n+\n+def test_repo_root_wins_over_a_differing_cwd():\n+    # A worktree/subdir session still groups under its repo root, not its cwd.\n+    row = {\"git_repo_root\": \"/www/app\", \"cwd\": \"/www/app/.worktrees/x\"}\n+    assert workspace_key(row) == \"/www/app\"\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}