Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions hermes_cli/_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,12 @@ def build_top_level_parser():
default=False,
help="Run in an isolated git worktree (for parallel agents)",
)
parser.add_argument(
"--no-restore-cwd",
action="store_true",
default=False,
help="Do not cd into a resumed session's recorded working directory.",
)
_inherited_flag(
parser,
"--accept-hooks",
Expand Down Expand Up @@ -321,6 +327,12 @@ def build_top_level_parser():
default=argparse.SUPPRESS,
help="Run in an isolated git worktree (for parallel agents on the same repo)",
)
chat_parser.add_argument(
"--no-restore-cwd",
action="store_true",
default=argparse.SUPPRESS,
help="Do not cd into a resumed session's recorded working directory.",
)
_inherited_flag(
chat_parser,
"--accept-hooks",
Expand Down
85 changes: 70 additions & 15 deletions hermes_cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -2170,6 +2170,29 @@ def cmd_chat(args):
# If resolution fails, keep the original value — _init_agent will
# report "Session not found" with the original input

# Workspace restore: cd back into the resumed session's recorded cwd so a
# session resumes in the repo it belonged to (the session<->workspace bind).
# Opt out with --no-restore-cwd. Best-effort: a missing dir warns and falls
# back to the current dir rather than failing the resume.
if getattr(args, "resume", None) and not getattr(args, "no_restore_cwd", False):
try:
from hermes_state import SessionDB as _SessionDB
_row = _SessionDB().get_session(args.resume)
_saved_cwd = (_row or {}).get("cwd")
if _saved_cwd and not getattr(args, "worktree", False):
if os.path.isdir(_saved_cwd):
if os.path.realpath(_saved_cwd) != os.path.realpath(os.getcwd()):
os.chdir(_saved_cwd)
print(f"↪ restored workspace dir: {_saved_cwd}")
else:
sys.stderr.write(
f"⚠ session's recorded dir is gone ({_saved_cwd}); "
f"staying in {os.getcwd()}\n"
)
except Exception:
# Never let cwd-restore break a resume.
pass

# xAI retirement warning — one-shot, non-blocking, never fails startup
try:
from hermes_cli.xai_retirement import (
Expand Down Expand Up @@ -12056,6 +12079,11 @@ def cmd_computer_use(args):
sessions_list.add_argument(
"--limit", type=int, default=20, help="Max sessions to show"
)
sessions_list.add_argument(
"--workspace",
help="Filter to one workspace: a git remote (e.g. 'owner/repo') or a "
"project directory. Adds a Workspace column to the listing.",
)

sessions_export = sessions_subparsers.add_parser(
"export", help="Export sessions to a JSONL file"
Expand Down Expand Up @@ -12201,33 +12229,60 @@ def cmd_sessions(args):
_exclude = None if _source else ["tool"]

if action == "list":
_workspace = getattr(args, "workspace", None)
sessions = db.list_sessions_rich(
source=args.source, exclude_sources=_exclude, limit=args.limit
source=args.source, exclude_sources=_exclude, limit=args.limit,
workspace=_workspace,
)
if not sessions:
print("No sessions found.")
return
from hermes_state import workspace_key as _ws_key
has_titles = any(s.get("title") for s in sessions)
# Short workspace label: repo basename (from remote or cwd), so the
# column stays narrow. Falls back to "—" for unbound sessions.
def _ws_label(s):
key = _ws_key(s)
if not key:
return "—"
base = key.rstrip("/").rsplit("/", 1)[-1]
br = s.get("git_branch")
return f"{base}@{br}" if br else base
has_ws = any(_ws_key(s) for s in sessions)
if has_titles:
print(f"{'Title':<32} {'Preview':<40} {'Last Active':<13} {'ID'}")
print("─" * 110)
if has_ws:
print(f"{'Title':<26} {'Workspace':<22} {'Last Active':<13} {'ID'}")
print("─" * 110)
else:
print(f"{'Title':<32} {'Preview':<40} {'Last Active':<13} {'ID'}")
print("─" * 110)
else:
print(f"{'Preview':<50} {'Last Active':<13} {'Src':<6} {'ID'}")
print("─" * 95)
if has_ws:
print(f"{'Preview':<40} {'Workspace':<22} {'Last Active':<13} {'ID'}")
print("─" * 100)
else:
print(f"{'Preview':<50} {'Last Active':<13} {'Src':<6} {'ID'}")
print("─" * 95)
for s in sessions:
last_active = _relative_time(s.get("last_active"))
preview = (
s.get("preview", "")[:38]
if has_titles
else s.get("preview", "")[:48]
)
sid = s["id"]
if has_titles:
title = (s.get("title") or "—")[:30]
sid = s["id"]
print(f"{title:<32} {preview:<40} {last_active:<13} {sid}")
if has_ws:
title = (s.get("title") or "—")[:24]
ws = _ws_label(s)[:20]
print(f"{title:<26} {ws:<22} {last_active:<13} {sid}")
else:
title = (s.get("title") or "—")[:30]
preview = s.get("preview", "")[:38]
print(f"{title:<32} {preview:<40} {last_active:<13} {sid}")
else:
sid = s["id"]
print(f"{preview:<50} {last_active:<13} {s['source']:<6} {sid}")
if has_ws:
preview = s.get("preview", "")[:38]
ws = _ws_label(s)[:20]
print(f"{preview:<40} {ws:<22} {last_active:<13} {sid}")
else:
preview = s.get("preview", "")[:48]
print(f"{preview:<50} {last_active:<13} {s['source']:<6} {sid}")

elif action == "export":
if args.session_id:
Expand Down
110 changes: 107 additions & 3 deletions hermes_state.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,76 @@

logger = logging.getLogger(__name__)


def _git_value(cwd: str, args: list) -> Optional[str]:
"""Run a git command in ``cwd`` and return stripped stdout, or None.

Best-effort and never fatal: any failure (not a repo, git missing, detached
HEAD, timeout) yields None so session creation can't be broken by git.
"""
if not cwd:
return None
import subprocess
try:
out = subprocess.run(
["git", "-C", str(cwd), *args],
capture_output=True, text=True, timeout=3,
)
except (OSError, subprocess.SubprocessError):
return None
if out.returncode != 0:
return None
val = (out.stdout or "").strip()
return val or None


def _normalize_git_remote(url: Optional[str]) -> Optional[str]:
"""Normalize an origin URL into a stable host/owner/repo key.

Strips credentials, scheme, and the trailing .git so HTTPS and SSH clones of
the same repo collapse to one workspace key
(e.g. ``github.com/owner/repo``). Returns None when there's no remote.
"""
if not url:
return None
u = url.strip()
# scp-style: git@host:owner/repo.git -> host/owner/repo
m = re.match(r"^[^@]+@([^:]+):(.+)$", u)
if m:
u = f"{m.group(1)}/{m.group(2)}"
else:
u = re.sub(r"^[a-zA-Z][a-zA-Z0-9+.-]*://", "", u) # drop scheme
u = re.sub(r"^[^@/]+@", "", u) # drop user[:pass]@
u = re.sub(r"\.git/?$", "", u)
return u.strip("/") or None


def _detect_git_remote(cwd: str) -> Optional[str]:
"""Normalized origin remote for ``cwd``'s repo, or None (best-effort)."""
return _normalize_git_remote(_git_value(cwd, ["remote", "get-url", "origin"]))


def _detect_git_branch(cwd: str) -> Optional[str]:
"""Current branch for ``cwd``'s repo, or None (best-effort)."""
return _git_value(cwd, ["rev-parse", "--abbrev-ref", "HEAD"])


def workspace_key(row: Dict[str, Any]) -> Optional[str]:
"""Derive a session's workspace grouping key from a rich row.

The key is the git remote when present (so the same repo cloned to
different paths groups together), else the cwd (so non-git project dirs
still group). Branch is deliberately NOT part of the key — it's a
per-session attribute, so checking out a new branch doesn't fragment a
workspace's session history. Returns None when neither is recorded
(pre-existing/unbound sessions).
"""
remote = row.get("git_remote")
if remote:
return str(remote)
cwd = row.get("cwd")
return str(cwd) if cwd else None

def _delegate_from_json(col: str = "model_config") -> str:
return f"json_extract(COALESCE({col}, '{{}}'), '$._delegate_from')"

Expand Down Expand Up @@ -530,6 +600,8 @@ def repair_state_db_schema(db_path: Path, *, backup: bool = True) -> Dict[str, A
cache_write_tokens INTEGER DEFAULT 0,
reasoning_tokens INTEGER DEFAULT 0,
cwd TEXT,
git_remote TEXT,
git_branch TEXT,
billing_provider TEXT,
billing_base_url TEXT,
billing_mode TEXT,
Expand Down Expand Up @@ -1287,13 +1359,15 @@ def _insert_session_row(
user_id: str = None,
parent_session_id: str = None,
cwd: str = None,
git_remote: str = None,
git_branch: str = None,
) -> None:
"""Shared INSERT OR IGNORE for session rows."""
def _do(conn):
conn.execute(
"""INSERT OR IGNORE INTO sessions (id, source, user_id, model, model_config,
system_prompt, parent_session_id, cwd, started_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)""",
system_prompt, parent_session_id, cwd, git_remote, git_branch, started_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
(
session_id,
source,
Expand All @@ -1303,13 +1377,31 @@ def _do(conn):
system_prompt,
parent_session_id,
cwd,
git_remote,
git_branch,
time.time(),
),
)
self._execute_write(_do)

def create_session(self, session_id: str, source: str, **kwargs) -> str:
"""Create a new session record. Returns the session_id."""
"""Create a new session record. Returns the session_id.

When a ``cwd`` is supplied and ``git_remote``/``git_branch`` are not
explicitly passed, they are auto-derived from that directory's git repo
(best-effort, never fatal). This is the session<->workspace binding:
the grouping key is ``(cwd, git_remote)`` and the branch is recorded as
a per-session attribute (see ``workspace_key``). Auto-derivation is
skipped for child sessions (subagents/compression) so worktree byproduct
doesn't get its own workspace identity.
"""
cwd = kwargs.get("cwd")
is_child = bool(kwargs.get("parent_session_id"))
if cwd and not is_child:
if kwargs.get("git_remote") is None:
kwargs["git_remote"] = _detect_git_remote(cwd)
if kwargs.get("git_branch") is None:
kwargs["git_branch"] = _detect_git_branch(cwd)
self._insert_session_row(session_id, source, **kwargs)
return session_id
def end_session(self, session_id: str, end_reason: str) -> None:
Expand Down Expand Up @@ -1990,6 +2082,7 @@ def list_sessions_rich(
include_archived: bool = False,
archived_only: bool = False,
id_query: str = None,
workspace: str = None,
) -> List[Dict[str, Any]]:
"""List sessions with preview (first user message) and last active timestamp.

Expand Down Expand Up @@ -2042,6 +2135,17 @@ def list_sessions_rich(
if source:
where_clauses.append("s.source = ?")
params.append(source)
if workspace:
# Match the workspace grouping key: a session belongs to <workspace>
# if its normalized git_remote equals it (or contains it, so
# "owner/repo" matches "github.com/owner/repo"), or — for non-git
# dirs — its cwd equals it.
where_clauses.append(
"(s.git_remote = ? OR s.git_remote LIKE ? ESCAPE '\\' OR s.cwd = ?)"
)
ws = str(workspace)
like = "%" + ws.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_") + "%"
params.extend([ws, like, ws])
if exclude_sources:
placeholders = ",".join("?" for _ in exclude_sources)
where_clauses.append(f"s.source NOT IN ({placeholders})")
Expand Down
Loading