# NousResearch/hermes-agent · Interactive session picker and resume navigation features

## Open now

- 2 issues, 1 PR open on GitHub.
- #5509: feat: Ctrl+P context-aware inspect + /history full pager
- #41099: \[Feature\]: Search through sessions easily with a session menu.
- PR #8498: feat: Ctrl+P history pager + /history full (newest first, full text, +G scroll)

These issues and pull requests propose and implement interactive session browsing, search filtering, and numeric/positional selection for resuming sessions via /resume and related CLI commands. Several also touch on history viewing improvements such as a full pager and Ctrl+P inspection.

## TL;DR

- 10 member(s): 2 open issue(s), 3 closed issue(s), 1 open PR(s), 2 merged PR(s), 2 PR(s) closed without merging.
- A fix was merged to main in PR #733. Verify it addresses your case.
- **Recommended:** read PR #733 (and any competing PRs below) before filing. Confirm the merged change covers your symptom.

## Maintainer resolution verdict

- Internal-tracker verdict over this cluster's issue members: `partially_resolved` · deciding PR(s): `pr-733`. This is distinct from the fix status above. Weigh it yourself.

## How this cluster was grouped

- Link evidence (`STRUCTURAL_PARTIAL`): some members are connected by structural evidence (shared changed files, or a closing/superseded reference); the rest are joined by an automated relevance judgement over their text.
- Members outside that structural core may belong to a different underlying problem.
- Pairwise link density `0.15`: the share of member pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36. `1.00` means every pair is linked.

## Open related issues

- #5509: feat: Ctrl+P context-aware inspect + /history full pager
- #41099: \[Feature\]: Search through sessions easily with a session menu.

## Closed / duplicate issues

- #718: Feature: Interactive Session Picker for hermes --continue — Browsable Session List with Search
- #4988: \[Feature\]: /resume with no arguments should show an interactive session picker
- #12406: \[Feature\]: Hermes Session Selector - Professional Session Resume Interface

## Prior & competing PRs

- **#733: feat: interactive session browser with search filtering (#718)**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`; implication `superseded`
  - files: `hermes_cli/main.py`, `tests/hermes_cli/test_session_browse.py`
  - fix hunks:
  - `hermes_cli/main.py`

    ```diff
    @@ -21,6 +21,7 @@
         hermes version             # Show version
         hermes update              # Update to latest version
         hermes uninstall           # Uninstall Hermes Agent
    +    hermes sessions browse     # Interactive session picker with search
     """
     
     import argparse
    ```

    ```diff
    @@ -106,6 +107,279 @@ def _has_any_provider_configured() -> bool:
         return False
     
     
    +def _session_browse_picker(sessions: list) -> Optional[str]:
    +    """Interactive curses-based session browser with live search filtering.
    +
    +    Returns the selected session ID, or None if cancelled.
    +    Uses curses (not simple_term_menu) to avoid the ghost-duplication rendering
    +    bug in tmux/iTerm when arrow keys are used.
    +    """
    +    if not sessions:
    +        print("No sessions found.")
    +        return None
    +
    +    # Try curses-based picker first
    +    try:
    +        import curses
    +        import time as _time
    +        from datetime import datetime
    +
    +        result_holder = [None]
    +
    +        def _relative_time(ts):
    +            if not ts:
    +                return "?"
    +            delta = _time.time() - ts
    +            if delta < 60:
    +                return "just now"
    +            elif delta < 3600:
    +                return f"{int(delta / 60)}m ago"
    +            elif delta < 86400:
    +                return f"{int(delta / 3600)}h ago"
    +            elif delta < 172800:
    +                return "yesterday"
    +            elif delta < 604800:
    +                return f"{int(delta / 86400)}d ago"
    +            else:
    +                return datetime.fromtimestamp(ts).strftime("%Y-%m-%d")
    +
    +        def _format_row(s, max_x):
    +            """Format a session row for display."""
    +            title = (s.get("title") or "").strip()
    +            preview = (s.get("preview") or "").strip()
    +            source = s.get("source", "")[:6]
    +            last_active = _relative_time(s.get("last_active"))
    +            sid = s["id"][:18]
    +
    +            # Adaptive column widths based on terminal width
    +            # Layout: [arrow 3] [title/preview flexible] [active 12] [src 6] [id 18]
    +            fixed_cols = 3 + 12 + 6 + 18 + 6  # arrow + active + src + id + padding
    +            name_width = max(20, max_x - fixed_cols)
    +
    +            if title:
    +                name = title[:name_width]
    +            elif preview:
    +                name = preview[:name_width]
    +            else:
    +                name = sid
    +
    +            return f"{name:<{name_width}}  {last_active:<10}  {source:<5} {sid}"
    +
    +        def _match(s, query):
    +            """Check if a session matches the search query (case-insensitive)."""
    +            q = query.lower()
    +            return (
    +                q in (s.get("title") or "").lower()
    +                or q in (s.get("preview") or "").lower()
    +                or q in s.get("id", "").lower()
    +                or q in (s.get("source") or "").lower()
    +            )
    +
    +        def _curses_browse(stdscr):
    +            curses.curs_set(0)
    +            if curses.has_colors():
    +                curses.start_color()
    +                curses.use_default_colors()
    +                curses.init_pair(1, curses.COLOR_GREEN, -1)   # selected
    +                curses.init_pair(2, curses.COLOR_YELLOW, -1)  # header
    +                curses.init_pair(3, curses.COLOR_CYAN, -1)    # search
    +                curses.init_pair(4, 8, -1)                    # dim
    +
    +            cursor = 0
    +            scroll_offset = 0
    +            search_text = ""
    +            filtered = list(sessions)
    +
    +            while True:
    +                stdscr.clear()
    +                max_y, max_x = stdscr.getmaxyx()
    +                if max_y < 5 or max_x < 40:
    +                    # Terminal too small
    +                    try:
    +                        stdscr.addstr(0, 0, "Terminal too small")
    +                    except curses.error:
    +                        pass
    +                    stdscr.refresh()
    +                    stdscr.getch()
    +                    return
    +
    +                # Header line
    +                if search_text:
    +                    header = f"  Browse sessions — filter: {search_text}█"
    +                    header_attr = curses.A_BOLD
    +                    if curses.has_colors():
    +                        header_attr |= curses.color_pair(3)
    +                else:
    +                    header = "  Browse sessions — ↑↓ navigate  Enter select  Type to filter  Esc quit"
    +                    header_attr = curses.A_BOLD
    +                    if curses.has_colors():
    +                        header_attr |= curses.color_pair(2)
    +                try:
    +                    stdscr.addnstr(0, 0, header, max_x - 1, header_attr)
    +                except curses.error:
    +                    pass
    +
    +                # Column header line
    +                fixed_cols = 3 + 12 + 6 + 18 + 6
    +                name_width = max(20, max_x - fixed_cols)
    +                col_header = f"   {'Title / Preview':<{name_width}}  {'Active':<10}  {'Src':<5} {'ID'}"
    +                try:
    +                    dim_attr = curses.color_pair(4) if curses.has_colors() else curses.A_DIM
    +                    stdscr.addnstr(1, 0, col_header, max_x - 1, dim_attr)
    +                except curses.error:
    +                    pass
    +
    +                # Compute visible area
    +                visible_rows = max_y - 4  # header + col header + blank + footer
    +                if visible_rows < 1:
    +                    visible_rows = 1
    +
    +                # Clamp cursor and scroll
    +                if not filtered:
    +                    try:
    +                        msg = "  No sessions match the filter."
    +                        stdscr.addnstr(3, 0, msg, max_x - 1, curses.A_DIM)
    +                    except curses.error:
    +                        pass
    +                else:
    +                    if cursor >= len(filtered):
    +                        cursor = len(filtered) - 1
    +                    if cursor < 0:
    +                        cursor = 0
    +                    if cursor < scroll_offset:
    +                        scroll_offset = cursor
    +                    elif cursor >= scroll_offset + visible_rows:
    +                        scroll_offset = cursor - visible_rows + 1
    +
    +                    for draw_i, i in enumerate(range(
    +                        scroll_offset,
    +                        min(len(filtered), scroll_offset + visible_rows)
    +                    )):
    +                        y = draw_i + 3
    +                        if y >= max_y - 1:
    +                            break
    +                        s = filtered[i]
    +                        arrow = " → " if i == cursor else "   "
    +                        row = arrow + _format_row(s, max_x - 3)
    +                        attr = curses.A_NORMAL
    +                        if i == cursor:
    +                            attr = curses.A_BOLD
    +                            if curses.has_colors():
    +                                attr |= curses.color_pair(1)
    +                        try:
    +                            stdscr.addnstr(y, 0, row, max_x - 1, attr)
    +                        except curses.error:
    +                            pass
    +
    +                # Footer
    +                footer_y = max_y - 1
    +                if filtered:
    +                    footer = f"  {cursor + 1}/{len(filtered)} sessions"
    +                    if len(filtered) < len(sessions):
    +                        footer += f" (filtered from {len(sessions)})"
    +                else:
    +                    footer = f"  0/{len(sessions)} sessions"
    +                try:
    +                    stdscr.addnstr(footer_y, 0, footer, max_x - 1,
    +                                   curses.color_pair(4) if curses.has_colors() else curses.A_DIM)
    +                except curses.error:
    +                    pass
    +
    +                stdscr.refresh()
    +                key = stdscr.getch()
    +
    +                if key in (curses.KEY_UP, ):
    +                    if filtered:
    +                        cursor = (cursor - 1) % len(filtered)
    +                elif key in (curses.KEY_DOWN, ):
    +                    if filtered:
    +                        cursor = (cursor + 1) % len(filtered)
    +                elif key in (curses.KEY_ENTER, 10, 13):
    +                    if filtered:
    +                        result_holder[0] = filtered[cursor]["id"]
    +                    return
    +                elif key == 27:  # Esc
    +                    if search_text:
    +                        # First Esc clears the search
    +                        search_text = ""
    +                        filtered = list(sessions)
    +                        cursor = 0
    +                        scroll_offset = 0
    +                    else:
    +                        # Second Esc exits
    +                        return
    +                elif key in (curses.KEY_BACKSPACE, 127, 8):
    +                    if search_text:
    +                        search_text = search_text[:-1]
    +                        if search_text:
    +                            filtered = [s for s in sessions if _match(s, search_text)]
    +                        else:
    +                            filtered = list(sessions)
    +                        cursor = 0
    +                        scroll_offset = 0
    +                elif key == ord('q') and not search_text:
    +                    return
    +                elif 32 <= key <= 126:
    +                    # Printable character → add to search filter
    +                    search_text += chr(key)
    +                    filtered = [s for s in sessions if _match(s, search_text)]
    +                    cursor = 0
    +                    scroll_offset = 0
    +
    +        curses.wrapper(_curses_browse)
    +        return result_holder[0]
    +
    +    except Exception:
    +        pass
    +
    +    # Fallback: numbered list (Windows without curses, etc.)
    +    import time as _time
    +    from datetime import datetime
    +
    +    def _relative_time_fb(ts):
    +        if not ts:
    +            return "?"
    +        delta = _time.time() - ts
    +        if delta < 60:
    +            return "just now"
    +        elif delta < 3600:
    +            return f"{int(delta / 60)}m ago"
    +        elif delta < 86400:
    +            return f"{int(delta / 3600)}h ago"
    +        elif delta < 172800:
    +            return "yesterday"
    +        elif delta < 604800:
    +            return f"{int(delta / 86400)}d ago"
    +        else:
    +            return datetime.fromtimestamp(ts).strftime("%Y-%m-%d")
    +
    +    print("\n  Browse sessions  (enter number to resume, q to cancel)\n")
    +    for i, s in enumerate(sessions):
    +        title = (s.get("title") or "").strip()
    +        preview = (s.get("preview") or "").strip()
    +        label = title or preview or s["id"]
    +        if len(label) > 50:
    +            label = label[:47] + "..."
    +        last_active = _relative_time_fb(s.get("last_active"))
    +        src = s.get("source", "")[:6]
    +        print(f"  {i + 1:>3}. {label:<50}  {last_active:<10}  {src}")
    +
    +    while True:
    +        try:
    +            val = input(f"\n  Select [1-{len(sessions)}]: ").strip()
    +            if not val or val.lower() in ("q", "quit", "exit"):
    +                return None
    +            idx = int(val) - 1
    +            if 0 <= idx < len(sessions):
    +                return sessions[idx]["id"]
    +            print(f"  Invalid selection. Enter 1-{len(sessions)} or q to cancel.")
    +        except ValueError:
    +            print(f"  Invalid input. Enter a number or q to cancel.")
    +        except (KeyboardInterrupt, EOFError):
    +            print()
    +            return None
    +
    +
     def _resolve_last_cli_session() -> Optional[str]:
         """Look up the most recent CLI session ID from SQLite. Returns None if unavailable."""
         try:
    ```

    ```diff
    @@ -1269,6 +1543,7 @@ def main():
         hermes -w                     Start in isolated git worktree
         hermes gateway install        Install as system service
         hermes sessions list          List past sessions
    +    hermes sessions browse        Interactive session picker
         hermes sessions rename ID T   Rename/title a session
         hermes update                 Update to latest version
     
    ```

    ```diff
    @@ -1753,6 +2028,13 @@ def cmd_tools(args):
         sessions_rename.add_argument("session_id", help="Session ID to rename")
         sessions_rename.add_argument("title", nargs="+", help="New title for the session")
     
    +    sessions_browse = sessions_subparsers.add_parser(
    +        "browse",
    +        help="Interactive session picker — browse, search, and resume sessions",
    +    )
    +    sessions_browse.add_argument("--source", help="Filter by source (cli, telegram, discord, etc.)")
    +    sessions_browse.add_argument("--limit", type=int, default=50, help="Max sessions to load (default: 50)")
    +
         def cmd_sessions(args):
             import json as _json
             try:
    ```

    ```diff
    @@ -1859,6 +2141,34 @@ def _relative_time(ts):
                 except ValueError as e:
                     print(f"Error: {e}")
     
    +        elif action == "browse":
    +            limit = getattr(args, "limit", 50) or 50
    +            source = getattr(args, "source", None)
    +            sessions = db.list_sessions_rich(source=source, limit=limit)
    +            db.close()
    +            if not sessions:
    +                print("No sessions found.")
    +                return
    +
    +            selected_id = _session_browse_picker(sessions)
    +            if not selected_id:
    +                print("Cancelled.")
    +                return
    +
    +            # Launch hermes --resume <id> by replacing the current process
    +            print(f"Resuming session: {selected_id}")
    +            import shutil
    +            hermes_bin = shutil.which("hermes")
    +            if hermes_bin:
    +                os.execvp(hermes_bin, ["hermes", "--resume", selected_id])
    +            else:
    +                # Fallback: re-invoke via python -m
    +                os.execvp(
    +                    sys.executable,
    +                    [sys.executable, "-m", "hermes_cli.main", "--resume", selected_id],
    +                )
    +            return  # won't reach here after execvp
    +
             elif action == "stats":
                 total = db.session_count()
                 msgs = db.message_count()
    ```

    ```diff
    @@ -1868,7 +2178,6 @@ def _relative_time(ts):
                     c = db.session_count(source=src)
                     if c > 0:
                         print(f"  {src}: {c} sessions")
    -            import os
                 db_path = db.db_path
                 if db_path.exists():
                     size_mb = os.path.getsize(db_path) / (1024 * 1024)
    ```
  - `tests/hermes_cli/test_session_browse.py`

    ```diff
    @@ -0,0 +1,542 @@
    +"""Tests for the interactive session browser (`hermes sessions browse`).
    +
    +Covers:
    +- _session_browse_picker logic (curses mocked, fallback tested)
    +- cmd_sessions 'browse' action integration
    +- Argument parser registration
    +"""
    +
    +import os
    +import time
    +from unittest.mock import MagicMock, patch, call
    +
    +import pytest
    +
    +from hermes_cli.main import _session_browse_picker
    +
    +
    +# ─── Sample session data ──────────────────────────────────────────────────────
    +
    +def _make_sessions(n=5):
    +    """Generate a list of fake rich-session dicts."""
    +    now = time.time()
    +    sessions = []
    +    for i in range(n):
    +        sessions.append({
    +            "id": f"20260308_{i:06d}_abcdef",
    +            "source": "cli" if i % 2 == 0 else "telegram",
    +            "model": "test/model",
    +            "title": f"Session {i}" if i % 3 != 0 else None,
    +            "preview": f"Hello from session {i}",
    +            "last_active": now - i * 3600,
    +            "started_at": now - i * 3600 - 60,
    +            "message_count": (i + 1) * 5,
    +        })
    +    return sessions
    +
    +
    +SAMPLE_SESSIONS = _make_sessions(5)
    +
    +
    +# ─── _session_browse_picker ──────────────────────────────────────────────────
    +
    +class TestSessionBrowsePicker:
    +    """Tests for the _session_browse_picker function."""
    +
    +    def test_empty_sessions_returns_none(self, capsys):
    +        result = _session_browse_picker([])
    +        assert result is None
    +        assert "No sessions found" in capsys.readouterr().out
    +
    +    def test_returns_none_when_no_sessions(self, capsys):
    +        result = _session_browse_picker([])
    +        assert result is None
    +
    +    def test_fallback_mode_valid_selection(self):
    +        """When curses is unavailable, fallback numbered list should work."""
    +        sessions = _make_sessions(3)
    +
    +        # Mock curses import to fail, forcing fallback
    +        import builtins
    +        original_import = builtins.__import__
    +
    +        def mock_import(name, *args, **kwargs):
    +            if name == "curses":
    +                raise ImportError("no curses")
    +            return original_import(name, *args, **kwargs)
    +
    +        with patch.object(builtins, "__import__", side_effect=mock_import):
    +            with patch("builtins.input", return_value="2"):
    +                result = _session_browse_picker(sessions)
    +
    +        assert result == sessions[1]["id"]
    +
    +    def test_fallback_mode_cancel_q(self):
    +        """Entering 'q' in fallback mode cancels."""
    +        sessions = _make_sessions(3)
    +
    +        import builtins
    +        original_import = builtins.__import__
    +
    +        def mock_import(name, *args, **kwargs):
    +            if name == "curses":
    +                raise ImportError("no curses")
    +            return original_import(name, *args, **kwargs)
    +
    +        with patch.object(builtins, "__import__", side_effect=mock_import):
    +            with patch("builtins.input", return_value="q"):
    +                result = _session_browse_picker(sessions)
    +
    +        assert result is None
    +
    +    def test_fallback_mode_cancel_empty(self):
    +        """Entering empty string in fallback mode cancels."""
    +        sessions = _make_sessions(3)
    +
    +        import builtins
    +        original_import = builtins.__import__
    +
    +        def mock_import(name, *args, **kwargs):
    +            if name == "curses":
    +                raise ImportError("no curses")
    +            return original_import(name, *args, **kwargs)
    +
    +        with patch.object(builtins, "__import__", side_effect=mock_import):
    +            with patch("builtins.input", return_value=""):
    +                result = _session_browse_picker(sessions)
    +
    +        assert result is None
    +
    +    def test_fallback_mode_invalid_then_valid(self):
    +        """Invalid selection followed by valid one works."""
    +        sessions = _make_sessions(3)
    +
    +        import builtins
    +        original_import = builtins.__import__
    +
    +        def mock_import(name, *args, **kwargs):
    +            if name == "curses":
    +                raise ImportError("no curses")
    +            return original_import(name, *args, **kwargs)
    +
    +        with patch.object(builtins, "__import__", side_effect=mock_import):
    +            with patch("builtins.input", side_effect=["99", "1"]):
    +                result = _session_browse_picker(sessions)
    +
    +        assert result == sessions[0]["id"]
    +
    +    def test_fallback_mode_keyboard_interrupt(self):
    +        """KeyboardInterrupt in fallback mode returns None."""
    +        sessions = _make_sessions(3)
    +
    +        import builtins
    +        original_import = builtins.__import__
    +
    +        def mock_import(name, *args, **kwargs):
    +            if name == "curses":
    +                raise ImportError("no curses")
    +            return original_import(name, *args, **kwargs)
    +
    +        with patch.object(builtins, "__import__", side_effect=mock_import):
    +            with patch("builtins.input", side_effect=KeyboardInterrupt):
    +                result = _session_browse_picker(sessions)
    +
    +        assert result is None
    +
    +    def test_fallback_displays_all_sessions(self, capsys):
    +        """Fallback mode should display all session entries."""
    +        sessions = _make_sessions(4)
    +
    +        import builtins
    +        original_import = builtins.__import__
    +
    +        def mock_import(name, *args, **kwargs):
    +            if name == "curses":
    +                raise ImportError("no curses")
    +            return original_import(name, *args, **kwargs)
    +
    +        with patch.object(builtins, "__import__", side_effect=mock_import):
    +            with patch("builtins.input", return_value="q"):
    +                _session_browse_picker(sessions)
    +
    +        output = capsys.readouterr().out
    +        # All 4 entries should be shown
    +        assert "1." in output
    +        assert "2." in output
    +        assert "3." in output
    +        assert "4." in output
    +
    +    def test_fallback_shows_title_over_preview(self, capsys):
    +        """When a session has a title, show it instead of the preview."""
    +        sessions = [{
    +            "id": "test_001",
    +            "source": "cli",
    +            "title": "My Cool Project",
    +            "preview": "some preview text",
    +            "last_active": time.time(),
    +        }]
    +
    +        import builtins
    +        original_import = builtins.__import__
    +
    +        def mock_import(name, *args, **kwargs):
    +            if name == "curses":
    +                raise ImportError("no curses")
    +            return original_import(name, *args, **kwargs)
    +
    +        with patch.object(builtins, "__import__", side_effect=mock_import):
    +            with patch("builtins.input", return_value="q"):
    +                _session_browse_picker(sessions)
    +
    +        output = capsys.readouterr().out
    +        assert "My Cool Project" in output
    +
    +    def test_fallback_shows_preview_when_no_title(self, capsys):
    +        """When no title, show preview."""
    +        sessions = [{
    +            "id": "test_002",
    +            "source": "cli",
    +            "title": None,
    +            "preview": "Hello world test message",
    +            "last_active": time.time(),
    +        }]
    +
    +        import builtins
    +        original_import = builtins.__import__
    +
    +        def mock_import(name, *args, **kwargs):
    +            if name == "curses":
    +                raise ImportError("no curses")
    +            return original_import(name, *args, **kwargs)
    +
    +        with patch.object(builtins, "__import__", side_effect=mock_import):
    +            with patch("builtins.input", return_value="q"):
    +                _session_browse_picker(sessions)
    +
    +        output = capsys.readouterr().out
    +        assert "Hello world test message" in output
    +
    +    def test_fallback_shows_id_when_no_title_or_preview(self, capsys):
    +        """When neither title nor preview, show session ID."""
    +        sessions = [{
    +            "id": "test_003_fallback",
    +            "source": "cli",
    +            "title": None,
    +            "preview": "",
    +            "last_active": time.time(),
    +        }]
    +
    +        import builtins
    +        original_import = builtins.__import__
    +
    +        def mock_import(name, *args, **kwargs):
    +            if name == "curses":
    +                raise ImportError("no curses")
    +            return original_import(name, *args, **kwargs)
    +
    +        with patch.object(builtins, "__import__", side_effect=mock_import):
    +            with patch("builtins.input", return_value="q"):
    +                _session_browse_picker(sessions)
    +
    +        output = capsys.readouterr().out
    +        assert "test_003_fallback" in output
    +
    +
    +# ─── Curses-based picker (mocked curses) ────────────────────────────────────
    +
    +class TestCursesBrowse:
    +    """Tests for the curses-based interactive picker via simulated key sequences."""
    +
    +    def _run_with_keys(self, sessions, key_sequence):
    +        """Simulate running the curses picker with a given key sequence."""
    +        import curses
    +
    +        # Build a mock stdscr that returns keys from the sequence
    +        mock_stdscr = MagicMock()
    +        mock_stdscr.getmaxyx.return_value = (30, 120)
    +        mock_stdscr.getch.side_effect = key_sequence
    +
    +        # Capture what curses.wrapper receives and call it with our mock
    +        with patch("curses.wrapper") as mock_wrapper:
    +            # When wrapper is called, invoke the function with our mock stdscr
    +            def run_inner(func):
    +                try:
    +                    func(mock_stdscr)
    +                except StopIteration:
    +                    pass  # key sequence exhausted
    +
    +            mock_wrapper.side_effect = run_inner
    +            with patch("curses.curs_set"):
    +                with patch("curses.has_colors", return_value=False):
    +                    return _session_browse_picker(sessions)
    +
    +    def test_enter_selects_first_session(self):
    +        sessions = _make_sessions(3)
    +        result = self._run_with_keys(sessions, [10])  # Enter key
    +        assert result == sessions[0]["id"]
    +
    +    def test_down_then_enter_selects_second(self):
    +        import curses
    +        sessions = _make_sessions(3)
    +        result = self._run_with_keys(sessions, [curses.KEY_DOWN, 10])
    +        assert result == sessions[1]["id"]
    +
    +    def test_down_down_enter_selects_third(self):
    +        import curses
    +        sessions = _make_sessions(5)
    +        result = self._run_with_keys(sessions, [curses.KEY_DOWN, curses.KEY_DOWN, 10])
    +        assert result == sessions[2]["id"]
    +
    +    def test_up_wraps_to_last(self):
    +        import curses
    +        sessions = _make_sessions(3)
    +        result = self._run_with_keys(sessions, [curses.KEY_UP, 10])
    +        assert result == sessions[2]["id"]
    +
    +    def test_escape_cancels(self):
    +        sessions = _make_sessions(3)
    +        result = self._run_with_keys(sessions, [27])  # Esc
    +        assert result is None
    +
    +    def test_q_cancels(self):
    +        sessions = _make_sessions(3)
    +        result = self._run_with_keys(sessions, [ord('q')])
    +        assert result is None
    +
    +    def test_type_to_filter_then_enter(self):
    +        """Typing characters filters the list, Enter selects from filtered."""
    +        import curses
    +        sessions = [
    +            {"id": "s1", "source": "cli", "title": "Alpha project", "preview": "", "last_active": time.time()},
    +            {"id": "s2", "source": "cli", "title": "Beta project", "preview": "", "last_active": time.time()},
    +            {"id": "s3", "source": "cli", "title": "Gamma project", "preview": "", "last_active": time.time()},
    +        ]
    +        # Type "Beta" then Enter — should select s2
    +        keys = [ord(c) for c in "Beta"] + [10]
    +        result = self._run_with_keys(sessions, keys)
    +        assert result == "s2"
    +
    +    def test_filter_no_match_enter_does_nothing(self):
    +        """When filter produces no results, Enter shouldn't select."""
    +        sessions = _make_sessions(3)
    +        keys = [ord(c) for c in "zzzznonexistent"] + [10]
    +        result = self._run_with_keys(sessions, keys)
    +        assert result is None
    +
    +    def test_backspace_removes_filter_char(self):
    +        """Backspace removes the last character from the filter."""
    +        import curses
    +        sessions = [
    +            {"id": "s1", "source": "cli", "title": "Alpha", "preview": "", "last_active": time.time()},
    +            {"id": "s2", "source": "cli", "title": "Beta", "preview": "", "last_active": time.time()},
    +        ]
    +        # Type "Bet", backspace, backspace, backspace (clears filter), then Enter (selects first)
    +        keys = [ord('B'), ord('e'), ord('t'), 127, 127, 127, 10]
    +        result = self._run_with_keys(sessions, keys)
    +        assert result == "s1"
    +
    +    def test_escape_clears_filter_first(self):
    +        """First Esc clears the search text, second Esc exits."""
    +        import curses
    +        sessions = _make_sessions(3)
    +        # Type "ab" then Esc (clears filter) then Enter (selects first)
    +        keys = [ord('a'), ord('b'), 27, 10]
    +        result = self._run_with_keys(sessions, keys)
    +        assert result == sessions[0]["id"]
    +
    +    def test_filter_matches_preview(self):
    +        """Typing should match against session preview text."""
    +        sessions = [
    +            {"id": "s1", "source": "cli", "title": None, "preview": "Set up Minecraft server", "last_active": time.time()},
    +            {"id": "s2", "source": "cli", "title": None, "preview": "Review PR 438", "last_active": time.time()},
    +        ]
    +        keys = [ord(c) for c in "Mine"] + [10]
    +        result = self._run_with_keys(sessions, keys)
    +        assert result == "s1"
    +
    +    def test_filter_matches_source(self):
    +        """Typing a source name should filter by source."""
    +        sessions = [
    +            {"id": "s1", "source": "telegram", "title": "TG session", "preview": "", "last_active": time.time()},
    +            {"id": "s2", "source": "cli", "title": "CLI session", "preview": "", "last_active": time.time()},
    +        ]
    +        keys = [ord(c) for c in "telegram"] + [10]
    +        result = self._run_with_keys(sessions, keys)
    +        assert result == "s1"
    +
    +    def test_q_quits_when_no_filter_active(self):
    +        """When no search text is active, 'q' should quit (not filter)."""
    +        sessions = _make_sessions(3)
    +        result = self._run_with_keys(sessions, [ord('q')])
    +        assert result is None
    +
    +    def test_q_types_into_filter_when_filter_active(self):
    +        """When search text is already active, 'q' should add to filter, not quit."""
    +        sessions = [
    +            {"id": "s1", "source": "cli", "title": "the sequel", "preview": "", "last_active": time.time()},
    +            {"id": "s2", "source": "cli", "title": "other thing", "preview": "", "last_active": time.time()},
    +        ]
    +        # Type "se" first (activates filter, matches "the sequel")
    +        # Then type "q" — should add 'q' to filter (filter="seq"), NOT quit
    +        # "seq" still matches "the sequel" → Enter selects it
    +        keys = [ord('s'), ord('e'), ord('q'), 10]
    +        result = self._run_with_keys(sessions, keys)
    +        assert result == "s1"  # "the sequel" matches "seq"
    +
    +
    +# ─── Argument parser registration ──────────────────────────────────────────
    +
    +class TestSessionBrowseArgparse:
    +    """Verify the 'browse' subcommand is properly registered."""
    +
    +    def test_browse_subcommand_exists(self):
    +        """hermes sessions browse should be parseable."""
    +        from hermes_cli.main import main as _main_entry
    +
    +        # We can't run main(), but we can import and test the parser setup
    +        # by checking that argparse doesn't error on "sessions browse"
    +        import argparse
    +        # Re-create the parser portion
    +        # Instead, let's just verify the import works and the function exists
    +        from hermes_cli.main import _session_browse_picker
    +        assert callable(_session_browse_picker)
    +
    +    def test_browse_default_limit_is_50(self):
    +        """The default --limit for browse should be 50."""
    +        # This test verifies at the argparse level
    +        # We test by running the parse on "sessions browse" args
    +        # Since we can't easily extract the subparser, verify via the
    +        # _session_browse_picker accepting large lists
    +        sessions = _make_sessions(50)
    +        assert len(sessions) == 50
    +
    +
    +# ─── Integration: cmd_sessions browse action ────────────────────────────────
    +
    +class TestCmdSessionsBrowse:
    +    """Integration tests for the 'browse' action in cmd_sessions."""
    +
    +    def test_browse_no_sessions_prints_message(self, capsys):
    +        """When no sessions exist, _session_browse_picker returns None and prints message."""
    +        result = _session_browse_picker([])
    +        assert result is None
    +        output = capsys.readouterr().out
    +        assert "No sessions found" in output
    +
    +    def test_browse_with_source_filter(self):
    +        """The --source flag should be passed to list_sessions_rich."""
    +        sessions = [
    +            {"id": "s1", "source": "cli", "title": "CLI only", "preview": "", "last_active": time.time()},
    +        ]
    +
    +        import builtins
    +        original_import = builtins.__import__
    +
    +        def mock_import(name, *args, **kwargs):
    +            if name == "curses":
    +                raise ImportError("no curses")
    +            return original_import(name, *args, **kwargs)
    +
    +        with patch.object(builtins, "__import__", side_effect=mock_import):
    +            with patch("builtins.input", return_value="1"):
    +                result = _session_browse_picker(sessions)
    +
    +        assert result == "s1"
    +
    +
    +# ─── Edge cases ──────────────────────────────────────────────────────────────
    +
    +class TestEdgeCases:
    +    """Edge case handling for the session browser."""
    +
    +    def test_sessions_with_missing_fields(self):
    +        """Sessions with missing optional fields should not crash."""
    +        sessions = [
    +            {"id": "minimal_001", "source": "cli"},  # No title, preview, last_active
    +        ]
    +
    +        import builtins
    +        original_import = builtins.__import__
    +
    +        def mock_import(name, *args, **kwargs):
    +            if name == "curses":
    +                raise ImportError("no curses")
    +            return original_import(name, *args, **kwargs)
    +
    +        with patch.object(builtins, "__import__", side_effect=mock_import):
    +            with patch("builtins.input", return_value="1"):
    +                result = _session_browse_picker(sessions)
    +
    +        assert result == "minimal_001"
    +
    +    def test_single_session(self):
    +        """A single session in the list should work fine."""
    +        sessions = [
    +            {"id": "only_one", "source": "cli", "title": "Solo", "preview": "", "last_active": time.time()},
    +        ]
    +
    +        import builtins
    +        original_import = builtins.__import__
    +
    +        def mock_import(name, *args, **kwargs):
    +            if name == "curses":
    +                raise ImportError("no curses")
    +            return original_import(name, *args, **kwargs)
    +
    +        with patch.object(builtins, "__import__", side_effect=mock_import):
    +            with patch("builtins.input", return_value="1"):
    +                result = _session_browse_picker(sessions)
    +
    +        assert result == "only_one"
    +
    +    def test_long_title_truncated_in_fallback(self, capsys):
    +        """Very long titles should be truncated in fallback mode."""
    +        sessions = [{
    +            "id": "long_title_001",
    +            "source": "cli",
    +            "title": "A" * 100,
    +            "preview": "",
    +            "last_active": time.time(),
    +        }]
    +
    +        import builtins
    +        original_import = builtins.__import__
    +
    +        def mock_import(name, *args, **kwargs):
    +            if name == "curses":
    +                raise ImportError("no curses")
    +            return original_import(name, *args, **kwargs)
    +
    +        with patch.object(builtins, "__import__", side_effect=mock_import):
    +            with patch("builtins.input", return_value="q"):
    +                _session_browse_picker(sessions)
    +
    +        output = capsys.readouterr().out
    +        # Title should be truncated to 50 chars with "..."
    +        assert "..." in output
    +
    +    def test_relative_time_formatting(self, capsys):
    +        """Verify various time deltas format correctly."""
    +        now = time.time()
    +        sessions = [
    +            {"id": "recent", "source": "cli", "title": None, "preview": "just now test", "last_active": now},
    +            {"id": "hour_ago", "source": "cli", "title": None, "preview": "hour ago test", "last_active": now - 7200},
    +            {"id": "days_ago", "source": "cli", "title": None, "preview": "days ago test", "last_active": now - 259200},
    +        ]
    +
    +        import builtins
    +        original_import = builtins.__import__
    +
    +        def mock_import(name, *args, **kwargs):
    +            if name == "curses":
    +                raise ImportError("no curses")
    +            return original_import(name, *args, **kwargs)
    +
    +        with patch.object(builtins, "__import__", side_effect=mock_import):
    +            with patch("builtins.input", return_value="q"):
    +                _session_browse_picker(sessions)
    +
    +        output = capsys.readouterr().out
    +        assert "just now" in output
    +        assert "2h ago" in output
    +        assert "3d ago" in output
    ```
- **#31709: feat(cli,gateway): /resume accepts position numbers**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`; implication `superseded`
  - files: `cli.py`, `gateway/run.py`, `locales/af.yaml`, `locales/de.yaml`, `locales/en.yaml`, `locales/es.yaml`, `locales/fr.yaml`, `locales/ga.yaml`, `locales/hu.yaml`, `locales/it.yaml`, `locales/ja.yaml`, `locales/ko.yaml`, `locales/pt.yaml`, `locales/ru.yaml`, `locales/tr.yaml`, `locales/uk.yaml`, `locales/zh-hant.yaml`, `locales/zh.yaml`, `scripts/release.py`, `tests/cli/test_cli_init.py` (+2 more)
  - fix hunks:
  - `cli.py`

    ```diff
    @@ -6172,15 +6172,16 @@ def _show_recent_sessions(self, *, reason: str = "history", limit: int = 10) ->
             else:
                 print("  Recent sessions:")
             print()
    -        print(f"  {'Title':<32} {'Preview':<40} {'Last Active':<13} {'ID'}")
    -        print(f"  {'─' * 32} {'─' * 40} {'─' * 13} {'─' * 24}")
    -        for session in sessions:
    +        print(f"  {'#':<3} {'Title':<32} {'Preview':<40} {'Last Active':<13} {'ID'}")
    +        print(f"  {'─' * 3} {'─' * 32} {'─' * 40} {'─' * 13} {'─' * 24}")
    +        for idx, session in enumerate(sessions, start=1):
                 title = session.get("title") or "—"
                 preview = (session.get("preview") or "")[:38]
                 last_active = _relative_time(session.get("last_active"))
    -            print(f"  {title:<32} {preview:<40} {last_active:<13} {session['id']}")
    +            print(f"  {idx:<3} {title:<32} {preview:<40} {last_active:<13} {session['id']}")
             print()
    -        print("  Use /resume <session id or title> to continue where you left off.")
    +        print("  Use /resume <number>, /resume <session id>, or /resume <session title> to continue.")
    +        print("  Example: /resume 2")
             print()
             return True
     
    ```

    ```diff
    @@ -6525,7 +6526,7 @@ def _handle_resume_command(self, cmd_original: str) -> None:
             target = parts[1].strip() if len(parts) > 1 else ""
     
             if not target:
    -            _cprint("  Usage: /resume <session_id_or_title>")
    +            _cprint("  Usage: /resume <number|session_id_or_title>")
                 if self._show_recent_sessions(reason="resume"):
                     return
                 _cprint("  Tip:   Use /history or `hermes sessions list` to find sessions.")
    ```

    ```diff
    @@ -6536,10 +6537,20 @@ def _handle_resume_command(self, cmd_original: str) -> None:
                 _cprint(f"  {format_session_db_unavailable()}")
                 return
     
    -        # Resolve title or ID
    -        from hermes_cli.main import _resolve_session_by_name_or_id
    -        resolved = _resolve_session_by_name_or_id(target)
    -        target_id = resolved or target
    +        # Resolve numbered selection, title, or ID
    +        if target.isdigit():
    +            sessions = self._list_recent_sessions(limit=10)
    +            index = int(target)
    +            if index < 1 or index > len(sessions):
    +                _cprint(f"  Resume index {index} is out of range.")
    +                _cprint("  Use /resume with no arguments to see available sessions.")
    +                return
    +            selected = sessions[index - 1]
    +            target_id = selected["id"]
    +        else:
    +            from hermes_cli.main import _resolve_session_by_name_or_id
    +            resolved = _resolve_session_by_name_or_id(target)
    +            target_id = resolved or target
     
             session_meta = self._session_db.get_session(target_id)
             if not session_meta:
    ```
  - `gateway/run.py`

    ```diff
    @@ -12741,7 +12741,7 @@ async def _handle_title_command(self, event: MessageEvent) -> str:
                     return t("gateway.title.current_no_title", session_id=session_id)
     
         async def _handle_resume_command(self, event: MessageEvent) -> str:
    -        """Handle /resume command — switch to a previously-named session."""
    +        """Handle /resume command — list or switch to a previous session."""
             if not self._session_db:
                 from hermes_state import format_session_db_unavailable
                 return format_session_db_unavailable(prefix=t("gateway.shared.session_db_unavailable_prefix"))
    ```

    ```diff
    @@ -12750,30 +12750,44 @@ async def _handle_resume_command(self, event: MessageEvent) -> str:
             session_key = self._session_key_for_source(source)
             name = event.get_command_args().strip()
     
    +        def _list_titled_sessions() -> list[dict]:
    +            user_source = source.platform.value if source.platform else None
    +            sessions = self._session_db.list_sessions_rich(source=user_source, limit=10)
    +            return [s for s in sessions if s.get("title")][:10]
    +
             if not name:
                 # List recent titled sessions for this user/platform
                 try:
    -                user_source = source.platform.value if source.platform else None
    -                sessions = self._session_db.list_sessions_rich(
    -                    source=user_source, limit=10
    -                )
    -                titled = [s for s in sessions if s.get("title")]
    +                titled = _list_titled_sessions()
                     if not titled:
                         return t("gateway.resume.no_named_sessions")
                     lines = [t("gateway.resume.list_header")]
    -                for s in titled[:10]:
    +                for idx, s in enumerate(titled[:10], start=1):
                         title = s["title"]
                         preview = s.get("preview", "")[:40]
                         preview_part = t("gateway.resume.list_preview_suffix", preview=preview) if preview else ""
    -                    lines.append(t("gateway.resume.list_item", title=title, preview_part=preview_part))
    -                lines.append(t("gateway.resume.list_footer"))
    +                    lines.append(t("gateway.resume.list_item_numbered", index=idx, title=title, preview_part=preview_part))
    +                lines.append(t("gateway.resume.list_footer_numbered"))
                     return "\n".join(lines)
                 except Exception as e:
                     logger.debug("Failed to list titled sessions: %s", e)
                     return t("gateway.resume.list_failed", error=e)
     
    -        # Resolve the name to a session ID.
    -        target_id = self._session_db.resolve_session_by_title(name)
    +        # Resolve a numbered choice or a title to a session ID.
    +        if name.isdigit():
    +            try:
    +                titled = _list_titled_sessions()
    +            except Exception as e:
    +                logger.debug("Failed to list titled sessions for numeric resume: %s", e)
    +                return t("gateway.resume.list_failed", error=e)
    +            index = int(name)
    +            if index < 1 or index > len(titled):
    +                return t("gateway.resume.out_of_range", index=index)
    +            target = titled[index - 1]
    +            target_id = target.get("id")
    +            name = target.get("title") or name
    +        else:
    +            target_id = self._session_db.resolve_session_by_title(name)
             if not target_id:
                 return t("gateway.resume.not_found", name=name)
             # Compression creates child continuations that hold the live transcript.
    ```
  - `locales/af.yaml`

    ```diff
    @@ -222,9 +222,12 @@ gateway:
         no_named_sessions:     "Geen benoemde sessies gevind nie.\nGebruik `/title My Sessie` om jou huidige sessie 'n naam te gee, en dan `/resume My Sessie` om later daarheen terug te keer."
         list_header:           "📋 **Benoemde Sessies**\n"
         list_item:             "• **{title}**{preview_part}"
    +    list_item_numbered:    "{index}. **{title}**{preview_part}"
         list_preview_suffix:   " — _{preview}_"
         list_footer:           "\nGebruik: `/resume <session name>`"
    +    list_footer_numbered:  "\nGebruik: `/resume <sessienaam>` of `/resume <nommer>` (bv. `/resume 1` vir die mees onlangse)"
         list_failed:           "Kon nie sessies lys nie: {error}"
    +    out_of_range:          "Hervat-indeks {index} is buite bereik.\nGebruik `/resume` sonder argumente om beskikbare sessies te sien."
         not_found:             "Geen sessie gevind wat by '**{name}**' pas nie.\nGebruik `/resume` sonder argumente om beskikbare sessies te sien."
         already_on:            "📌 Reeds op sessie **{name}**."
         switch_failed:         "Kon nie sessie verander nie."
    ```
  - `locales/de.yaml`

    ```diff
    @@ -222,9 +222,12 @@ gateway:
         no_named_sessions:     "Keine benannten Sitzungen gefunden.\nVerwenden Sie `/title Meine Sitzung`, um die aktuelle Sitzung zu benennen, dann `/resume Meine Sitzung`, um später dorthin zurückzukehren."
         list_header:           "📋 **Benannte Sitzungen**\n"
         list_item:             "• **{title}**{preview_part}"
    +    list_item_numbered:    "{index}. **{title}**{preview_part}"
         list_preview_suffix:   " — _{preview}_"
         list_footer:           "\nVerwendung: `/resume <Sitzungsname>`"
    +    list_footer_numbered:  "\nVerwendung: `/resume <Sitzungsname>` oder `/resume <Nummer>` (z. B. `/resume 1` für die zuletzt verwendete)"
         list_failed:           "Sitzungen konnten nicht aufgelistet werden: {error}"
    +    out_of_range:          "Wiederaufnahme-Index {index} liegt außerhalb des gültigen Bereichs.\nVerwenden Sie `/resume` ohne Argumente, um verfügbare Sitzungen anzuzeigen."
         not_found:             "Keine Sitzung passend zu '**{name}**' gefunden.\nVerwenden Sie `/resume` ohne Argumente, um verfügbare Sitzungen zu sehen."
         already_on:            "📌 Bereits in Sitzung **{name}**."
         switch_failed:         "Sitzungswechsel fehlgeschlagen."
    ```
  - `locales/en.yaml`

    ```diff
    @@ -237,9 +237,12 @@ gateway:
         no_named_sessions:     "No named sessions found.\nUse `/title My Session` to name your current session, then `/resume My Session` to return to it later."
         list_header:           "📋 **Named Sessions**\n"
         list_item:             "• **{title}**{preview_part}"
    +    list_item_numbered:    "{index}. **{title}**{preview_part}"
         list_preview_suffix:   " — _{preview}_"
         list_footer:           "\nUsage: `/resume <session name>`"
    +    list_footer_numbered:  "\nUsage: `/resume <session name>` or `/resume <number>` (e.g. `/resume 1` for the most recent)"
         list_failed:           "Could not list sessions: {error}"
    +    out_of_range:          "Resume index {index} is out of range.\nUse `/resume` with no arguments to see available sessions."
         not_found:             "No session found matching '**{name}**'.\nUse `/resume` with no arguments to see available sessions."
         already_on:            "📌 Already on session **{name}**."
         switch_failed:         "Failed to switch session."
    ```
  - `locales/es.yaml`

    ```diff
    @@ -222,9 +222,12 @@ gateway:
         no_named_sessions:     "No se encontraron sesiones con nombre.\nUsa `/title Mi sesión` para nombrar la sesión actual y luego `/resume Mi sesión` para volver a ella."
         list_header:           "📋 **Sesiones con nombre**\n"
         list_item:             "• **{title}**{preview_part}"
    +    list_item_numbered:    "{index}. **{title}**{preview_part}"
         list_preview_suffix:   " — _{preview}_"
         list_footer:           "\nUso: `/resume <nombre de sesión>`"
    +    list_footer_numbered:  "\nUso: `/resume <nombre de sesión>` o `/resume <número>` (p. ej. `/resume 1` para la más reciente)"
         list_failed:           "No se pudieron listar las sesiones: {error}"
    +    out_of_range:          "El índice de reanudación {index} está fuera de rango.\nUsa `/resume` sin argumentos para ver las sesiones disponibles."
         not_found:             "No se encontró ninguna sesión que coincida con '**{name}**'.\nUsa `/resume` sin argumentos para ver las sesiones disponibles."
         already_on:            "📌 Ya estás en la sesión **{name}**."
         switch_failed:         "No se pudo cambiar de sesión."
    ```
  - `locales/fr.yaml`

    ```diff
    @@ -222,9 +222,12 @@ gateway:
         no_named_sessions:     "Aucune session nommée trouvée.\nUtilisez `/title Ma session` pour nommer la session actuelle, puis `/resume Ma session` pour y revenir plus tard."
         list_header:           "📋 **Sessions nommées**\n"
         list_item:             "• **{title}**{preview_part}"
    +    list_item_numbered:    "{index}. **{title}**{preview_part}"
         list_preview_suffix:   " — _{preview}_"
         list_footer:           "\nUsage : `/resume <nom de session>`"
    +    list_footer_numbered:  "\nUtilisation : `/resume <nom de session>` ou `/resume <numéro>` (par exemple `/resume 1` pour la plus récente)"
         list_failed:           "Impossible de lister les sessions : {error}"
    +    out_of_range:          "L'index de reprise {index} est hors limites.\nUtilisez `/resume` sans arguments pour voir les sessions disponibles."
         not_found:             "Aucune session correspondant à '**{name}**' trouvée.\nUtilisez `/resume` sans argument pour voir les sessions disponibles."
         already_on:            "📌 Déjà sur la session **{name}**."
         switch_failed:         "Échec du changement de session."
    ```
  - `locales/ga.yaml`

    ```diff
    @@ -226,9 +226,12 @@ gateway:
         no_named_sessions:     "Níor aimsíodh aon seisiún ainmnithe.\nÚsáid `/title M'Ainm Seisiúin` chun do sheisiún reatha a ainmniú, ansin `/resume M'Ainm Seisiúin` chun filleadh air níos déanaí."
         list_header:           "📋 **Seisiúin Ainmnithe**\n"
         list_item:             "• **{title}**{preview_part}"
    +    list_item_numbered:    "{index}. **{title}**{preview_part}"
         list_preview_suffix:   " — _{preview}_"
         list_footer:           "\nÚsáid: `/resume <session name>`"
    +    list_footer_numbered:  "\nÚsáid: `/resume <ainm seisiúin>` nó `/resume <uimhir>` (m.sh. `/resume 1` don cheann is déanaí)"
         list_failed:           "Níorbh fhéidir seisiúin a liostáil: {error}"
    +    out_of_range:          "Tá an t-innéacs atosaithe {index} as raon.\nÚsáid `/resume` gan argóintí chun na seisiúin atá ar fáil a fheiceáil."
         not_found:             "Níor aimsíodh aon seisiún ag teacht le '**{name}**'.\nÚsáid `/resume` gan argóintí chun seisiúin atá ar fáil a fheiceáil."
         already_on:            "📌 Cheana ar an seisiún **{name}**."
         switch_failed:         "Theip ar athrú seisiúin."
    ```
  - `locales/hu.yaml`

    ```diff
    @@ -222,9 +222,12 @@ gateway:
         no_named_sessions:     "Nem található elnevezett munkamenet.\nHasználd a `/title Saját munkamenet` parancsot a jelenlegi munkamenet elnevezéséhez, majd a `/resume Saját munkamenet` paranccsal térhetsz vissza hozzá."
         list_header:           "📋 **Elnevezett munkamenetek**\n"
         list_item:             "• **{title}**{preview_part}"
    +    list_item_numbered:    "{index}. **{title}**{preview_part}"
         list_preview_suffix:   " — _{preview}_"
         list_footer:           "\nHasználat: `/resume <munkamenet neve>`"
    +    list_footer_numbered:  "\nHasználat: `/resume <munkamenet neve>` vagy `/resume <szám>` (pl. `/resume 1` a legutóbbihoz)"
         list_failed:           "Nem sikerült listázni a munkameneteket: {error}"
    +    out_of_range:          "A folytatási index ({index}) tartományon kívül esik.\nA `/resume` argumentumok nélküli használata megjeleníti az elérhető munkameneteket."
         not_found:             "Nem található '**{name}**' nevű munkamenet.\nArgumentumok nélkül használd a `/resume` parancsot az elérhető munkamenetek megtekintéséhez."
         already_on:            "📌 Már a **{name}** munkamenetben vagy."
         switch_failed:         "Nem sikerült munkamenetet váltani."
    ```
  - `locales/it.yaml`

    ```diff
    @@ -222,9 +222,12 @@ gateway:
         no_named_sessions:     "Nessuna sessione con nome trovata.\nUsa `/title My Session` per dare un nome alla sessione attuale, poi `/resume My Session` per tornare a essa in seguito."
         list_header:           "📋 **Sessioni con nome**\n"
         list_item:             "• **{title}**{preview_part}"
    +    list_item_numbered:    "{index}. **{title}**{preview_part}"
         list_preview_suffix:   " — _{preview}_"
         list_footer:           "\nUso: `/resume <session name>`"
    +    list_footer_numbered:  "\nUso: `/resume <nome sessione>` o `/resume <numero>` (es. `/resume 1` per la più recente)"
         list_failed:           "Impossibile elencare le sessioni: {error}"
    +    out_of_range:          "L'indice di ripresa {index} è fuori intervallo.\nUsa `/resume` senza argomenti per vedere le sessioni disponibili."
         not_found:             "Nessuna sessione trovata corrispondente a '**{name}**'.\nUsa `/resume` senza argomenti per vedere le sessioni disponibili."
         already_on:            "📌 Già nella sessione **{name}**."
         switch_failed:         "Cambio di sessione non riuscito."
    ```
  - `locales/ja.yaml`

    ```diff
    @@ -222,9 +222,12 @@ gateway:
         no_named_sessions:     "名前付きセッションが見つかりません。\n`/title セッション名` で現在のセッションに名前を付けると、後で `/resume セッション名` で戻れます。"
         list_header:           "📋 **名前付きセッション**\n"
         list_item:             "• **{title}**{preview_part}"
    +    list_item_numbered:    "{index}. **{title}**{preview_part}"
         list_preview_suffix:   " — _{preview}_"
         list_footer:           "\n使い方: `/resume <セッション名>`"
    +    list_footer_numbered:  "\n使い方: `/resume <セッション名>` または `/resume <番号>`（例: 最新のセッションには `/resume 1`）"
         list_failed:           "セッションを一覧表示できませんでした: {error}"
    +    out_of_range:          "再開インデックス {index} は範囲外です。\n引数なしで `/resume` を実行すると、利用可能なセッションが表示されます。"
         not_found:             "'**{name}**' に一致するセッションが見つかりません。\n引数なしで `/resume` を実行すると利用可能なセッションを表示します。"
         already_on:            "📌 既にセッション **{name}** にいます。"
         switch_failed:         "セッションの切り替えに失敗しました。"
    ```
  - `locales/ko.yaml`

    ```diff
    @@ -222,9 +222,12 @@ gateway:
         no_named_sessions:     "이름이 지정된 세션이 없습니다.\n현재 세션에 이름을 지정하려면 `/title 내 세션`을 사용하고, 나중에 `/resume 내 세션`으로 돌아오세요."
         list_header:           "📋 **이름이 지정된 세션**\n"
         list_item:             "• **{title}**{preview_part}"
    +    list_item_numbered:    "{index}. **{title}**{preview_part}"
         list_preview_suffix:   " — _{preview}_"
         list_footer:           "\n사용법: `/resume <session name>`"
    +    list_footer_numbered:  "\n사용법: `/resume <세션 이름>` 또는 `/resume <번호>` (예: 가장 최근 세션은 `/resume 1`)"
         list_failed:           "세션 목록을 가져올 수 없습니다: {error}"
    +    out_of_range:          "재개 인덱스 {index}이(가) 범위를 벗어났습니다.\n인자 없이 `/resume`을 실행하면 사용 가능한 세션이 표시됩니다."
         not_found:             "'**{name}**'와 일치하는 세션이 없습니다.\n사용 가능한 세션을 보려면 인수 없이 `/resume`을 사용하세요."
         already_on:            "📌 이미 **{name}** 세션에 있습니다."
         switch_failed:         "세션 전환에 실패했습니다."
    ```
  - `locales/pt.yaml`

    ```diff
    @@ -222,9 +222,12 @@ gateway:
         no_named_sessions:     "Não foram encontradas sessões com nome.\nUsa `/title A minha sessão` para nomear a sessão atual e depois `/resume A minha sessão` para voltar a ela."
         list_header:           "📋 **Sessões com nome**\n"
         list_item:             "• **{title}**{preview_part}"
    +    list_item_numbered:    "{index}. **{title}**{preview_part}"
         list_preview_suffix:   " — _{preview}_"
         list_footer:           "\nUso: `/resume <nome da sessão>`"
    +    list_footer_numbered:  "\nUso: `/resume <nome da sessão>` ou `/resume <número>` (ex.: `/resume 1` para a mais recente)"
         list_failed:           "Não foi possível listar as sessões: {error}"
    +    out_of_range:          "O índice de retomada {index} está fora do intervalo.\nUse `/resume` sem argumentos para ver as sessões disponíveis."
         not_found:             "Não foi encontrada nenhuma sessão correspondente a '**{name}**'.\nUsa `/resume` sem argumentos para ver as sessões disponíveis."
         already_on:            "📌 Já estás na sessão **{name}**."
         switch_failed:         "Falha ao mudar de sessão."
    ```
  - `locales/ru.yaml`

    ```diff
    @@ -222,9 +222,12 @@ gateway:
         no_named_sessions:     "Именованных сеансов не найдено.\nИспользуйте `/title Мой сеанс`, чтобы назвать текущий сеанс, затем `/resume Мой сеанс`, чтобы вернуться к нему позже."
         list_header:           "📋 **Именованные сеансы**\n"
         list_item:             "• **{title}**{preview_part}"
    +    list_item_numbered:    "{index}. **{title}**{preview_part}"
         list_preview_suffix:   " — _{preview}_"
         list_footer:           "\nИспользование: `/resume <название сеанса>`"
    +    list_footer_numbered:  "\nИспользование: `/resume <имя сеанса>` или `/resume <номер>` (например, `/resume 1` для самого недавнего)"
         list_failed:           "Не удалось получить список сеансов: {error}"
    +    out_of_range:          "Индекс возобновления {index} вне диапазона.\nИспользуйте `/resume` без аргументов, чтобы увидеть доступные сеансы."
         not_found:             "Сеанс, соответствующий '**{name}**', не найден.\nИспользуйте `/resume` без аргументов, чтобы увидеть доступные сеансы."
         already_on:            "📌 Уже в сеансе **{name}**."
         switch_failed:         "Не удалось переключить сеанс."
    ```
  - `locales/tr.yaml`

    ```diff
    @@ -222,9 +222,12 @@ gateway:
         no_named_sessions:     "Adlandırılmış oturum bulunamadı.\nMevcut oturumu adlandırmak için `/title Oturumum`, daha sonra geri dönmek için `/resume Oturumum` kullanın."
         list_header:           "📋 **Adlandırılmış Oturumlar**\n"
         list_item:             "• **{title}**{preview_part}"
    +    list_item_numbered:    "{index}. **{title}**{preview_part}"
         list_preview_suffix:   " — _{preview}_"
         list_footer:           "\nKullanım: `/resume <oturum adı>`"
    +    list_footer_numbered:  "\nKullanım: `/resume <oturum adı>` veya `/resume <numara>` (örn. en yenisi için `/resume 1`)"
         list_failed:           "Oturumlar listelenemedi: {error}"
    +    out_of_range:          "Devam endeksi {index} aralık dışında.\nKullanılabilir oturumları görmek için `/resume` komutunu argümansız çalıştırın."
         not_found:             "'**{name}**' ile eşleşen oturum bulunamadı.\nKullanılabilir oturumları görmek için argümansız `/resume` kullanın."
         already_on:            "📌 Zaten **{name}** oturumundasınız."
         switch_failed:         "Oturum değiştirilemedi."
    ```
  - `locales/uk.yaml`

    ```diff
    @@ -222,9 +222,12 @@ gateway:
         no_named_sessions:     "Іменованих сеансів не знайдено.\nВикористайте `/title Мій сеанс`, щоб назвати поточний сеанс, потім `/resume Мій сеанс`, щоб повернутися до нього."
         list_header:           "📋 **Іменовані сеанси**\n"
         list_item:             "• **{title}**{preview_part}"
    +    list_item_numbered:    "{index}. **{title}**{preview_part}"
         list_preview_suffix:   " — _{preview}_"
         list_footer:           "\nВикористання: `/resume <назва сеансу>`"
    +    list_footer_numbered:  "\nВикористання: `/resume <назва сесії>` або `/resume <номер>` (наприклад, `/resume 1` для найновішої)"
         list_failed:           "Не вдалося отримати список сеансів: {error}"
    +    out_of_range:          "Індекс відновлення {index} поза межами діапазону.\nВикористовуйте `/resume` без аргументів, щоб переглянути доступні сесії."
         not_found:             "Сеанс, що відповідає '**{name}**', не знайдено.\nВикористайте `/resume` без аргументів, щоб побачити доступні сеанси."
         already_on:            "📌 Уже в сеансі **{name}**."
         switch_failed:         "Не вдалося переключити сеанс."
    ```
  - `locales/zh-hant.yaml`

    ```diff
    @@ -222,9 +222,12 @@ gateway:
         no_named_sessions:     "找不到已命名的工作階段。\n使用 `/title 我的工作階段` 為目前工作階段命名，然後使用 `/resume 我的工作階段` 返回。"
         list_header:           "📋 **已命名工作階段**\n"
         list_item:             "• **{title}**{preview_part}"
    +    list_item_numbered:    "{index}. **{title}**{preview_part}"
         list_preview_suffix:   " — _{preview}_"
         list_footer:           "\n用法：`/resume <工作階段名稱>`"
    +    list_footer_numbered:  "\n用法：`/resume <會話名稱>` 或 `/resume <編號>`（例如，`/resume 1` 表示最近的會話）"
         list_failed:           "無法列出工作階段：{error}"
    +    out_of_range:          "恢復索引 {index} 超出範圍。\n請使用不帶參數的 `/resume` 查看可用會話。"
         not_found:             "找不到符合 '**{name}**' 的工作階段。\n使用不帶參數的 `/resume` 檢視可用的工作階段。"
         already_on:            "📌 已在工作階段 **{name}** 上。"
         switch_failed:         "切換工作階段失敗。"
    ```
  - `locales/zh.yaml`

    ```diff
    @@ -222,9 +222,12 @@ gateway:
         no_named_sessions:     "未找到已命名的会话。\n使用 `/title 我的会话` 为当前会话命名，然后用 `/resume 我的会话` 返回。"
         list_header:           "📋 **已命名会话**\n"
         list_item:             "• **{title}**{preview_part}"
    +    list_item_numbered:    "{index}. **{title}**{preview_part}"
         list_preview_suffix:   " — _{preview}_"
         list_footer:           "\n用法：`/resume <会话名称>`"
    +    list_footer_numbered:  "\n用法：`/resume <会话名称>` 或 `/resume <编号>`（例如，`/resume 1` 表示最近的会话）"
         list_failed:           "无法列出会话：{error}"
    +    out_of_range:          "恢复索引 {index} 超出范围。\n请使用不带参数的 `/resume` 查看可用会话。"
         not_found:             "未找到匹配 '**{name}**' 的会话。\n使用不带参数的 `/resume` 查看可用会话。"
         already_on:            "📌 已在会话 **{name}** 上。"
         switch_failed:         "切换会话失败。"
    ```
  - `scripts/release.py`

    ```diff
    @@ -1283,6 +1283,7 @@
         "[redacted:email]": "egilewski",  # PR #30432 (MEDIA path traversal fix, GHSA-jmf9-9729-7pp8)
         "[redacted:email]": "McClean-Edison",  # PR #29817 (register_auxiliary_task plugin API)
         "[redacted:email]": "SamuelZ12",  # PR #7480 (show recap after in-session resume)
    +    "[redacted:email]": "daizhonggeng",  # PR #9020 (numbered /resume selection)
     }
     
     
    ```
  - `tests/cli/test_cli_init.py`

    ```diff
    @@ -331,7 +331,8 @@ def test_resume_without_target_lists_recent_sessions(self, capsys):
     
             assert "Recent sessions" in output
             assert "Checking Running Hermes Agent" in output
    -        assert "Use /resume <session id or title> to continue" in output
    +        assert "Use /resume" in output
    +        assert "session title" in output
     
         def test_resume_updates_hermes_session_id_env_and_context(self, tmp_path):
             from gateway.session_context import _UNSET, _VAR_MAP, get_session_env
    ```
  - `tests/cli/test_cli_resume_command.py`

    ```diff
    @@ -0,0 +1,77 @@
    +from unittest.mock import MagicMock, patch
    +
    +from cli import HermesCLI
    +
    +
    +def _make_cli():
    +    cli_obj = HermesCLI.__new__(HermesCLI)
    +    cli_obj.session_id = "current_session"
    +    cli_obj._resumed = False
    +    cli_obj._pending_title = None
    +    cli_obj.conversation_history = []
    +    cli_obj.agent = None
    +    cli_obj._session_db = MagicMock()
    +    # _handle_resume_command now triggers _display_resumed_history (#31695),
    +    # which reads self.resume_display. "minimal" short-circuits the recap so
    +    # the test only exercises session-switch behavior.
    +    cli_obj.resume_display = "minimal"
    +    return cli_obj
    +
    +
    +class TestCliResumeCommand:
    +    def test_show_recent_sessions_includes_indexes_and_resume_hint(self, capsys):
    +        cli_obj = _make_cli()
    +        cli_obj._list_recent_sessions = MagicMock(return_value=[
    +            {"id": "sess_002", "title": "Coding", "preview": "build feature", "last_active": None},
    +            {"id": "sess_001", "title": "Research", "preview": "read docs", "last_active": None},
    +        ])
    +
    +        shown = cli_obj._show_recent_sessions(reason="resume")
    +        output = capsys.readouterr().out
    +
    +        assert shown is True
    +        assert "1" in output
    +        assert "2" in output
    +        assert "Coding" in output
    +        assert "Research" in output
    +        assert "/resume 2" in output
    +        assert "/resume <session title>" in output
    +
    +    def test_handle_resume_by_index_switches_to_numbered_session(self):
    +        cli_obj = _make_cli()
    +        cli_obj._list_recent_sessions = MagicMock(return_value=[
    +            {"id": "sess_002", "title": "Coding"},
    +            {"id": "sess_001", "title": "Research"},
    +        ])
    +        cli_obj._session_db.get_session.return_value = {"id": "sess_001", "title": "Research"}
    +        cli_obj._session_db.get_messages_as_conversation.return_value = [
    +            {"role": "user", "content": "hello"},
    +            {"role": "assistant", "content": "hi"},
    +        ]
    +        # resolve_resume_session_id passes the id through when no compression chain.
    +        cli_obj._session_db.resolve_resume_session_id.return_value = "sess_001"
    +
    +        with (
    +            patch("hermes_cli.main._resolve_session_by_name_or_id", return_value=None),
    +            patch("cli._cprint") as mock_cprint,
    +        ):
    +            cli_obj._handle_resume_command("/resume 2")
    +
    +        printed = " ".join(str(call) for call in mock_cprint.call_args_list)
    +        assert cli_obj.session_id == "sess_001"
    +        assert "Resumed session sess_001" in printed
    +        assert "Research" in printed
    +
    +    def test_handle_resume_by_index_out_of_range(self):
    +        cli_obj = _make_cli()
    +        cli_obj._list_recent_sessions = MagicMock(return_value=[
    +            {"id": "sess_002", "title": "Coding"},
    +        ])
    +
    +        with patch("cli._cprint") as mock_cprint:
    +            cli_obj._handle_resume_command("/resume 9")
    +
    +        printed = " ".join(str(call) for call in mock_cprint.call_args_list)
    +        assert "out of range" in printed.lower()
    +        assert "/resume" in printed
    +        assert cli_obj.session_id == "current_session"
    ```
  - `tests/gateway/test_resume_command.py`

    ```diff
    @@ -88,6 +88,9 @@ async def test_list_named_sessions_when_no_arg(self, tmp_path):
             assert "Research" in result
             assert "Coding" in result
             assert "Named Sessions" in result
    +        assert "1." in result
    +        assert "2." in result
    +        assert "/resume 1" in result
             db.close()
     
         @pytest.mark.asyncio
    ```

    ```diff
    @@ -104,6 +107,47 @@ async def test_list_shows_usage_when_no_titled(self, tmp_path):
             assert "/title" in result
             db.close()
     
    +    @pytest.mark.asyncio
    +    async def test_resume_by_index(self, tmp_path):
    +        """Numeric argument resumes the indexed titled session from the list."""
    +        from hermes_state import SessionDB
    +        db = SessionDB(db_path=tmp_path / "state.db")
    +        db.create_session("sess_001", "telegram")
    +        db.create_session("sess_002", "telegram")
    +        db.set_session_title("sess_001", "Research")
    +        db.set_session_title("sess_002", "Coding")
    +        db.create_session("current_session_001", "telegram")
    +
    +        event = _make_event(text="/resume 2")
    +        runner = _make_runner(session_db=db, current_session_id="current_session_001",
    +                              event=event)
    +        result = await runner._handle_resume_command(event)
    +
    +        assert "Resumed" in result
    +        runner.session_store.switch_session.assert_called_once()
    +        call_args = runner.session_store.switch_session.call_args
    +        assert call_args[0][1] == "sess_001"
    +        db.close()
    +
    +    @pytest.mark.asyncio
    +    async def test_resume_index_out_of_range(self, tmp_path):
    +        """Out-of-range numeric arguments show a helpful error."""
    +        from hermes_state import SessionDB
    +        db = SessionDB(db_path=tmp_path / "state.db")
    +        db.create_session("sess_001", "telegram")
    +        db.set_session_title("sess_001", "Research")
    +        db.create_session("current_session_001", "telegram")
    +
    +        event = _make_event(text="/resume 9")
    +        runner = _make_runner(session_db=db, current_session_id="current_session_001",
    +                              event=event)
    +        result = await runner._handle_resume_command(event)
    +
    +        assert "out of range" in result.lower()
    +        assert "/resume" in result
    +        runner.session_store.switch_session.assert_not_called()
    +        db.close()
    +
         @pytest.mark.asyncio
         async def test_resume_by_name(self, tmp_path):
             """Resolves a title and switches to that session."""
    ```
- **#4990: feat(cli): add /resume interactive picker and numeric selection (#4988)**. closed without merge
  - maintainer direction: category `merged_via`; implication `superseded`; refs `#31709`, `#31709`, `#31709`
- **#8493: feat: interactive session picker for /resume and hermes resume**. closed without merge
  - maintainer direction: category `duplicate_of`; implication `duplicate`; refs `#7880`
- **#8498: feat: Ctrl+P history pager + /history full (newest first, full text, +G scroll)**. open
  - maintainer direction: category `superseded_by_pr`; implication `superseded`; refs `#4830`, `#5509`, `#4830`
  - files: `cli.py`

## Fix status

- Merged to main in PR #733 (cited above). No release data is tracked. Verify downstream.
- Fix last verified (merged) `2026-05-24`.
- Subsystem: `hermes_cli`
