feat: --resume latest keyword and --in DIR launch flag
--resume latest resolves the most recent session through the same workspace-scoped MRU lookup as -c (TUI source first under --tui, with classic-CLI fallback). --in DIR chdirs before session resolution so the lookup keys off DIR's workspace, and pins the session there by skipping the recorded-cwd restore. Requested by @Jeff9James: hermes --tui --resume latest --in ./dir
This commit is contained in:
@@ -46,6 +46,8 @@ Examples:
|
||||
hermes -c Resume the most recent session
|
||||
hermes -c "my project" Resume a session by name (latest in lineage)
|
||||
hermes --resume <session_id> Resume a specific session by ID
|
||||
hermes --resume latest Resume the most recent session (same as -c)
|
||||
hermes --tui --resume latest --in ./dir Resume ./dir's latest session in the TUI
|
||||
hermes setup Run setup wizard
|
||||
hermes logout Clear stored authentication
|
||||
hermes auth add <provider> Add a pooled credential
|
||||
@@ -170,7 +172,10 @@ def build_top_level_parser():
|
||||
"-r",
|
||||
metavar="SESSION",
|
||||
default=None,
|
||||
help="Resume a previous session by ID or title",
|
||||
help=(
|
||||
"Resume a previous session by ID or title, or pass 'latest' for "
|
||||
"the most recent session (workspace-scoped, like -c with no name)"
|
||||
),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--no-restore-cwd",
|
||||
@@ -178,6 +183,18 @@ def build_top_level_parser():
|
||||
default=False,
|
||||
help="Don't cd into a resumed session's recorded working directory.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--in",
|
||||
dest="in_dir",
|
||||
metavar="DIR",
|
||||
default=None,
|
||||
help=(
|
||||
"Change into DIR before starting or resuming. Combined with "
|
||||
"'--resume latest' or -c, the most recent session for DIR's "
|
||||
"workspace is picked, and the session stays in DIR (skips the "
|
||||
"recorded-cwd restore)."
|
||||
),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--continue",
|
||||
"-c",
|
||||
@@ -358,7 +375,10 @@ def build_top_level_parser():
|
||||
"-r",
|
||||
metavar="SESSION_ID",
|
||||
default=argparse.SUPPRESS,
|
||||
help="Resume a previous session by ID (shown on exit)",
|
||||
help=(
|
||||
"Resume a previous session by ID (shown on exit), or 'latest' "
|
||||
"for the most recent session"
|
||||
),
|
||||
)
|
||||
chat_parser.add_argument(
|
||||
"--no-restore-cwd",
|
||||
@@ -366,6 +386,16 @@ def build_top_level_parser():
|
||||
default=argparse.SUPPRESS,
|
||||
help="Don't cd into a resumed session's recorded working directory.",
|
||||
)
|
||||
chat_parser.add_argument(
|
||||
"--in",
|
||||
dest="in_dir",
|
||||
metavar="DIR",
|
||||
default=argparse.SUPPRESS,
|
||||
help=(
|
||||
"Change into DIR before starting or resuming (scopes "
|
||||
"'--resume latest' / -c lookups to DIR's workspace)."
|
||||
),
|
||||
)
|
||||
chat_parser.add_argument(
|
||||
"--continue",
|
||||
"-c",
|
||||
|
||||
@@ -579,6 +579,7 @@ def _apply_profile_override() -> None:
|
||||
"-r", "--resume",
|
||||
"-s", "--skills",
|
||||
"--usage-file",
|
||||
"--in",
|
||||
}
|
||||
optional_value_flags = {"-c", "--continue"}
|
||||
i = 0
|
||||
@@ -2533,6 +2534,41 @@ def cmd_chat(args):
|
||||
|
||||
_apply_safe_mode(args)
|
||||
|
||||
# --in DIR: run in DIR. Must happen before any session resolution so the
|
||||
# workspace-scoped "latest"/-c lookups key off DIR, and it pins the
|
||||
# session there — an explicit --in wins over a resumed session's
|
||||
# recorded cwd (so the restore step below is skipped).
|
||||
in_dir = getattr(args, "in_dir", None)
|
||||
if in_dir:
|
||||
_target_dir = os.path.abspath(os.path.expanduser(in_dir))
|
||||
if not os.path.isdir(_target_dir):
|
||||
print(f"Error: --in directory not found: {in_dir}")
|
||||
sys.exit(1)
|
||||
try:
|
||||
os.chdir(_target_dir)
|
||||
except OSError as e:
|
||||
print(f"Error: cannot enter --in directory {in_dir}: {e}")
|
||||
sys.exit(1)
|
||||
args.no_restore_cwd = True
|
||||
|
||||
# --resume latest: keyword for "most recent session" — same resolution
|
||||
# as `-c` with no name (workspace-scoped MRU, then global fallback).
|
||||
# The keyword wins over a session literally titled "latest"; that
|
||||
# session stays reachable via its ID or `-c latest` (title match).
|
||||
_resume_raw = getattr(args, "resume", None)
|
||||
if isinstance(_resume_raw, str) and _resume_raw.strip().lower() == "latest":
|
||||
_source = "tui" if use_tui else "cli"
|
||||
_last_id = _resolve_last_session(source=_source)
|
||||
if not _last_id and _source == "tui":
|
||||
_last_id = _resolve_last_session(source="cli")
|
||||
if _last_id:
|
||||
args.resume = _last_id
|
||||
else:
|
||||
kind = "TUI" if use_tui else "CLI"
|
||||
print(f"No previous {kind} session found to resume.")
|
||||
print("Use 'hermes sessions list' to see available sessions.")
|
||||
sys.exit(1)
|
||||
|
||||
# Resolve --continue into --resume with the latest session or by name
|
||||
continue_val = getattr(args, "continue_last", None)
|
||||
if continue_val and not getattr(args, "resume", None):
|
||||
@@ -10651,6 +10687,7 @@ _TOP_LEVEL_VALUE_FLAGS = frozenset(
|
||||
"-r", "--resume",
|
||||
"-s", "--skills",
|
||||
"--usage-file",
|
||||
"--in",
|
||||
# ``-c / --continue`` is nargs='?' (optional value). Treat it as
|
||||
# value-taking: if the next token is a subcommand-looking word
|
||||
# the user almost certainly meant it as the session name, and
|
||||
|
||||
231
tests/hermes_cli/test_resume_latest_and_in_dir.py
Normal file
231
tests/hermes_cli/test_resume_latest_and_in_dir.py
Normal file
@@ -0,0 +1,231 @@
|
||||
"""Tests for `--resume latest` and `--in DIR` launch sugar.
|
||||
|
||||
`hermes --tui --resume latest --in ./dir` (and the classic-CLI equivalents)
|
||||
resolve "latest" through the same workspace-scoped MRU lookup as `-c`, with
|
||||
`--in` re-homing the process before any session resolution happens.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from argparse import Namespace
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def _args(**overrides):
|
||||
base = {
|
||||
"cli": False,
|
||||
"continue_last": None,
|
||||
"in_dir": None,
|
||||
"model": None,
|
||||
"no_restore_cwd": False,
|
||||
"provider": None,
|
||||
"query": None,
|
||||
"resume": None,
|
||||
"safe_mode": False,
|
||||
"toolsets": None,
|
||||
"tui": True,
|
||||
"tui_dev": False,
|
||||
"worktree": False,
|
||||
}
|
||||
base.update(overrides)
|
||||
return Namespace(**base)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def main_mod(monkeypatch):
|
||||
import hermes_cli.main as mod
|
||||
|
||||
monkeypatch.setattr(mod, "_has_any_provider_configured", lambda: True)
|
||||
monkeypatch.setattr(mod, "_sync_bundled_skills_for_startup", lambda: False)
|
||||
monkeypatch.setattr(mod, "_pin_kanban_board_env", lambda: None)
|
||||
return mod
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def launched(main_mod, monkeypatch):
|
||||
"""Capture the _launch_tui call instead of exec'ing Node."""
|
||||
captured = {}
|
||||
|
||||
def fake_launch(resume_session_id=None, **kwargs):
|
||||
captured["resume"] = resume_session_id
|
||||
captured.update(kwargs)
|
||||
raise SystemExit(0)
|
||||
|
||||
monkeypatch.setattr(main_mod, "_launch_tui", fake_launch)
|
||||
return captured
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# argparse surface
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_top_level_parser_accepts_in_and_resume_latest():
|
||||
from hermes_cli._parser import build_top_level_parser
|
||||
|
||||
parser, _subparsers, _chat = build_top_level_parser()
|
||||
args = parser.parse_args(["--tui", "--resume", "latest", "--in", "./dir"])
|
||||
assert args.tui is True
|
||||
assert args.resume == "latest"
|
||||
assert args.in_dir == "./dir"
|
||||
|
||||
|
||||
def test_chat_subparser_accepts_in_flag():
|
||||
from hermes_cli._parser import build_top_level_parser
|
||||
|
||||
parser, _subparsers, _chat = build_top_level_parser()
|
||||
args = parser.parse_args(["chat", "--in", "/tmp", "--resume", "latest"])
|
||||
assert args.in_dir == "/tmp"
|
||||
assert args.resume == "latest"
|
||||
|
||||
|
||||
def test_top_level_in_value_not_mistaken_for_subcommand(monkeypatch):
|
||||
# `hermes --in chat` — "chat" is the flag's value, not the subcommand.
|
||||
import sys
|
||||
|
||||
import hermes_cli.main as mod
|
||||
|
||||
monkeypatch.setattr(sys, "argv", ["hermes", "--in", "chat", "--resume", "latest"])
|
||||
assert mod._first_positional_argv() is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# --resume latest resolution
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_resume_latest_resolves_to_mru_session(main_mod, launched, monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
main_mod, "_resolve_last_session", lambda source="cli": "20260807_120000_abc123"
|
||||
)
|
||||
# Keyword must NOT fall through to title resolution.
|
||||
monkeypatch.setattr(
|
||||
main_mod,
|
||||
"_resolve_session_by_name_or_id",
|
||||
lambda val: val if val != "latest" else pytest.fail("'latest' hit title resolution"),
|
||||
)
|
||||
|
||||
with pytest.raises(SystemExit) as exc:
|
||||
main_mod.cmd_chat(_args(resume="latest"))
|
||||
assert exc.value.code == 0
|
||||
assert launched["resume"] == "20260807_120000_abc123"
|
||||
|
||||
|
||||
def test_resume_latest_tui_falls_back_to_cli_source(main_mod, launched, monkeypatch):
|
||||
calls = []
|
||||
|
||||
def fake_resolve(source="cli"):
|
||||
calls.append(source)
|
||||
return "cli_session_1" if source == "cli" else None
|
||||
|
||||
monkeypatch.setattr(main_mod, "_resolve_last_session", fake_resolve)
|
||||
monkeypatch.setattr(main_mod, "_resolve_session_by_name_or_id", lambda v: v)
|
||||
|
||||
with pytest.raises(SystemExit) as exc:
|
||||
main_mod.cmd_chat(_args(resume="latest"))
|
||||
assert exc.value.code == 0
|
||||
assert calls == ["tui", "cli"]
|
||||
assert launched["resume"] == "cli_session_1"
|
||||
|
||||
|
||||
def test_resume_latest_is_case_insensitive(main_mod, launched, monkeypatch):
|
||||
monkeypatch.setattr(main_mod, "_resolve_last_session", lambda source="cli": "sess_1")
|
||||
monkeypatch.setattr(main_mod, "_resolve_session_by_name_or_id", lambda v: v)
|
||||
|
||||
with pytest.raises(SystemExit):
|
||||
main_mod.cmd_chat(_args(resume="Latest"))
|
||||
assert launched["resume"] == "sess_1"
|
||||
|
||||
|
||||
def test_resume_latest_no_sessions_exits_with_error(main_mod, monkeypatch, capsys):
|
||||
monkeypatch.setattr(main_mod, "_resolve_last_session", lambda source="cli": None)
|
||||
|
||||
with pytest.raises(SystemExit) as exc:
|
||||
main_mod.cmd_chat(_args(resume="latest"))
|
||||
assert exc.value.code == 1
|
||||
out = capsys.readouterr().out
|
||||
assert "No previous TUI session found" in out
|
||||
|
||||
|
||||
def test_resume_real_id_untouched_by_latest_keyword(main_mod, launched, monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
main_mod,
|
||||
"_resolve_last_session",
|
||||
lambda source="cli": pytest.fail("MRU lookup must not run for explicit IDs"),
|
||||
)
|
||||
monkeypatch.setattr(main_mod, "_resolve_session_by_name_or_id", lambda v: v)
|
||||
|
||||
with pytest.raises(SystemExit):
|
||||
main_mod.cmd_chat(_args(resume="20260807_120000_abc123"))
|
||||
assert launched["resume"] == "20260807_120000_abc123"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# --in DIR
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_in_dir_chdirs_before_session_resolution(main_mod, launched, monkeypatch, tmp_path):
|
||||
import os
|
||||
|
||||
target = tmp_path / "projdir"
|
||||
target.mkdir()
|
||||
start = os.getcwd()
|
||||
seen_cwd = {}
|
||||
|
||||
def fake_resolve(source="cli"):
|
||||
seen_cwd["at_resolve"] = os.getcwd()
|
||||
return "sess_scoped"
|
||||
|
||||
monkeypatch.setattr(main_mod, "_resolve_last_session", fake_resolve)
|
||||
monkeypatch.setattr(main_mod, "_resolve_session_by_name_or_id", lambda v: v)
|
||||
|
||||
try:
|
||||
with pytest.raises(SystemExit):
|
||||
main_mod.cmd_chat(_args(resume="latest", in_dir=str(target)))
|
||||
finally:
|
||||
os.chdir(start)
|
||||
|
||||
assert seen_cwd["at_resolve"] == str(target.resolve())
|
||||
assert launched["resume"] == "sess_scoped"
|
||||
|
||||
|
||||
def test_in_dir_sets_no_restore_cwd(main_mod, launched, monkeypatch, tmp_path):
|
||||
import os
|
||||
|
||||
target = tmp_path / "pin-here"
|
||||
target.mkdir()
|
||||
start = os.getcwd()
|
||||
|
||||
args = _args(resume=None, in_dir=str(target))
|
||||
try:
|
||||
with pytest.raises(SystemExit):
|
||||
main_mod.cmd_chat(args)
|
||||
finally:
|
||||
os.chdir(start)
|
||||
|
||||
assert args.no_restore_cwd is True
|
||||
|
||||
|
||||
def test_in_dir_missing_directory_exits(main_mod, monkeypatch, tmp_path, capsys):
|
||||
with pytest.raises(SystemExit) as exc:
|
||||
main_mod.cmd_chat(_args(in_dir=str(tmp_path / "nope")))
|
||||
assert exc.value.code == 1
|
||||
assert "--in directory not found" in capsys.readouterr().out
|
||||
|
||||
|
||||
def test_in_dir_expands_user_home(main_mod, launched, monkeypatch, tmp_path):
|
||||
import os
|
||||
|
||||
home = tmp_path / "home"
|
||||
(home / "proj").mkdir(parents=True)
|
||||
monkeypatch.setenv("HOME", str(home))
|
||||
start = os.getcwd()
|
||||
|
||||
try:
|
||||
with pytest.raises(SystemExit):
|
||||
main_mod.cmd_chat(_args(in_dir="~/proj"))
|
||||
assert os.getcwd() == str((home / "proj").resolve())
|
||||
finally:
|
||||
os.chdir(start)
|
||||
@@ -22,8 +22,9 @@ hermes [global-options] <command> [subcommand/options]
|
||||
|--------|-------------|
|
||||
| `--version`, `-V` | Show version and exit. |
|
||||
| `--profile <name>`, `-p <name>` | Select which Hermes profile to use for this invocation. Overrides the sticky default set by `hermes profile use`. |
|
||||
| `--resume <session>`, `-r <session>` | Resume a previous session by ID or title. |
|
||||
| `--resume <session>`, `-r <session>` | Resume a previous session by ID or title. The keyword `latest` resumes the most recent session (workspace-scoped, same lookup as `-c`). |
|
||||
| `--continue [name]`, `-c [name]` | Resume the most recent session, or the most recent session matching a title. |
|
||||
| `--in <dir>` | Change into `<dir>` before starting or resuming. Scopes `--resume latest` / `-c` lookups to that directory's workspace and keeps the session there (skips the recorded-cwd restore). |
|
||||
| `--worktree`, `-w` | Start in an isolated git worktree for parallel-agent workflows. |
|
||||
| `--yolo` | Bypass dangerous-command approval prompts. |
|
||||
| `--pass-session-id` | Include the session ID in the agent's system prompt. |
|
||||
|
||||
@@ -42,6 +42,8 @@ hermes chat -s github-pr-workflow -q "open a draft PR"
|
||||
# Resume previous sessions
|
||||
hermes --continue # Resume the most recent CLI session (-c)
|
||||
hermes --resume <session_id> # Resume a specific session by ID (-r)
|
||||
hermes --resume latest # Resume the most recent session (same as -c)
|
||||
hermes --resume latest --in ./dir # Resume ./dir's latest session, staying in ./dir
|
||||
|
||||
# Verbose mode (debug output)
|
||||
hermes chat --verbose
|
||||
@@ -391,6 +393,8 @@ hermes -c # Short form
|
||||
hermes -c "my project" # Resume a named session (latest in lineage)
|
||||
hermes --resume 20260225_143052_a1b2c3 # Resume a specific session by ID
|
||||
hermes --resume "refactoring auth" # Resume by title
|
||||
hermes --resume latest # Resume the most recent session (same as -c)
|
||||
hermes --resume latest --in ./my-project # Latest session for ./my-project's workspace
|
||||
hermes -r 20260225_143052_a1b2c3 # Short form
|
||||
```
|
||||
|
||||
|
||||
@@ -138,12 +138,33 @@ hermes -r 20250305_091523_a1b2c3d4
|
||||
# Resume by title
|
||||
hermes --resume "refactoring auth"
|
||||
|
||||
# Resume the most recent session — same lookup as -c
|
||||
hermes --resume latest
|
||||
|
||||
# Or with the chat subcommand
|
||||
hermes chat --resume 20250305_091523_a1b2c3d4
|
||||
```
|
||||
|
||||
Session IDs are shown when you exit a CLI session, and can be found with `hermes sessions list`.
|
||||
|
||||
:::note
|
||||
`latest` is a reserved keyword for `--resume`. A session literally titled "latest" is still reachable by its ID or via `-c latest` (title match).
|
||||
:::
|
||||
|
||||
### Resume in a Specific Directory
|
||||
|
||||
Pass `--in <dir>` to change into a directory before starting or resuming. Combined with `--resume latest` (or `-c`), the most recent session for that directory's workspace is picked — no need to `cd` first or remember session IDs:
|
||||
|
||||
```bash
|
||||
# Resume the latest session that belongs to ./my-project
|
||||
hermes --resume latest --in ./my-project
|
||||
|
||||
# Works with the TUI too
|
||||
hermes --tui --resume latest --in ./my-project
|
||||
```
|
||||
|
||||
`--in` also pins the session to that directory: the resumed session's recorded working directory is not restored (as if `--no-restore-cwd` were passed).
|
||||
|
||||
### Resume Restores the Working Directory
|
||||
|
||||
Resuming a CLI session also `cd`s back into the session's recorded working directory (its git repo root or project dir), so the conversation picks up in the workspace it belonged to. If you'd rather stay where you are, pass `--no-restore-cwd`:
|
||||
|
||||
@@ -19,11 +19,15 @@ hermes --tui
|
||||
# Resume the latest TUI session (falls back to the latest classic session)
|
||||
hermes --tui -c
|
||||
hermes --tui --continue
|
||||
hermes --tui --resume latest
|
||||
|
||||
# Resume a specific session by ID or title
|
||||
hermes --tui -r 20260409_000000_aa11bb
|
||||
hermes --tui --resume "my t0p session"
|
||||
|
||||
# Resume the latest session for a specific project directory
|
||||
hermes --tui --resume latest --in ./my-project
|
||||
|
||||
# Run source directly — skips the prebuild step (for TUI contributors)
|
||||
hermes --tui --dev
|
||||
```
|
||||
|
||||
Reference in New Issue
Block a user