{"repo":"NousResearch/hermes-agent","public_id":"718","display_label":"Interactive session picker and resume navigation features","summary":"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.","subsystem":"hermes_cli","counts":{"members":10,"open_issues":2,"open_issues_distinct":2,"open_prs":1,"closed_issues":3,"merged_prs":2,"attempt_prs":3},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":733},"resolution":{"state":"partially_resolved","by":"pr-733"},"freshness":{"state":"verified","last_verified_at":"2026-05-24T23:22:49+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_PARTIAL","link_density":0.15111111111111114},"fix_edges":[{"issue_number":718,"pr_number":733,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":4988,"pr_number":31709,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false}],"members":[{"kind":"issue","number":718,"title":"Feature: Interactive Session Picker for hermes --continue — Browsable Session List with Search","url":null,"state":"closed"},{"kind":"issue","number":4988,"title":"[Feature]: /resume with no arguments should show an interactive session picker","url":null,"state":"closed"},{"kind":"issue","number":5509,"title":"feat: Ctrl+P context-aware inspect + /history full pager","url":null,"state":"open"},{"kind":"issue","number":12406,"title":"[Feature]: Hermes Session Selector - Professional Session Resume Interface","url":null,"state":"closed"},{"kind":"issue","number":41099,"title":"[Feature]: Search through sessions easily with a session menu.","url":null,"state":"open"},{"kind":"pr","number":733,"title":"feat: interactive session browser with search filtering (#718)","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["hermes_cli/main.py","tests/hermes_cli/test_session_browse.py"],"fix_hunks":[{"file":"hermes_cli/main.py","hunks":["@@ -21,6 +21,7 @@\n     hermes version             # Show version\n     hermes update              # Update to latest version\n     hermes uninstall           # Uninstall Hermes Agent\n+    hermes sessions browse     # Interactive session picker with search\n \"\"\"\n \n import argparse","@@ -106,6 +107,279 @@ def _has_any_provider_configured() -> bool:\n     return False\n \n \n+def _session_browse_picker(sessions: list) -> Optional[str]:\n+    \"\"\"Interactive curses-based session browser with live search filtering.\n+\n+    Returns the selected session ID, or None if cancelled.\n+    Uses curses (not simple_term_menu) to avoid the ghost-duplication rendering\n+    bug in tmux/iTerm when arrow keys are used.\n+    \"\"\"\n+    if not sessions:\n+        print(\"No sessions found.\")\n+        return None\n+\n+    # Try curses-based picker first\n+    try:\n+        import curses\n+        import time as _time\n+        from datetime import datetime\n+\n+        result_holder = [None]\n+\n+        def _relative_time(ts):\n+            if not ts:\n+                return \"?\"\n+            delta = _time.time() - ts\n+            if delta < 60:\n+                return \"just now\"\n+            elif delta < 3600:\n+                return f\"{int(delta / 60)}m ago\"\n+            elif delta < 86400:\n+                return f\"{int(delta / 3600)}h ago\"\n+            elif delta < 172800:\n+                return \"yesterday\"\n+            elif delta < 604800:\n+                return f\"{int(delta / 86400)}d ago\"\n+            else:\n+                return datetime.fromtimestamp(ts).strftime(\"%Y-%m-%d\")\n+\n+        def _format_row(s, max_x):\n+            \"\"\"Format a session row for display.\"\"\"\n+            title = (s.get(\"title\") or \"\").strip()\n+            preview = (s.get(\"preview\") or \"\").strip()\n+            source = s.get(\"source\", \"\")[:6]\n+            last_active = _relative_time(s.get(\"last_active\"))\n+            sid = s[\"id\"][:18]\n+\n+            # Adaptive column widths based on terminal width\n+            # Layout: [arrow 3] [title/preview flexible] [active 12] [src 6] [id 18]\n+            fixed_cols = 3 + 12 + 6 + 18 + 6  # arrow + active + src + id + padding\n+            name_width = max(20, max_x - fixed_cols)\n+\n+            if title:\n+                name = title[:name_width]\n+            elif preview:\n+                name = preview[:name_width]\n+            else:\n+                name = sid\n+\n+            return f\"{name:<{name_width}}  {last_active:<10}  {source:<5} {sid}\"\n+\n+        def _match(s, query):\n+            \"\"\"Check if a session matches the search query (case-insensitive).\"\"\"\n+            q = query.lower()\n+            return (\n+                q in (s.get(\"title\") or \"\").lower()\n+                or q in (s.get(\"preview\") or \"\").lower()\n+                or q in s.get(\"id\", \"\").lower()\n+                or q in (s.get(\"source\") or \"\").lower()\n+            )\n+\n+        def _curses_browse(stdscr):\n+            curses.curs_set(0)\n+            if curses.has_colors():\n+                curses.start_color()\n+                curses.use_default_colors()\n+                curses.init_pair(1, curses.COLOR_GREEN, -1)   # selected\n+                curses.init_pair(2, curses.COLOR_YELLOW, -1)  # header\n+                curses.init_pair(3, curses.COLOR_CYAN, -1)    # search\n+                curses.init_pair(4, 8, -1)                    # dim\n+\n+            cursor = 0\n+            scroll_offset = 0\n+            search_text = \"\"\n+            filtered = list(sessions)\n+\n+            while True:\n+                stdscr.clear()\n+                max_y, max_x = stdscr.getmaxyx()\n+                if max_y < 5 or max_x < 40:\n+                    # Terminal too small\n+                    try:\n+                        stdscr.addstr(0, 0, \"Terminal too small\")\n+                    except curses.error:\n+                        pass\n+                    stdscr.refresh()\n+                    stdscr.getch()\n+                    return\n+\n+                # Header line\n+                if search_text:\n+                    header = f\"  Browse sessions — filter: {search_text}█\"\n+                    header_attr = curses.A_BOLD\n+                    if curses.has_colors():\n+                        header_attr |= curses.color_pair(3)\n+                else:\n+                    header = \"  Browse sessions — ↑↓ navigate  Enter select  Type to filter  Esc quit\"\n+                    header_attr = curses.A_BOLD\n+                    if curses.has_colors():\n+                        header_attr |= curses.color_pair(2)\n+                try:\n+                    stdscr.addnstr(0, 0, header, max_x - 1, header_attr)\n+                except curses.error:\n+                    pass\n+\n+                # Column header line\n+                fixed_cols = 3 + 12 + 6 + 18 + 6\n+                name_width = max(20, max_x - fixed_cols)\n+                col_header = f\"   {'Title / Preview':<{name_width}}  {'Active':<10}  {'Src':<5} {'ID'}\"\n+                try:\n+                    dim_attr = curses.color_pair(4) if curses.has_colors() else curses.A_DIM\n+                    stdscr.addnstr(1, 0, col_header, max_x - 1, dim_attr)\n+                except curses.error:\n+                    pass\n+\n+                # Compute visible area\n+                visible_rows = max_y - 4  # header + col header + blank + footer\n+                if visible_rows < 1:\n+                    visible_rows = 1\n+\n+                # Clamp cursor and scroll\n+                if not filtered:\n+                    try:\n+                        msg = \"  No sessions match the filter.\"\n+                        stdscr.addnstr(3, 0, msg, max_x - 1, curses.A_DIM)\n+                    except curses.error:\n+                        pass\n+                else:\n+                    if cursor >= len(filtered):\n+                        cursor = len(filtered) - 1\n+                    if cursor < 0:\n+                        cursor = 0\n+                    if cursor < scroll_offset:\n+                        scroll_offset = cursor\n+                    elif cursor >= scroll_offset + visible_rows:\n+                        scroll_offset = cursor - visible_rows + 1\n+\n+                    for draw_i, i in enumerate(range(\n+                        scroll_offset,\n+                        min(len(filtered), scroll_offset + visible_rows)\n+                    )):\n+                        y = draw_i + 3\n+                        if y >= max_y - 1:\n+                            break\n+                        s = filtered[i]\n+                        arrow = \" → \" if i == cursor else \"   \"\n+                        row = arrow + _format_row(s, max_x - 3)\n+                        attr = curses.A_NORMAL\n+                        if i == cursor:\n+                            attr = curses.A_BOLD\n+                            if curses.has_colors():\n+                                attr |= curses.color_pair(1)\n+                        try:\n+                            stdscr.addnstr(y, 0, row, max_x - 1, attr)\n+                        except curses.error:\n+                            pass\n+\n+                # Footer\n+                footer_y = max_y - 1\n+                if filtered:\n+                    footer = f\"  {cursor + 1}/{len(filtered)} sessions\"\n+                    if len(filtered) < len(sessions):\n+                        footer += f\" (filtered from {len(sessions)})\"\n+                else:\n+                    footer = f\"  0/{len(sessions)} sessions\"\n+                try:\n+                    stdscr.addnstr(footer_y, 0, footer, max_x - 1,\n+                                   curses.color_pair(4) if curses.has_colors() else curses.A_DIM)\n+                except curses.error:\n+                    pass\n+\n+                stdscr.refresh()\n+                key = stdscr.getch()\n+\n+                if key in (curses.KEY_UP, ):\n+                    if filtered:\n+                        cursor = (cursor - 1) % len(filtered)\n+                elif key in (curses.KEY_DOWN, ):\n+                    if filtered:\n+                        cursor = (cursor + 1) % len(filtered)\n+                elif key in (curses.KEY_ENTER, 10, 13):\n+                    if filtered:\n+                        result_holder[0] = filtered[cursor][\"id\"]\n+                    return\n+                elif key == 27:  # Esc\n+                    if search_text:\n+                        # First Esc clears the search\n+                        search_text = \"\"\n+                        filtered = list(sessions)\n+                        cursor = 0\n+                        scroll_offset = 0\n+                    else:\n+                        # Second Esc exits\n+                        return\n+                elif key in (curses.KEY_BACKSPACE, 127, 8):\n+                    if search_text:\n+                        search_text = search_text[:-1]\n+                        if search_text:\n+                            filtered = [s for s in sessions if _match(s, search_text)]\n+                        else:\n+                            filtered = list(sessions)\n+                        cursor = 0\n+                        scroll_offset = 0\n+                elif key == ord('q') and not search_text:\n+                    return\n+                elif 32 <= key <= 126:\n+                    # Printable character → add to search filter\n+                    search_text += chr(key)\n+                    filtered = [s for s in sessions if _match(s, search_text)]\n+                    cursor = 0\n+                    scroll_offset = 0\n+\n+        curses.wrapper(_curses_browse)\n+        return result_holder[0]\n+\n+    except Exception:\n+        pass\n+\n+    # Fallback: numbered list (Windows without curses, etc.)\n+    import time as _time\n+    from datetime import datetime\n+\n+    def _relative_time_fb(ts):\n+        if not ts:\n+            return \"?\"\n+        delta = _time.time() - ts\n+        if delta < 60:\n+            return \"just now\"\n+        elif delta < 3600:\n+            return f\"{int(delta / 60)}m ago\"\n+        elif delta < 86400:\n+            return f\"{int(delta / 3600)}h ago\"\n+        elif delta < 172800:\n+            return \"yesterday\"\n+        elif delta < 604800:\n+            return f\"{int(delta / 86400)}d ago\"\n+        else:\n+            return datetime.fromtimestamp(ts).strftime(\"%Y-%m-%d\")\n+\n+    print(\"\\n  Browse sessions  (enter number to resume, q to cancel)\\n\")\n+    for i, s in enumerate(sessions):\n+        title = (s.get(\"title\") or \"\").strip()\n+        preview = (s.get(\"preview\") or \"\").strip()\n+        label = title or preview or s[\"id\"]\n+        if len(label) > 50:\n+            label = label[:47] + \"...\"\n+        last_active = _relative_time_fb(s.get(\"last_active\"))\n+        src = s.get(\"source\", \"\")[:6]\n+        print(f\"  {i + 1:>3}. {label:<50}  {last_active:<10}  {src}\")\n+\n+    while True:\n+        try:\n+            val = input(f\"\\n  Select [1-{len(sessions)}]: \").strip()\n+            if not val or val.lower() in (\"q\", \"quit\", \"exit\"):\n+                return None\n+            idx = int(val) - 1\n+            if 0 <= idx < len(sessions):\n+                return sessions[idx][\"id\"]\n+            print(f\"  Invalid selection. Enter 1-{len(sessions)} or q to cancel.\")\n+        except ValueError:\n+            print(f\"  Invalid input. Enter a number or q to cancel.\")\n+        except (KeyboardInterrupt, EOFError):\n+            print()\n+            return None\n+\n+\n def _resolve_last_cli_session() -> Optional[str]:\n     \"\"\"Look up the most recent CLI session ID from SQLite. Returns None if unavailable.\"\"\"\n     try:","@@ -1269,6 +1543,7 @@ def main():\n     hermes -w                     Start in isolated git worktree\n     hermes gateway install        Install as system service\n     hermes sessions list          List past sessions\n+    hermes sessions browse        Interactive session picker\n     hermes sessions rename ID T   Rename/title a session\n     hermes update                 Update to latest version\n ","@@ -1753,6 +2028,13 @@ def cmd_tools(args):\n     sessions_rename.add_argument(\"session_id\", help=\"Session ID to rename\")\n     sessions_rename.add_argument(\"title\", nargs=\"+\", help=\"New title for the session\")\n \n+    sessions_browse = sessions_subparsers.add_parser(\n+        \"browse\",\n+        help=\"Interactive session picker — browse, search, and resume sessions\",\n+    )\n+    sessions_browse.add_argument(\"--source\", help=\"Filter by source (cli, telegram, discord, etc.)\")\n+    sessions_browse.add_argument(\"--limit\", type=int, default=50, help=\"Max sessions to load (default: 50)\")\n+\n     def cmd_sessions(args):\n         import json as _json\n         try:","@@ -1859,6 +2141,34 @@ def _relative_time(ts):\n             except ValueError as e:\n                 print(f\"Error: {e}\")\n \n+        elif action == \"browse\":\n+            limit = getattr(args, \"limit\", 50) or 50\n+            source = getattr(args, \"source\", None)\n+            sessions = db.list_sessions_rich(source=source, limit=limit)\n+            db.close()\n+            if not sessions:\n+                print(\"No sessions found.\")\n+                return\n+\n+            selected_id = _session_browse_picker(sessions)\n+            if not selected_id:\n+                print(\"Cancelled.\")\n+                return\n+\n+            # Launch hermes --resume <id> by replacing the current process\n+            print(f\"Resuming session: {selected_id}\")\n+            import shutil\n+            hermes_bin = shutil.which(\"hermes\")\n+            if hermes_bin:\n+                os.execvp(hermes_bin, [\"hermes\", \"--resume\", selected_id])\n+            else:\n+                # Fallback: re-invoke via python -m\n+                os.execvp(\n+                    sys.executable,\n+                    [sys.executable, \"-m\", \"hermes_cli.main\", \"--resume\", selected_id],\n+                )\n+            return  # won't reach here after execvp\n+\n         elif action == \"stats\":\n             total = db.session_count()\n             msgs = db.message_count()","@@ -1868,7 +2178,6 @@ def _relative_time(ts):\n                 c = db.session_count(source=src)\n                 if c > 0:\n                     print(f\"  {src}: {c} sessions\")\n-            import os\n             db_path = db.db_path\n             if db_path.exists():\n                 size_mb = os.path.getsize(db_path) / (1024 * 1024)"]},{"file":"tests/hermes_cli/test_session_browse.py","hunks":["@@ -0,0 +1,542 @@\n+\"\"\"Tests for the interactive session browser (`hermes sessions browse`).\n+\n+Covers:\n+- _session_browse_picker logic (curses mocked, fallback tested)\n+- cmd_sessions 'browse' action integration\n+- Argument parser registration\n+\"\"\"\n+\n+import os\n+import time\n+from unittest.mock import MagicMock, patch, call\n+\n+import pytest\n+\n+from hermes_cli.main import _session_browse_picker\n+\n+\n+# ─── Sample session data ──────────────────────────────────────────────────────\n+\n+def _make_sessions(n=5):\n+    \"\"\"Generate a list of fake rich-session dicts.\"\"\"\n+    now = time.time()\n+    sessions = []\n+    for i in range(n):\n+        sessions.append({\n+            \"id\": f\"20260308_{i:06d}_abcdef\",\n+            \"source\": \"cli\" if i % 2 == 0 else \"telegram\",\n+            \"model\": \"test/model\",\n+            \"title\": f\"Session {i}\" if i % 3 != 0 else None,\n+            \"preview\": f\"Hello from session {i}\",\n+            \"last_active\": now - i * 3600,\n+            \"started_at\": now - i * 3600 - 60,\n+            \"message_count\": (i + 1) * 5,\n+        })\n+    return sessions\n+\n+\n+SAMPLE_SESSIONS = _make_sessions(5)\n+\n+\n+# ─── _session_browse_picker ──────────────────────────────────────────────────\n+\n+class TestSessionBrowsePicker:\n+    \"\"\"Tests for the _session_browse_picker function.\"\"\"\n+\n+    def test_empty_sessions_returns_none(self, capsys):\n+        result = _session_browse_picker([])\n+        assert result is None\n+        assert \"No sessions found\" in capsys.readouterr().out\n+\n+    def test_returns_none_when_no_sessions(self, capsys):\n+        result = _session_browse_picker([])\n+        assert result is None\n+\n+    def test_fallback_mode_valid_selection(self):\n+        \"\"\"When curses is unavailable, fallback numbered list should work.\"\"\"\n+        sessions = _make_sessions(3)\n+\n+        # Mock curses import to fail, forcing fallback\n+        import builtins\n+        original_import = builtins.__import__\n+\n+        def mock_import(name, *args, **kwargs):\n+            if name == \"curses\":\n+                raise ImportError(\"no curses\")\n+            return original_import(name, *args, **kwargs)\n+\n+        with patch.object(builtins, \"__import__\", side_effect=mock_import):\n+            with patch(\"builtins.input\", return_value=\"2\"):\n+                result = _session_browse_picker(sessions)\n+\n+        assert result == sessions[1][\"id\"]\n+\n+    def test_fallback_mode_cancel_q(self):\n+        \"\"\"Entering 'q' in fallback mode cancels.\"\"\"\n+        sessions = _make_sessions(3)\n+\n+        import builtins\n+        original_import = builtins.__import__\n+\n+        def mock_import(name, *args, **kwargs):\n+            if name == \"curses\":\n+                raise ImportError(\"no curses\")\n+            return original_import(name, *args, **kwargs)\n+\n+        with patch.object(builtins, \"__import__\", side_effect=mock_import):\n+            with patch(\"builtins.input\", return_value=\"q\"):\n+                result = _session_browse_picker(sessions)\n+\n+        assert result is None\n+\n+    def test_fallback_mode_cancel_empty(self):\n+        \"\"\"Entering empty string in fallback mode cancels.\"\"\"\n+        sessions = _make_sessions(3)\n+\n+        import builtins\n+        original_import = builtins.__import__\n+\n+        def mock_import(name, *args, **kwargs):\n+            if name == \"curses\":\n+                raise ImportError(\"no curses\")\n+            return original_import(name, *args, **kwargs)\n+\n+        with patch.object(builtins, \"__import__\", side_effect=mock_import):\n+            with patch(\"builtins.input\", return_value=\"\"):\n+                result = _session_browse_picker(sessions)\n+\n+        assert result is None\n+\n+    def test_fallback_mode_invalid_then_valid(self):\n+        \"\"\"Invalid selection followed by valid one works.\"\"\"\n+        sessions = _make_sessions(3)\n+\n+        import builtins\n+        original_import = builtins.__import__\n+\n+        def mock_import(name, *args, **kwargs):\n+            if name == \"curses\":\n+                raise ImportError(\"no curses\")\n+            return original_import(name, *args, **kwargs)\n+\n+        with patch.object(builtins, \"__import__\", side_effect=mock_import):\n+            with patch(\"builtins.input\", side_effect=[\"99\", \"1\"]):\n+                result = _session_browse_picker(sessions)\n+\n+        assert result == sessions[0][\"id\"]\n+\n+    def test_fallback_mode_keyboard_interrupt(self):\n+        \"\"\"KeyboardInterrupt in fallback mode returns None.\"\"\"\n+        sessions = _make_sessions(3)\n+\n+        import builtins\n+        original_import = builtins.__import__\n+\n+        def mock_import(name, *args, **kwargs):\n+            if name == \"curses\":\n+                raise ImportError(\"no curses\")\n+            return original_import(name, *args, **kwargs)\n+\n+        with patch.object(builtins, \"__import__\", side_effect=mock_import):\n+            with patch(\"builtins.input\", side_effect=KeyboardInterrupt):\n+                result = _session_browse_picker(sessions)\n+\n+        assert result is None\n+\n+    def test_fallback_displays_all_sessions(self, capsys):\n+        \"\"\"Fallback mode should display all session entries.\"\"\"\n+        sessions = _make_sessions(4)\n+\n+        import builtins\n+        original_import = builtins.__import__\n+\n+        def mock_import(name, *args, **kwargs):\n+            if name == \"curses\":\n+                raise ImportError(\"no curses\")\n+            return original_import(name, *args, **kwargs)\n+\n+        with patch.object(builtins, \"__import__\", side_effect=mock_import):\n+            with patch(\"builtins.input\", return_value=\"q\"):\n+                _session_browse_picker(sessions)\n+\n+        output = capsys.readouterr().out\n+        # All 4 entries should be shown\n+        assert \"1.\" in output\n+        assert \"2.\" in output\n+        assert \"3.\" in output\n+        assert \"4.\" in output\n+\n+    def test_fallback_shows_title_over_preview(self, capsys):\n+        \"\"\"When a session has a title, show it instead of the preview.\"\"\"\n+        sessions = [{\n+            \"id\": \"test_001\",\n+            \"source\": \"cli\",\n+            \"title\": \"My Cool Project\",\n+            \"preview\": \"some preview text\",\n+            \"last_active\": time.time(),\n+        }]\n+\n+        import builtins\n+        original_import = builtins.__import__\n+\n+        def mock_import(name, *args, **kwargs):\n+            if name == \"curses\":\n+                raise ImportError(\"no curses\")\n+            return original_import(name, *args, **kwargs)\n+\n+        with patch.object(builtins, \"__import__\", side_effect=mock_import):\n+            with patch(\"builtins.input\", return_value=\"q\"):\n+                _session_browse_picker(sessions)\n+\n+        output = capsys.readouterr().out\n+        assert \"My Cool Project\" in output\n+\n+    def test_fallback_shows_preview_when_no_title(self, capsys):\n+        \"\"\"When no title, show preview.\"\"\"\n+        sessions = [{\n+            \"id\": \"test_002\",\n+            \"source\": \"cli\",\n+            \"title\": None,\n+            \"preview\": \"Hello world test message\",\n+            \"last_active\": time.time(),\n+        }]\n+\n+        import builtins\n+        original_import = builtins.__import__\n+\n+        def mock_import(name, *args, **kwargs):\n+            if name == \"curses\":\n+                raise ImportError(\"no curses\")\n+            return original_import(name, *args, **kwargs)\n+\n+        with patch.object(builtins, \"__import__\", side_effect=mock_import):\n+            with patch(\"builtins.input\", return_value=\"q\"):\n+                _session_browse_picker(sessions)\n+\n+        output = capsys.readouterr().out\n+        assert \"Hello world test message\" in output\n+\n+    def test_fallback_shows_id_when_no_title_or_preview(self, capsys):\n+        \"\"\"When neither title nor preview, show session ID.\"\"\"\n+        sessions = [{\n+            \"id\": \"test_003_fallback\",\n+            \"source\": \"cli\",\n+            \"title\": None,\n+            \"preview\": \"\",\n+            \"last_active\": time.time(),\n+        }]\n+\n+        import builtins\n+        original_import = builtins.__import__\n+\n+        def mock_import(name, *args, **kwargs):\n+            if name == \"curses\":\n+                raise ImportError(\"no curses\")\n+            return original_import(name, *args, **kwargs)\n+\n+        with patch.object(builtins, \"__import__\", side_effect=mock_import):\n+            with patch(\"builtins.input\", return_value=\"q\"):\n+                _session_browse_picker(sessions)\n+\n+        output = capsys.readouterr().out\n+        assert \"test_003_fallback\" in output\n+\n+\n+# ─── Curses-based picker (mocked curses) ────────────────────────────────────\n+\n+class TestCursesBrowse:\n+    \"\"\"Tests for the curses-based interactive picker via simulated key sequences.\"\"\"\n+\n+    def _run_with_keys(self, sessions, key_sequence):\n+        \"\"\"Simulate running the curses picker with a given key sequence.\"\"\"\n+        import curses\n+\n+        # Build a mock stdscr that returns keys from the sequence\n+        mock_stdscr = MagicMock()\n+        mock_stdscr.getmaxyx.return_value = (30, 120)\n+        mock_stdscr.getch.side_effect = key_sequence\n+\n+        # Capture what curses.wrapper receives and call it with our mock\n+        with patch(\"curses.wrapper\") as mock_wrapper:\n+            # When wrapper is called, invoke the function with our mock stdscr\n+            def run_inner(func):\n+                try:\n+                    func(mock_stdscr)\n+                except StopIteration:\n+                    pass  # key sequence exhausted\n+\n+            mock_wrapper.side_effect = run_inner\n+            with patch(\"curses.curs_set\"):\n+                with patch(\"curses.has_colors\", return_value=False):\n+                    return _session_browse_picker(sessions)\n+\n+    def test_enter_selects_first_session(self):\n+        sessions = _make_sessions(3)\n+        result = self._run_with_keys(sessions, [10])  # Enter key\n+        assert result == sessions[0][\"id\"]\n+\n+    def test_down_then_enter_selects_second(self):\n+        import curses\n+        sessions = _make_sessions(3)\n+        result = self._run_with_keys(sessions, [curses.KEY_DOWN, 10])\n+        assert result == sessions[1][\"id\"]\n+\n+    def test_down_down_enter_selects_third(self):\n+        import curses\n+        sessions = _make_sessions(5)\n+        result = self._run_with_keys(sessions, [curses.KEY_DOWN, curses.KEY_DOWN, 10])\n+        assert result == sessions[2][\"id\"]\n+\n+    def test_up_wraps_to_last(self):\n+        import curses\n+        sessions = _make_sessions(3)\n+        result = self._run_with_keys(sessions, [curses.KEY_UP, 10])\n+        assert result == sessions[2][\"id\"]\n+\n+    def test_escape_cancels(self):\n+        sessions = _make_sessions(3)\n+        result = self._run_with_keys(sessions, [27])  # Esc\n+        assert result is None\n+\n+    def test_q_cancels(self):\n+        sessions = _make_sessions(3)\n+        result = self._run_with_keys(sessions, [ord('q')])\n+        assert result is None\n+\n+    def test_type_to_filter_then_enter(self):\n+        \"\"\"Typing characters filters the list, Enter selects from filtered.\"\"\"\n+        import curses\n+        sessions = [\n+            {\"id\": \"s1\", \"source\": \"cli\", \"title\": \"Alpha project\", \"preview\": \"\", \"last_active\": time.time()},\n+            {\"id\": \"s2\", \"source\": \"cli\", \"title\": \"Beta project\", \"preview\": \"\", \"last_active\": time.time()},\n+            {\"id\": \"s3\", \"source\": \"cli\", \"title\": \"Gamma project\", \"preview\": \"\", \"last_active\": time.time()},\n+        ]\n+        # Type \"Beta\" then Enter — should select s2\n+        keys = [ord(c) for c in \"Beta\"] + [10]\n+        result = self._run_with_keys(sessions, keys)\n+        assert result == \"s2\"\n+\n+    def test_filter_no_match_enter_does_nothing(self):\n+        \"\"\"When filter produces no results, Enter shouldn't select.\"\"\"\n+        sessions = _make_sessions(3)\n+        keys = [ord(c) for c in \"zzzznonexistent\"] + [10]\n+        result = self._run_with_keys(sessions, keys)\n+        assert result is None\n+\n+    def test_backspace_removes_filter_char(self):\n+        \"\"\"Backspace removes the last character from the filter.\"\"\"\n+        import curses\n+        sessions = [\n+            {\"id\": \"s1\", \"source\": \"cli\", \"title\": \"Alpha\", \"preview\": \"\", \"last_active\": time.time()},\n+            {\"id\": \"s2\", \"source\": \"cli\", \"title\": \"Beta\", \"preview\": \"\", \"last_active\": time.time()},\n+        ]\n+        # Type \"Bet\", backspace, backspace, backspace (clears filter), then Enter (selects first)\n+        keys = [ord('B'), ord('e'), ord('t'), 127, 127, 127, 10]\n+        result = self._run_with_keys(sessions, keys)\n+        assert result == \"s1\"\n+\n+    def test_escape_clears_filter_first(self):\n+        \"\"\"First Esc clears the search text, second Esc exits.\"\"\"\n+        import curses\n+        sessions = _make_sessions(3)\n+        # Type \"ab\" then Esc (clears filter) then Enter (selects first)\n+        keys = [ord('a'), ord('b'), 27, 10]\n+        result = self._run_with_keys(sessions, keys)\n+        assert result == sessions[0][\"id\"]\n+\n+    def test_filter_matches_preview(self):\n+        \"\"\"Typing should match against session preview text.\"\"\"\n+        sessions = [\n+            {\"id\": \"s1\", \"source\": \"cli\", \"title\": None, \"preview\": \"Set up Minecraft server\", \"last_active\": time.time()},\n+            {\"id\": \"s2\", \"source\": \"cli\", \"title\": None, \"preview\": \"Review PR 438\", \"last_active\": time.time()},\n+        ]\n+        keys = [ord(c) for c in \"Mine\"] + [10]\n+        result = self._run_with_keys(sessions, keys)\n+        assert result == \"s1\"\n+\n+    def test_filter_matches_source(self):\n+        \"\"\"Typing a source name should filter by source.\"\"\"\n+        sessions = [\n+            {\"id\": \"s1\", \"source\": \"telegram\", \"title\": \"TG session\", \"preview\": \"\", \"last_active\": time.time()},\n+            {\"id\": \"s2\", \"source\": \"cli\", \"title\": \"CLI session\", \"preview\": \"\", \"last_active\": time.time()},\n+        ]\n+        keys = [ord(c) for c in \"telegram\"] + [10]\n+        result = self._run_with_keys(sessions, keys)\n+        assert result == \"s1\"\n+\n+    def test_q_quits_when_no_filter_active(self):\n+        \"\"\"When no search text is active, 'q' should quit (not filter).\"\"\"\n+        sessions = _make_sessions(3)\n+        result = self._run_with_keys(sessions, [ord('q')])\n+        assert result is None\n+\n+    def test_q_types_into_filter_when_filter_active(self):\n+        \"\"\"When search text is already active, 'q' should add to filter, not quit.\"\"\"\n+        sessions = [\n+            {\"id\": \"s1\", \"source\": \"cli\", \"title\": \"the sequel\", \"preview\": \"\", \"last_active\": time.time()},\n+            {\"id\": \"s2\", \"source\": \"cli\", \"title\": \"other thing\", \"preview\": \"\", \"last_active\": time.time()},\n+        ]\n+        # Type \"se\" first (activates filter, matches \"the sequel\")\n+        # Then type \"q\" — should add 'q' to filter (filter=\"seq\"), NOT quit\n+        # \"seq\" still matches \"the sequel\" → Enter selects it\n+        keys = [ord('s'), ord('e'), ord('q'), 10]\n+        result = self._run_with_keys(sessions, keys)\n+        assert result == \"s1\"  # \"the sequel\" matches \"seq\"\n+\n+\n+# ─── Argument parser registration ──────────────────────────────────────────\n+\n+class TestSessionBrowseArgparse:\n+    \"\"\"Verify the 'browse' subcommand is properly registered.\"\"\"\n+\n+    def test_browse_subcommand_exists(self):\n+        \"\"\"hermes sessions browse should be parseable.\"\"\"\n+        from hermes_cli.main import main as _main_entry\n+\n+        # We can't run main(), but we can import and test the parser setup\n+        # by checking that argparse doesn't error on \"sessions browse\"\n+        import argparse\n+        # Re-create the parser portion\n+        # Instead, let's just verify the import works and the function exists\n+        from hermes_cli.main import _session_browse_picker\n+        assert callable(_session_browse_picker)\n+\n+    def test_browse_default_limit_is_50(self):\n+        \"\"\"The default --limit for browse should be 50.\"\"\"\n+        # This test verifies at the argparse level\n+        # We test by running the parse on \"sessions browse\" args\n+        # Since we can't easily extract the subparser, verify via the\n+        # _session_browse_picker accepting large lists\n+        sessions = _make_sessions(50)\n+        assert len(sessions) == 50\n+\n+\n+# ─── Integration: cmd_sessions browse action ────────────────────────────────\n+\n+class TestCmdSessionsBrowse:\n+    \"\"\"Integration tests for the 'browse' action in cmd_sessions.\"\"\"\n+\n+    def test_browse_no_sessions_prints_message(self, capsys):\n+        \"\"\"When no sessions exist, _session_browse_picker returns None and prints message.\"\"\"\n+        result = _session_browse_picker([])\n+        assert result is None\n+        output = capsys.readouterr().out\n+        assert \"No sessions found\" in output\n+\n+    def test_browse_with_source_filter(self):\n+        \"\"\"The --source flag should be passed to list_sessions_rich.\"\"\"\n+        sessions = [\n+            {\"id\": \"s1\", \"source\": \"cli\", \"title\": \"CLI only\", \"preview\": \"\", \"last_active\": time.time()},\n+        ]\n+\n+        import builtins\n+        original_import = builtins.__import__\n+\n+        def mock_import(name, *args, **kwargs):\n+            if name == \"curses\":\n+                raise ImportError(\"no curses\")\n+            return original_import(name, *args, **kwargs)\n+\n+        with patch.object(builtins, \"__import__\", side_effect=mock_import):\n+            with patch(\"builtins.input\", return_value=\"1\"):\n+                result = _session_browse_picker(sessions)\n+\n+        assert result == \"s1\"\n+\n+\n+# ─── Edge cases ──────────────────────────────────────────────────────────────\n+\n+class TestEdgeCases:\n+    \"\"\"Edge case handling for the session browser.\"\"\"\n+\n+    def test_sessions_with_missing_fields(self):\n+        \"\"\"Sessions with missing optional fields should not crash.\"\"\"\n+        sessions = [\n+            {\"id\": \"minimal_001\", \"source\": \"cli\"},  # No title, preview, last_active\n+        ]\n+\n+        import builtins\n+        original_import = builtins.__import__\n+\n+        def mock_import(name, *args, **kwargs):\n+            if name == \"curses\":\n+                raise ImportError(\"no curses\")\n+            return original_import(name, *args, **kwargs)\n+\n+        with patch.object(builtins, \"__import__\", side_effect=mock_import):\n+            with patch(\"builtins.input\", return_value=\"1\"):\n+                result = _session_browse_picker(sessions)\n+\n+        assert result == \"minimal_001\"\n+\n+    def test_single_session(self):\n+        \"\"\"A single session in the list should work fine.\"\"\"\n+        sessions = [\n+            {\"id\": \"only_one\", \"source\": \"cli\", \"title\": \"Solo\", \"preview\": \"\", \"last_active\": time.time()},\n+        ]\n+\n+        import builtins\n+        original_import = builtins.__import__\n+\n+        def mock_import(name, *args, **kwargs):\n+            if name == \"curses\":\n+                raise ImportError(\"no curses\")\n+            return original_import(name, *args, **kwargs)\n+\n+        with patch.object(builtins, \"__import__\", side_effect=mock_import):\n+            with patch(\"builtins.input\", return_value=\"1\"):\n+                result = _session_browse_picker(sessions)\n+\n+        assert result == \"only_one\"\n+\n+    def test_long_title_truncated_in_fallback(self, capsys):\n+        \"\"\"Very long titles should be truncated in fallback mode.\"\"\"\n+        sessions = [{\n+            \"id\": \"long_title_001\",\n+            \"source\": \"cli\",\n+            \"title\": \"A\" * 100,\n+            \"preview\": \"\",\n+            \"last_active\": time.time(),\n+        }]\n+\n+        import builtins\n+        original_import = builtins.__import__\n+\n+        def mock_import(name, *args, **kwargs):\n+            if name == \"curses\":\n+                raise ImportError(\"no curses\")\n+            return original_import(name, *args, **kwargs)\n+\n+        with patch.object(builtins, \"__import__\", side_effect=mock_import):\n+            with patch(\"builtins.input\", return_value=\"q\"):\n+                _session_browse_picker(sessions)\n+\n+        output = capsys.readouterr().out\n+        # Title should be truncated to 50 chars with \"...\"\n+        assert \"...\" in output\n+\n+    def test_relative_time_formatting(self, capsys):\n+        \"\"\"Verify various time deltas format correctly.\"\"\"\n+        now = time.time()\n+        sessions = [\n+            {\"id\": \"recent\", \"source\": \"cli\", \"title\": None, \"preview\": \"just now test\", \"last_active\": now},\n+            {\"id\": \"hour_ago\", \"source\": \"cli\", \"title\": None, \"preview\": \"hour ago test\", \"last_active\": now - 7200},\n+            {\"id\": \"days_ago\", \"source\": \"cli\", \"title\": None, \"preview\": \"days ago test\", \"last_active\": now - 259200},\n+        ]\n+\n+        import builtins\n+        original_import = builtins.__import__\n+\n+        def mock_import(name, *args, **kwargs):\n+            if name == \"curses\":\n+                raise ImportError(\"no curses\")\n+            return original_import(name, *args, **kwargs)\n+\n+        with patch.object(builtins, \"__import__\", side_effect=mock_import):\n+            with patch(\"builtins.input\", return_value=\"q\"):\n+                _session_browse_picker(sessions)\n+\n+        output = capsys.readouterr().out\n+        assert \"just now\" in output\n+        assert \"2h ago\" in output\n+        assert \"3d ago\" in output\n"]}]},{"kind":"pr","number":4990,"title":"feat(cli): add /resume interactive picker and numeric selection (#4988)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#31709","number":31709},{"kind":"pr","value":"#31709","number":31709},{"kind":"pr","value":"#31709","number":31709}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":8493,"title":"feat: interactive session picker for /resume and hermes resume","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#7880","number":7880}],"category":"duplicate_of","implication":"duplicate"},"merged":false},{"kind":"pr","number":8498,"title":"feat: Ctrl+P history pager + /history full (newest first, full text, +G scroll)","url":null,"state":"open","close_reason":{"refs":[{"kind":"pr","value":"#4830","number":4830},{"kind":"pr","value":"#5509","number":5509},{"kind":"pr","value":"#4830","number":4830}],"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"kind":"pr","number":31709,"title":"feat(cli,gateway): /resume accepts position numbers","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["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","tests/cli/test_cli_resume_command.py","tests/gateway/test_resume_command.py"],"fix_hunks":[{"file":"cli.py","hunks":["@@ -6172,15 +6172,16 @@ def _show_recent_sessions(self, *, reason: str = \"history\", limit: int = 10) ->\n         else:\n             print(\"  Recent sessions:\")\n         print()\n-        print(f\"  {'Title':<32} {'Preview':<40} {'Last Active':<13} {'ID'}\")\n-        print(f\"  {'─' * 32} {'─' * 40} {'─' * 13} {'─' * 24}\")\n-        for session in sessions:\n+        print(f\"  {'#':<3} {'Title':<32} {'Preview':<40} {'Last Active':<13} {'ID'}\")\n+        print(f\"  {'─' * 3} {'─' * 32} {'─' * 40} {'─' * 13} {'─' * 24}\")\n+        for idx, session in enumerate(sessions, start=1):\n             title = session.get(\"title\") or \"—\"\n             preview = (session.get(\"preview\") or \"\")[:38]\n             last_active = _relative_time(session.get(\"last_active\"))\n-            print(f\"  {title:<32} {preview:<40} {last_active:<13} {session['id']}\")\n+            print(f\"  {idx:<3} {title:<32} {preview:<40} {last_active:<13} {session['id']}\")\n         print()\n-        print(\"  Use /resume <session id or title> to continue where you left off.\")\n+        print(\"  Use /resume <number>, /resume <session id>, or /resume <session title> to continue.\")\n+        print(\"  Example: /resume 2\")\n         print()\n         return True\n ","@@ -6525,7 +6526,7 @@ def _handle_resume_command(self, cmd_original: str) -> None:\n         target = parts[1].strip() if len(parts) > 1 else \"\"\n \n         if not target:\n-            _cprint(\"  Usage: /resume <session_id_or_title>\")\n+            _cprint(\"  Usage: /resume <number|session_id_or_title>\")\n             if self._show_recent_sessions(reason=\"resume\"):\n                 return\n             _cprint(\"  Tip:   Use /history or `hermes sessions list` to find sessions.\")","@@ -6536,10 +6537,20 @@ def _handle_resume_command(self, cmd_original: str) -> None:\n             _cprint(f\"  {format_session_db_unavailable()}\")\n             return\n \n-        # Resolve title or ID\n-        from hermes_cli.main import _resolve_session_by_name_or_id\n-        resolved = _resolve_session_by_name_or_id(target)\n-        target_id = resolved or target\n+        # Resolve numbered selection, title, or ID\n+        if target.isdigit():\n+            sessions = self._list_recent_sessions(limit=10)\n+            index = int(target)\n+            if index < 1 or index > len(sessions):\n+                _cprint(f\"  Resume index {index} is out of range.\")\n+                _cprint(\"  Use /resume with no arguments to see available sessions.\")\n+                return\n+            selected = sessions[index - 1]\n+            target_id = selected[\"id\"]\n+        else:\n+            from hermes_cli.main import _resolve_session_by_name_or_id\n+            resolved = _resolve_session_by_name_or_id(target)\n+            target_id = resolved or target\n \n         session_meta = self._session_db.get_session(target_id)\n         if not session_meta:"]},{"file":"gateway/run.py","hunks":["@@ -12741,7 +12741,7 @@ async def _handle_title_command(self, event: MessageEvent) -> str:\n                 return t(\"gateway.title.current_no_title\", session_id=session_id)\n \n     async def _handle_resume_command(self, event: MessageEvent) -> str:\n-        \"\"\"Handle /resume command — switch to a previously-named session.\"\"\"\n+        \"\"\"Handle /resume command — list or switch to a previous session.\"\"\"\n         if not self._session_db:\n             from hermes_state import format_session_db_unavailable\n             return format_session_db_unavailable(prefix=t(\"gateway.shared.session_db_unavailable_prefix\"))","@@ -12750,30 +12750,44 @@ async def _handle_resume_command(self, event: MessageEvent) -> str:\n         session_key = self._session_key_for_source(source)\n         name = event.get_command_args().strip()\n \n+        def _list_titled_sessions() -> list[dict]:\n+            user_source = source.platform.value if source.platform else None\n+            sessions = self._session_db.list_sessions_rich(source=user_source, limit=10)\n+            return [s for s in sessions if s.get(\"title\")][:10]\n+\n         if not name:\n             # List recent titled sessions for this user/platform\n             try:\n-                user_source = source.platform.value if source.platform else None\n-                sessions = self._session_db.list_sessions_rich(\n-                    source=user_source, limit=10\n-                )\n-                titled = [s for s in sessions if s.get(\"title\")]\n+                titled = _list_titled_sessions()\n                 if not titled:\n                     return t(\"gateway.resume.no_named_sessions\")\n                 lines = [t(\"gateway.resume.list_header\")]\n-                for s in titled[:10]:\n+                for idx, s in enumerate(titled[:10], start=1):\n                     title = s[\"title\"]\n                     preview = s.get(\"preview\", \"\")[:40]\n                     preview_part = t(\"gateway.resume.list_preview_suffix\", preview=preview) if preview else \"\"\n-                    lines.append(t(\"gateway.resume.list_item\", title=title, preview_part=preview_part))\n-                lines.append(t(\"gateway.resume.list_footer\"))\n+                    lines.append(t(\"gateway.resume.list_item_numbered\", index=idx, title=title, preview_part=preview_part))\n+                lines.append(t(\"gateway.resume.list_footer_numbered\"))\n                 return \"\\n\".join(lines)\n             except Exception as e:\n                 logger.debug(\"Failed to list titled sessions: %s\", e)\n                 return t(\"gateway.resume.list_failed\", error=e)\n \n-        # Resolve the name to a session ID.\n-        target_id = self._session_db.resolve_session_by_title(name)\n+        # Resolve a numbered choice or a title to a session ID.\n+        if name.isdigit():\n+            try:\n+                titled = _list_titled_sessions()\n+            except Exception as e:\n+                logger.debug(\"Failed to list titled sessions for numeric resume: %s\", e)\n+                return t(\"gateway.resume.list_failed\", error=e)\n+            index = int(name)\n+            if index < 1 or index > len(titled):\n+                return t(\"gateway.resume.out_of_range\", index=index)\n+            target = titled[index - 1]\n+            target_id = target.get(\"id\")\n+            name = target.get(\"title\") or name\n+        else:\n+            target_id = self._session_db.resolve_session_by_title(name)\n         if not target_id:\n             return t(\"gateway.resume.not_found\", name=name)\n         # Compression creates child continuations that hold the live transcript."]},{"file":"locales/af.yaml","hunks":["@@ -222,9 +222,12 @@ gateway:\n     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.\"\n     list_header:           \"📋 **Benoemde Sessies**\\n\"\n     list_item:             \"• **{title}**{preview_part}\"\n+    list_item_numbered:    \"{index}. **{title}**{preview_part}\"\n     list_preview_suffix:   \" — _{preview}_\"\n     list_footer:           \"\\nGebruik: `/resume <session name>`\"\n+    list_footer_numbered:  \"\\nGebruik: `/resume <sessienaam>` of `/resume <nommer>` (bv. `/resume 1` vir die mees onlangse)\"\n     list_failed:           \"Kon nie sessies lys nie: {error}\"\n+    out_of_range:          \"Hervat-indeks {index} is buite bereik.\\nGebruik `/resume` sonder argumente om beskikbare sessies te sien.\"\n     not_found:             \"Geen sessie gevind wat by '**{name}**' pas nie.\\nGebruik `/resume` sonder argumente om beskikbare sessies te sien.\"\n     already_on:            \"📌 Reeds op sessie **{name}**.\"\n     switch_failed:         \"Kon nie sessie verander nie.\""]},{"file":"locales/de.yaml","hunks":["@@ -222,9 +222,12 @@ gateway:\n     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.\"\n     list_header:           \"📋 **Benannte Sitzungen**\\n\"\n     list_item:             \"• **{title}**{preview_part}\"\n+    list_item_numbered:    \"{index}. **{title}**{preview_part}\"\n     list_preview_suffix:   \" — _{preview}_\"\n     list_footer:           \"\\nVerwendung: `/resume <Sitzungsname>`\"\n+    list_footer_numbered:  \"\\nVerwendung: `/resume <Sitzungsname>` oder `/resume <Nummer>` (z. B. `/resume 1` für die zuletzt verwendete)\"\n     list_failed:           \"Sitzungen konnten nicht aufgelistet werden: {error}\"\n+    out_of_range:          \"Wiederaufnahme-Index {index} liegt außerhalb des gültigen Bereichs.\\nVerwenden Sie `/resume` ohne Argumente, um verfügbare Sitzungen anzuzeigen.\"\n     not_found:             \"Keine Sitzung passend zu '**{name}**' gefunden.\\nVerwenden Sie `/resume` ohne Argumente, um verfügbare Sitzungen zu sehen.\"\n     already_on:            \"📌 Bereits in Sitzung **{name}**.\"\n     switch_failed:         \"Sitzungswechsel fehlgeschlagen.\""]},{"file":"locales/en.yaml","hunks":["@@ -237,9 +237,12 @@ gateway:\n     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.\"\n     list_header:           \"📋 **Named Sessions**\\n\"\n     list_item:             \"• **{title}**{preview_part}\"\n+    list_item_numbered:    \"{index}. **{title}**{preview_part}\"\n     list_preview_suffix:   \" — _{preview}_\"\n     list_footer:           \"\\nUsage: `/resume <session name>`\"\n+    list_footer_numbered:  \"\\nUsage: `/resume <session name>` or `/resume <number>` (e.g. `/resume 1` for the most recent)\"\n     list_failed:           \"Could not list sessions: {error}\"\n+    out_of_range:          \"Resume index {index} is out of range.\\nUse `/resume` with no arguments to see available sessions.\"\n     not_found:             \"No session found matching '**{name}**'.\\nUse `/resume` with no arguments to see available sessions.\"\n     already_on:            \"📌 Already on session **{name}**.\"\n     switch_failed:         \"Failed to switch session.\""]},{"file":"locales/es.yaml","hunks":["@@ -222,9 +222,12 @@ gateway:\n     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.\"\n     list_header:           \"📋 **Sesiones con nombre**\\n\"\n     list_item:             \"• **{title}**{preview_part}\"\n+    list_item_numbered:    \"{index}. **{title}**{preview_part}\"\n     list_preview_suffix:   \" — _{preview}_\"\n     list_footer:           \"\\nUso: `/resume <nombre de sesión>`\"\n+    list_footer_numbered:  \"\\nUso: `/resume <nombre de sesión>` o `/resume <número>` (p. ej. `/resume 1` para la más reciente)\"\n     list_failed:           \"No se pudieron listar las sesiones: {error}\"\n+    out_of_range:          \"El índice de reanudación {index} está fuera de rango.\\nUsa `/resume` sin argumentos para ver las sesiones disponibles.\"\n     not_found:             \"No se encontró ninguna sesión que coincida con '**{name}**'.\\nUsa `/resume` sin argumentos para ver las sesiones disponibles.\"\n     already_on:            \"📌 Ya estás en la sesión **{name}**.\"\n     switch_failed:         \"No se pudo cambiar de sesión.\""]},{"file":"locales/fr.yaml","hunks":["@@ -222,9 +222,12 @@ gateway:\n     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.\"\n     list_header:           \"📋 **Sessions nommées**\\n\"\n     list_item:             \"• **{title}**{preview_part}\"\n+    list_item_numbered:    \"{index}. **{title}**{preview_part}\"\n     list_preview_suffix:   \" — _{preview}_\"\n     list_footer:           \"\\nUsage : `/resume <nom de session>`\"\n+    list_footer_numbered:  \"\\nUtilisation : `/resume <nom de session>` ou `/resume <numéro>` (par exemple `/resume 1` pour la plus récente)\"\n     list_failed:           \"Impossible de lister les sessions : {error}\"\n+    out_of_range:          \"L'index de reprise {index} est hors limites.\\nUtilisez `/resume` sans arguments pour voir les sessions disponibles.\"\n     not_found:             \"Aucune session correspondant à '**{name}**' trouvée.\\nUtilisez `/resume` sans argument pour voir les sessions disponibles.\"\n     already_on:            \"📌 Déjà sur la session **{name}**.\"\n     switch_failed:         \"Échec du changement de session.\""]},{"file":"locales/ga.yaml","hunks":["@@ -226,9 +226,12 @@ gateway:\n     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í.\"\n     list_header:           \"📋 **Seisiúin Ainmnithe**\\n\"\n     list_item:             \"• **{title}**{preview_part}\"\n+    list_item_numbered:    \"{index}. **{title}**{preview_part}\"\n     list_preview_suffix:   \" — _{preview}_\"\n     list_footer:           \"\\nÚsáid: `/resume <session name>`\"\n+    list_footer_numbered:  \"\\nÚsáid: `/resume <ainm seisiúin>` nó `/resume <uimhir>` (m.sh. `/resume 1` don cheann is déanaí)\"\n     list_failed:           \"Níorbh fhéidir seisiúin a liostáil: {error}\"\n+    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.\"\n     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.\"\n     already_on:            \"📌 Cheana ar an seisiún **{name}**.\"\n     switch_failed:         \"Theip ar athrú seisiúin.\""]},{"file":"locales/hu.yaml","hunks":["@@ -222,9 +222,12 @@ gateway:\n     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á.\"\n     list_header:           \"📋 **Elnevezett munkamenetek**\\n\"\n     list_item:             \"• **{title}**{preview_part}\"\n+    list_item_numbered:    \"{index}. **{title}**{preview_part}\"\n     list_preview_suffix:   \" — _{preview}_\"\n     list_footer:           \"\\nHasználat: `/resume <munkamenet neve>`\"\n+    list_footer_numbered:  \"\\nHasználat: `/resume <munkamenet neve>` vagy `/resume <szám>` (pl. `/resume 1` a legutóbbihoz)\"\n     list_failed:           \"Nem sikerült listázni a munkameneteket: {error}\"\n+    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.\"\n     not_found:             \"Nem található '**{name}**' nevű munkamenet.\\nArgumentumok nélkül használd a `/resume` parancsot az elérhető munkamenetek megtekintéséhez.\"\n     already_on:            \"📌 Már a **{name}** munkamenetben vagy.\"\n     switch_failed:         \"Nem sikerült munkamenetet váltani.\""]},{"file":"locales/it.yaml","hunks":["@@ -222,9 +222,12 @@ gateway:\n     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.\"\n     list_header:           \"📋 **Sessioni con nome**\\n\"\n     list_item:             \"• **{title}**{preview_part}\"\n+    list_item_numbered:    \"{index}. **{title}**{preview_part}\"\n     list_preview_suffix:   \" — _{preview}_\"\n     list_footer:           \"\\nUso: `/resume <session name>`\"\n+    list_footer_numbered:  \"\\nUso: `/resume <nome sessione>` o `/resume <numero>` (es. `/resume 1` per la più recente)\"\n     list_failed:           \"Impossibile elencare le sessioni: {error}\"\n+    out_of_range:          \"L'indice di ripresa {index} è fuori intervallo.\\nUsa `/resume` senza argomenti per vedere le sessioni disponibili.\"\n     not_found:             \"Nessuna sessione trovata corrispondente a '**{name}**'.\\nUsa `/resume` senza argomenti per vedere le sessioni disponibili.\"\n     already_on:            \"📌 Già nella sessione **{name}**.\"\n     switch_failed:         \"Cambio di sessione non riuscito.\""]},{"file":"locales/ja.yaml","hunks":["@@ -222,9 +222,12 @@ gateway:\n     no_named_sessions:     \"名前付きセッションが見つかりません。\\n`/title セッション名` で現在のセッションに名前を付けると、後で `/resume セッション名` で戻れます。\"\n     list_header:           \"📋 **名前付きセッション**\\n\"\n     list_item:             \"• **{title}**{preview_part}\"\n+    list_item_numbered:    \"{index}. **{title}**{preview_part}\"\n     list_preview_suffix:   \" — _{preview}_\"\n     list_footer:           \"\\n使い方: `/resume <セッション名>`\"\n+    list_footer_numbered:  \"\\n使い方: `/resume <セッション名>` または `/resume <番号>`（例: 最新のセッションには `/resume 1`）\"\n     list_failed:           \"セッションを一覧表示できませんでした: {error}\"\n+    out_of_range:          \"再開インデックス {index} は範囲外です。\\n引数なしで `/resume` を実行すると、利用可能なセッションが表示されます。\"\n     not_found:             \"'**{name}**' に一致するセッションが見つかりません。\\n引数なしで `/resume` を実行すると利用可能なセッションを表示します。\"\n     already_on:            \"📌 既にセッション **{name}** にいます。\"\n     switch_failed:         \"セッションの切り替えに失敗しました。\""]},{"file":"locales/ko.yaml","hunks":["@@ -222,9 +222,12 @@ gateway:\n     no_named_sessions:     \"이름이 지정된 세션이 없습니다.\\n현재 세션에 이름을 지정하려면 `/title 내 세션`을 사용하고, 나중에 `/resume 내 세션`으로 돌아오세요.\"\n     list_header:           \"📋 **이름이 지정된 세션**\\n\"\n     list_item:             \"• **{title}**{preview_part}\"\n+    list_item_numbered:    \"{index}. **{title}**{preview_part}\"\n     list_preview_suffix:   \" — _{preview}_\"\n     list_footer:           \"\\n사용법: `/resume <session name>`\"\n+    list_footer_numbered:  \"\\n사용법: `/resume <세션 이름>` 또는 `/resume <번호>` (예: 가장 최근 세션은 `/resume 1`)\"\n     list_failed:           \"세션 목록을 가져올 수 없습니다: {error}\"\n+    out_of_range:          \"재개 인덱스 {index}이(가) 범위를 벗어났습니다.\\n인자 없이 `/resume`을 실행하면 사용 가능한 세션이 표시됩니다.\"\n     not_found:             \"'**{name}**'와 일치하는 세션이 없습니다.\\n사용 가능한 세션을 보려면 인수 없이 `/resume`을 사용하세요.\"\n     already_on:            \"📌 이미 **{name}** 세션에 있습니다.\"\n     switch_failed:         \"세션 전환에 실패했습니다.\""]},{"file":"locales/pt.yaml","hunks":["@@ -222,9 +222,12 @@ gateway:\n     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.\"\n     list_header:           \"📋 **Sessões com nome**\\n\"\n     list_item:             \"• **{title}**{preview_part}\"\n+    list_item_numbered:    \"{index}. **{title}**{preview_part}\"\n     list_preview_suffix:   \" — _{preview}_\"\n     list_footer:           \"\\nUso: `/resume <nome da sessão>`\"\n+    list_footer_numbered:  \"\\nUso: `/resume <nome da sessão>` ou `/resume <número>` (ex.: `/resume 1` para a mais recente)\"\n     list_failed:           \"Não foi possível listar as sessões: {error}\"\n+    out_of_range:          \"O índice de retomada {index} está fora do intervalo.\\nUse `/resume` sem argumentos para ver as sessões disponíveis.\"\n     not_found:             \"Não foi encontrada nenhuma sessão correspondente a '**{name}**'.\\nUsa `/resume` sem argumentos para ver as sessões disponíveis.\"\n     already_on:            \"📌 Já estás na sessão **{name}**.\"\n     switch_failed:         \"Falha ao mudar de sessão.\""]},{"file":"locales/ru.yaml","hunks":["@@ -222,9 +222,12 @@ gateway:\n     no_named_sessions:     \"Именованных сеансов не найдено.\\nИспользуйте `/title Мой сеанс`, чтобы назвать текущий сеанс, затем `/resume Мой сеанс`, чтобы вернуться к нему позже.\"\n     list_header:           \"📋 **Именованные сеансы**\\n\"\n     list_item:             \"• **{title}**{preview_part}\"\n+    list_item_numbered:    \"{index}. **{title}**{preview_part}\"\n     list_preview_suffix:   \" — _{preview}_\"\n     list_footer:           \"\\nИспользование: `/resume <название сеанса>`\"\n+    list_footer_numbered:  \"\\nИспользование: `/resume <имя сеанса>` или `/resume <номер>` (например, `/resume 1` для самого недавнего)\"\n     list_failed:           \"Не удалось получить список сеансов: {error}\"\n+    out_of_range:          \"Индекс возобновления {index} вне диапазона.\\nИспользуйте `/resume` без аргументов, чтобы увидеть доступные сеансы.\"\n     not_found:             \"Сеанс, соответствующий '**{name}**', не найден.\\nИспользуйте `/resume` без аргументов, чтобы увидеть доступные сеансы.\"\n     already_on:            \"📌 Уже в сеансе **{name}**.\"\n     switch_failed:         \"Не удалось переключить сеанс.\""]},{"file":"locales/tr.yaml","hunks":["@@ -222,9 +222,12 @@ gateway:\n     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.\"\n     list_header:           \"📋 **Adlandırılmış Oturumlar**\\n\"\n     list_item:             \"• **{title}**{preview_part}\"\n+    list_item_numbered:    \"{index}. **{title}**{preview_part}\"\n     list_preview_suffix:   \" — _{preview}_\"\n     list_footer:           \"\\nKullanım: `/resume <oturum adı>`\"\n+    list_footer_numbered:  \"\\nKullanım: `/resume <oturum adı>` veya `/resume <numara>` (örn. en yenisi için `/resume 1`)\"\n     list_failed:           \"Oturumlar listelenemedi: {error}\"\n+    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.\"\n     not_found:             \"'**{name}**' ile eşleşen oturum bulunamadı.\\nKullanılabilir oturumları görmek için argümansız `/resume` kullanın.\"\n     already_on:            \"📌 Zaten **{name}** oturumundasınız.\"\n     switch_failed:         \"Oturum değiştirilemedi.\""]},{"file":"locales/uk.yaml","hunks":["@@ -222,9 +222,12 @@ gateway:\n     no_named_sessions:     \"Іменованих сеансів не знайдено.\\nВикористайте `/title Мій сеанс`, щоб назвати поточний сеанс, потім `/resume Мій сеанс`, щоб повернутися до нього.\"\n     list_header:           \"📋 **Іменовані сеанси**\\n\"\n     list_item:             \"• **{title}**{preview_part}\"\n+    list_item_numbered:    \"{index}. **{title}**{preview_part}\"\n     list_preview_suffix:   \" — _{preview}_\"\n     list_footer:           \"\\nВикористання: `/resume <назва сеансу>`\"\n+    list_footer_numbered:  \"\\nВикористання: `/resume <назва сесії>` або `/resume <номер>` (наприклад, `/resume 1` для найновішої)\"\n     list_failed:           \"Не вдалося отримати список сеансів: {error}\"\n+    out_of_range:          \"Індекс відновлення {index} поза межами діапазону.\\nВикористовуйте `/resume` без аргументів, щоб переглянути доступні сесії.\"\n     not_found:             \"Сеанс, що відповідає '**{name}**', не знайдено.\\nВикористайте `/resume` без аргументів, щоб побачити доступні сеанси.\"\n     already_on:            \"📌 Уже в сеансі **{name}**.\"\n     switch_failed:         \"Не вдалося переключити сеанс.\""]},{"file":"locales/zh-hant.yaml","hunks":["@@ -222,9 +222,12 @@ gateway:\n     no_named_sessions:     \"找不到已命名的工作階段。\\n使用 `/title 我的工作階段` 為目前工作階段命名，然後使用 `/resume 我的工作階段` 返回。\"\n     list_header:           \"📋 **已命名工作階段**\\n\"\n     list_item:             \"• **{title}**{preview_part}\"\n+    list_item_numbered:    \"{index}. **{title}**{preview_part}\"\n     list_preview_suffix:   \" — _{preview}_\"\n     list_footer:           \"\\n用法：`/resume <工作階段名稱>`\"\n+    list_footer_numbered:  \"\\n用法：`/resume <會話名稱>` 或 `/resume <編號>`（例如，`/resume 1` 表示最近的會話）\"\n     list_failed:           \"無法列出工作階段：{error}\"\n+    out_of_range:          \"恢復索引 {index} 超出範圍。\\n請使用不帶參數的 `/resume` 查看可用會話。\"\n     not_found:             \"找不到符合 '**{name}**' 的工作階段。\\n使用不帶參數的 `/resume` 檢視可用的工作階段。\"\n     already_on:            \"📌 已在工作階段 **{name}** 上。\"\n     switch_failed:         \"切換工作階段失敗。\""]},{"file":"locales/zh.yaml","hunks":["@@ -222,9 +222,12 @@ gateway:\n     no_named_sessions:     \"未找到已命名的会话。\\n使用 `/title 我的会话` 为当前会话命名，然后用 `/resume 我的会话` 返回。\"\n     list_header:           \"📋 **已命名会话**\\n\"\n     list_item:             \"• **{title}**{preview_part}\"\n+    list_item_numbered:    \"{index}. **{title}**{preview_part}\"\n     list_preview_suffix:   \" — _{preview}_\"\n     list_footer:           \"\\n用法：`/resume <会话名称>`\"\n+    list_footer_numbered:  \"\\n用法：`/resume <会话名称>` 或 `/resume <编号>`（例如，`/resume 1` 表示最近的会话）\"\n     list_failed:           \"无法列出会话：{error}\"\n+    out_of_range:          \"恢复索引 {index} 超出范围。\\n请使用不带参数的 `/resume` 查看可用会话。\"\n     not_found:             \"未找到匹配 '**{name}**' 的会话。\\n使用不带参数的 `/resume` 查看可用会话。\"\n     already_on:            \"📌 已在会话 **{name}** 上。\"\n     switch_failed:         \"切换会话失败。\""]},{"file":"scripts/release.py","hunks":["@@ -1283,6 +1283,7 @@\n     \"[redacted:email]\": \"egilewski\",  # PR #30432 (MEDIA path traversal fix, GHSA-jmf9-9729-7pp8)\n     \"[redacted:email]\": \"McClean-Edison\",  # PR #29817 (register_auxiliary_task plugin API)\n     \"[redacted:email]\": \"SamuelZ12\",  # PR #7480 (show recap after in-session resume)\n+    \"[redacted:email]\": \"daizhonggeng\",  # PR #9020 (numbered /resume selection)\n }\n \n "]},{"file":"tests/cli/test_cli_init.py","hunks":["@@ -331,7 +331,8 @@ def test_resume_without_target_lists_recent_sessions(self, capsys):\n \n         assert \"Recent sessions\" in output\n         assert \"Checking Running Hermes Agent\" in output\n-        assert \"Use /resume <session id or title> to continue\" in output\n+        assert \"Use /resume\" in output\n+        assert \"session title\" in output\n \n     def test_resume_updates_hermes_session_id_env_and_context(self, tmp_path):\n         from gateway.session_context import _UNSET, _VAR_MAP, get_session_env"]},{"file":"tests/cli/test_cli_resume_command.py","hunks":["@@ -0,0 +1,77 @@\n+from unittest.mock import MagicMock, patch\n+\n+from cli import HermesCLI\n+\n+\n+def _make_cli():\n+    cli_obj = HermesCLI.__new__(HermesCLI)\n+    cli_obj.session_id = \"current_session\"\n+    cli_obj._resumed = False\n+    cli_obj._pending_title = None\n+    cli_obj.conversation_history = []\n+    cli_obj.agent = None\n+    cli_obj._session_db = MagicMock()\n+    # _handle_resume_command now triggers _display_resumed_history (#31695),\n+    # which reads self.resume_display. \"minimal\" short-circuits the recap so\n+    # the test only exercises session-switch behavior.\n+    cli_obj.resume_display = \"minimal\"\n+    return cli_obj\n+\n+\n+class TestCliResumeCommand:\n+    def test_show_recent_sessions_includes_indexes_and_resume_hint(self, capsys):\n+        cli_obj = _make_cli()\n+        cli_obj._list_recent_sessions = MagicMock(return_value=[\n+            {\"id\": \"sess_002\", \"title\": \"Coding\", \"preview\": \"build feature\", \"last_active\": None},\n+            {\"id\": \"sess_001\", \"title\": \"Research\", \"preview\": \"read docs\", \"last_active\": None},\n+        ])\n+\n+        shown = cli_obj._show_recent_sessions(reason=\"resume\")\n+        output = capsys.readouterr().out\n+\n+        assert shown is True\n+        assert \"1\" in output\n+        assert \"2\" in output\n+        assert \"Coding\" in output\n+        assert \"Research\" in output\n+        assert \"/resume 2\" in output\n+        assert \"/resume <session title>\" in output\n+\n+    def test_handle_resume_by_index_switches_to_numbered_session(self):\n+        cli_obj = _make_cli()\n+        cli_obj._list_recent_sessions = MagicMock(return_value=[\n+            {\"id\": \"sess_002\", \"title\": \"Coding\"},\n+            {\"id\": \"sess_001\", \"title\": \"Research\"},\n+        ])\n+        cli_obj._session_db.get_session.return_value = {\"id\": \"sess_001\", \"title\": \"Research\"}\n+        cli_obj._session_db.get_messages_as_conversation.return_value = [\n+            {\"role\": \"user\", \"content\": \"hello\"},\n+            {\"role\": \"assistant\", \"content\": \"hi\"},\n+        ]\n+        # resolve_resume_session_id passes the id through when no compression chain.\n+        cli_obj._session_db.resolve_resume_session_id.return_value = \"sess_001\"\n+\n+        with (\n+            patch(\"hermes_cli.main._resolve_session_by_name_or_id\", return_value=None),\n+            patch(\"cli._cprint\") as mock_cprint,\n+        ):\n+            cli_obj._handle_resume_command(\"/resume 2\")\n+\n+        printed = \" \".join(str(call) for call in mock_cprint.call_args_list)\n+        assert cli_obj.session_id == \"sess_001\"\n+        assert \"Resumed session sess_001\" in printed\n+        assert \"Research\" in printed\n+\n+    def test_handle_resume_by_index_out_of_range(self):\n+        cli_obj = _make_cli()\n+        cli_obj._list_recent_sessions = MagicMock(return_value=[\n+            {\"id\": \"sess_002\", \"title\": \"Coding\"},\n+        ])\n+\n+        with patch(\"cli._cprint\") as mock_cprint:\n+            cli_obj._handle_resume_command(\"/resume 9\")\n+\n+        printed = \" \".join(str(call) for call in mock_cprint.call_args_list)\n+        assert \"out of range\" in printed.lower()\n+        assert \"/resume\" in printed\n+        assert cli_obj.session_id == \"current_session\""]},{"file":"tests/gateway/test_resume_command.py","hunks":["@@ -88,6 +88,9 @@ async def test_list_named_sessions_when_no_arg(self, tmp_path):\n         assert \"Research\" in result\n         assert \"Coding\" in result\n         assert \"Named Sessions\" in result\n+        assert \"1.\" in result\n+        assert \"2.\" in result\n+        assert \"/resume 1\" in result\n         db.close()\n \n     @pytest.mark.asyncio","@@ -104,6 +107,47 @@ async def test_list_shows_usage_when_no_titled(self, tmp_path):\n         assert \"/title\" in result\n         db.close()\n \n+    @pytest.mark.asyncio\n+    async def test_resume_by_index(self, tmp_path):\n+        \"\"\"Numeric argument resumes the indexed titled session from the list.\"\"\"\n+        from hermes_state import SessionDB\n+        db = SessionDB(db_path=tmp_path / \"state.db\")\n+        db.create_session(\"sess_001\", \"telegram\")\n+        db.create_session(\"sess_002\", \"telegram\")\n+        db.set_session_title(\"sess_001\", \"Research\")\n+        db.set_session_title(\"sess_002\", \"Coding\")\n+        db.create_session(\"current_session_001\", \"telegram\")\n+\n+        event = _make_event(text=\"/resume 2\")\n+        runner = _make_runner(session_db=db, current_session_id=\"current_session_001\",\n+                              event=event)\n+        result = await runner._handle_resume_command(event)\n+\n+        assert \"Resumed\" in result\n+        runner.session_store.switch_session.assert_called_once()\n+        call_args = runner.session_store.switch_session.call_args\n+        assert call_args[0][1] == \"sess_001\"\n+        db.close()\n+\n+    @pytest.mark.asyncio\n+    async def test_resume_index_out_of_range(self, tmp_path):\n+        \"\"\"Out-of-range numeric arguments show a helpful error.\"\"\"\n+        from hermes_state import SessionDB\n+        db = SessionDB(db_path=tmp_path / \"state.db\")\n+        db.create_session(\"sess_001\", \"telegram\")\n+        db.set_session_title(\"sess_001\", \"Research\")\n+        db.create_session(\"current_session_001\", \"telegram\")\n+\n+        event = _make_event(text=\"/resume 9\")\n+        runner = _make_runner(session_db=db, current_session_id=\"current_session_001\",\n+                              event=event)\n+        result = await runner._handle_resume_command(event)\n+\n+        assert \"out of range\" in result.lower()\n+        assert \"/resume\" in result\n+        runner.session_store.switch_session.assert_not_called()\n+        db.close()\n+\n     @pytest.mark.asyncio\n     async def test_resume_by_name(self, tmp_path):\n         \"\"\"Resolves a title and switches to that session.\"\"\"\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}