Session workspace binding, grouping, and cwd restore

NousResearch/hermes-agent · cluster 42707 · apps/desktop

Data as of

2 open items. Fix merged in #63091. Check it covers your case.

The short answer

2open issues
0open PRs
5items grouped
Known?
Yes. 5 related items grouped here, 1 issue already closed.
Fix event
A fix was merged to main in PR #63091. Check it covers your case. No release data is tracked.
Tracker
The maintainers' tracker marks this unresolved. That is separate from the fix event.
Still open
2 issues and 0 PRs.

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.

Open issues2

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

Open PRs0

No open pull requests.

Fixes on record

Merged to main. A stamp counts the issues a fix is linked to.

  • #63091 feat(sessions): CLI workspace filter + restore-cwd-on-resume (supersedes #48591)

    merged to main

    files: hermes_cli/_parser.py, hermes_cli/main.py, hermes_state.py, scripts/release.py, tests/test_session_workspace_binding.py

    Show the diff
    • 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",
    • 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 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(
      @@ -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
      @@ -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
      @@ -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
      @@ -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"

1 PR closed without merging

From the close comments: superseded by another PR 1

  • #48591 feat(sessions): workspace binding — capture git remote/branch, group + filter, restore cwd on resume (#48190) · superseded by another PR → #63091
Closed issues · 1
  • Closed#42707Desktop session grouping scatters sessions across multiple workspace groups based on unstable cwd
How this cluster was grouped
  • Link evidence (partly structural links): 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 item 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.

Pick up a Lab task

Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.

Open the Lab

At a glance

Subsystem
apps/desktop
Merged PRs
#63091
Closed issues
1
Attempts
1 PR closed without merging
Fix link checked
12 Jul 2026

For your agent

The same facts, typed.