Merge pull request #63091 from NousResearch/bb/salvage-48591-workspace-binding

feat(sessions): CLI workspace filter + restore-cwd-on-resume (supersedes #48591)
This commit is contained in:
brooklyn! 2026-07-12 04:14:28 -05:00 committed by GitHub
commit b4829643d6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 141 additions and 0 deletions

View file

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

View file

@ -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)

View file

@ -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')"

View file

@ -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)

View file

@ -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"