From c5f5fa40c36e447740f9b313423efe747ce2fbf7 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Fri, 7 Aug 2026 11:31:23 -0700 Subject: [PATCH] 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 --- hermes_cli/_parser.py | 34 ++- hermes_cli/main.py | 37 +++ .../test_resume_latest_and_in_dir.py | 231 ++++++++++++++++++ website/docs/reference/cli-commands.md | 3 +- website/docs/user-guide/cli.md | 4 + website/docs/user-guide/sessions.md | 21 ++ website/docs/user-guide/tui.md | 4 + 7 files changed, 331 insertions(+), 3 deletions(-) create mode 100644 tests/hermes_cli/test_resume_latest_and_in_dir.py diff --git a/hermes_cli/_parser.py b/hermes_cli/_parser.py index b5098f6c98..7d13d76caf 100644 --- a/hermes_cli/_parser.py +++ b/hermes_cli/_parser.py @@ -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 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 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", diff --git a/hermes_cli/main.py b/hermes_cli/main.py index a6237bbd51..88655b5dcb 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -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 diff --git a/tests/hermes_cli/test_resume_latest_and_in_dir.py b/tests/hermes_cli/test_resume_latest_and_in_dir.py new file mode 100644 index 0000000000..5aa8348884 --- /dev/null +++ b/tests/hermes_cli/test_resume_latest_and_in_dir.py @@ -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) diff --git a/website/docs/reference/cli-commands.md b/website/docs/reference/cli-commands.md index c1d63a59cd..bd670e9fdd 100644 --- a/website/docs/reference/cli-commands.md +++ b/website/docs/reference/cli-commands.md @@ -22,8 +22,9 @@ hermes [global-options] [subcommand/options] |--------|-------------| | `--version`, `-V` | Show version and exit. | | `--profile `, `-p ` | Select which Hermes profile to use for this invocation. Overrides the sticky default set by `hermes profile use`. | -| `--resume `, `-r ` | Resume a previous session by ID or title. | +| `--resume `, `-r ` | 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 ` | Change into `` 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. | diff --git a/website/docs/user-guide/cli.md b/website/docs/user-guide/cli.md index 2bcde3d2af..5fbc06d76d 100644 --- a/website/docs/user-guide/cli.md +++ b/website/docs/user-guide/cli.md @@ -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 # 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 ``` diff --git a/website/docs/user-guide/sessions.md b/website/docs/user-guide/sessions.md index 2d7e36a523..20d4ed4951 100644 --- a/website/docs/user-guide/sessions.md +++ b/website/docs/user-guide/sessions.md @@ -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 ` 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`: diff --git a/website/docs/user-guide/tui.md b/website/docs/user-guide/tui.md index f6dcfdf818..de62bd36d8 100644 --- a/website/docs/user-guide/tui.md +++ b/website/docs/user-guide/tui.md @@ -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 ```