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:
Teknium
2026-08-07 11:31:23 -07:00
parent f46636bfe2
commit c5f5fa40c3
7 changed files with 331 additions and 3 deletions

View File

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

View File

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

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

View File

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

View File

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

View File

@@ -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`:

View File

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