mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-20 15:33:54 +00:00
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:
commit
b4829643d6
5 changed files with 141 additions and 0 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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')"
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
38
tests/test_session_workspace_binding.py
Normal file
38
tests/test_session_workspace_binding.py
Normal 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"
|
||||
Loading…
Add table
Add a link
Reference in a new issue