# NousResearch/hermes-agent · Session workspace binding, grouping, and cwd restore

## Open now

- 2 issues, 0 PRs open on GitHub.
- #48190: Session ↔ Workspace binding: record cwd + repo, group in list/browse, restore on resume
- #48782: WebUI: CLI sessions imported from state.db ignore original cwd, always use get_last_workspace()

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.

## TL;DR

- 5 member(s): 2 open issue(s), 1 closed issue(s), 0 open PR(s), 1 merged PR(s), 1 PR(s) closed without merging.
- A fix was merged to main in PR #63091. Verify it addresses your case.
- **Recommended:** read PR #63091 (and any competing PRs below) before filing. Confirm the merged change covers your symptom.

## Maintainer resolution verdict

- Internal-tracker verdict over this cluster's issue members: `unresolved`. This is distinct from the fix status above. Weigh it yourself.

## How this cluster was grouped

- Link evidence (`STRUCTURAL_PARTIAL`): some members are connected by structural evidence (shared changed files, or a closing/superseded reference); the rest are joined by an automated relevance judgement over their text.
- Members outside that structural core may belong to a different underlying problem.
- Pairwise link density `0.27`: the share of member pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36. `1.00` means every pair is linked.

## Open related issues

- #48190: Session ↔ Workspace binding: record cwd + repo, group in list/browse, restore on resume
- #48782: WebUI: CLI sessions imported from state.db ignore original cwd, always use get_last_workspace()

## Closed / duplicate issues

- #42707: Desktop session grouping scatters sessions across multiple workspace groups based on unstable cwd

## Prior & competing PRs

- **#63091: feat(sessions): CLI workspace filter + restore-cwd-on-resume (supersedes #48591)**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`; implication `superseded`
  - files: `hermes_cli/_parser.py`, `hermes_cli/main.py`, `hermes_state.py`, `scripts/release.py`, `tests/test_session_workspace_binding.py`
  - fix hunks:
  - `hermes_cli/_parser.py`

    ```diff
    @@ -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",
    ```

    ```diff
    @@ -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",
    ```
  - `hermes_cli/main.py`

    ```diff
    @@ -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 (
    ```

    ```diff
    @@ -13514,6 +13535,12 @@ def cmd_computer_use(args):
         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(
    ```

    ```diff
    @@ -13840,13 +13867,58 @@ def cmd_sessions(args):
             _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)
    ```
  - `hermes_state.py`

    ```diff
    @@ -31,6 +31,24 @@
     
     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')"
     
    ```
  - `scripts/release.py`

    ```diff
    @@ -45,6 +45,7 @@
     
     # Auto-extracted from noreply emails + manual overrides
     AUTHOR_MAP = {
    +    "[redacted:email]": "professorpalmer",  # PR #48591 salvage (sessions: CLI workspace filter + restore-cwd-on-resume)
         "[redacted:email]": "Supersynergy",  # PR #59241 salvage (desktop: workspace path status-bar action)
         "[redacted:email]": "esthonjr",  # PR #61950 salvage (desktop: legacy non-git workspace grouping + Windows path identity)
         "[redacted:email]": "IgorGanapolsky",  # PR #62125 salvage (compaction anti-thrash threshold verification)
    ```
  - `tests/test_session_workspace_binding.py`

    ```diff
    @@ -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"
    ```
- **#48591: feat(sessions): workspace binding — capture git remote/branch, group + filter, restore cwd on resume (#48190)**. closed without merge
  - maintainer direction: category `superseded_by_pr`; implication `superseded`; refs `#63091`

## Fix status

- Merged to main in PR #63091 (cited above). No release data is tracked. Verify downstream.
- Fix last verified (merged) `2026-07-12`.
- Subsystem: `apps/desktop`
