{"repo":"NousResearch/hermes-agent","public_id":"4090","display_label":"Quick commands missing from /help output","summary":"These issues and pull requests concern quick commands not appearing in the /help output. Multiple fix attempts address making quick commands visible in the CLI help display.","subsystem":"hermes_cli","counts":{"members":4,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":1,"merged_prs":1,"attempt_prs":2},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":44038,"merged_at":"2026-06-11T10:04:31+00:00"},"resolution":{"state":"resolved","by":"pr-44038"},"freshness":{"state":"verified","last_verified_at":"2026-06-11T10:04:31+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.5},"fix_edges":[{"issue_number":4090,"pr_number":44038,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true,"pr_merged_at":"2026-06-11T10:04:31+00:00"}],"members":[{"kind":"issue","number":4090,"title":"[UX] Quick commands not shown in /help output","url":null,"state":"closed"},{"kind":"pr","number":4187,"title":"fix(cli): show quick commands in /help output","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#44038","number":44038}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":4258,"title":"fix: show quick commands in /help output","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#4187","number":4187}],"category":"superseded_by_pr","implication":"superseded"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":44038,"title":"fix(cli): show quick commands in /help output","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["cli.py","hermes_state.py","scripts/release.py","tests/test_empty_session_hygiene.py"],"fix_hunks":[{"file":"cli.py","hunks":["@@ -5552,6 +5552,15 @@ def show_help(self):\n                     f\"{_escape(desc)} [dim]({skill_count} skills)[/]\"\n                 )\n \n+        quick_commands = self.config.get(\"quick_commands\", {})\n+        if quick_commands:\n+            _cprint(f\"\\n  ⚡ {_BOLD}Quick Commands{_RST} ({len(quick_commands)} configured):\")\n+            for name, qcmd in sorted(quick_commands.items()):\n+                desc = qcmd.get(\"description\", qcmd.get(\"type\", \"\"))\n+                ChatConsole().print(\n+                    f\"    [bold {_accent_hex()}]{('/' + name):<22}[/] [dim]-[/] {_escape(desc)}\"\n+                )\n+\n         _cprint(f\"\\n  {_DIM}Tip: Just type your message to chat with Hermes!{_RST}\")\n         _cprint(f\"  {_DIM}Multi-line: Alt+Enter for a new line{_RST}\")\n         _cprint(f\"  {_DIM}Draft editor: Ctrl+G (Alt+G in VSCode/Cursor){_RST}\")","@@ -5821,6 +5830,35 @@ def _notify_session_boundary(self, event_type: str) -> None:\n         except Exception:\n             pass\n \n+    def _discard_session_if_empty(self, session_id: Optional[str]) -> bool:\n+        \"\"\"Drop a just-ended session row when it never gained content.\n+\n+        Starting the CLI and immediately quitting (or rotating with /new,\n+        /clear) used to leave an empty untitled row behind that clutters\n+        ``/resume`` and ``hermes sessions list``. Delegates the\n+        check-and-delete to ``SessionDB.delete_session_if_empty``, which\n+        only removes rows with no messages, no title, and no child\n+        sessions. Ported from google-gemini/gemini-cli#27770.\n+        \"\"\"\n+        if not self._session_db or not session_id:\n+            return False\n+        # In-memory transcript is authoritative: if this CLI object holds\n+        # conversation messages (flushed to the DB or not), the session is\n+        # not empty. Protects against pruning a real conversation whose DB\n+        # flush failed or hasn't happened yet.\n+        if getattr(self, \"conversation_history\", None):\n+            return False\n+        try:\n+            from hermes_constants import get_hermes_home as _ghh\n+            return self._session_db.delete_session_if_empty(\n+                session_id, sessions_dir=_ghh() / \"sessions\"\n+            )\n+        except Exception:\n+            logger.debug(\n+                \"Could not prune empty session %s\", session_id, exc_info=True\n+            )\n+            return False\n+\n     def new_session(self, silent=False, title=None):\n         \"\"\"Start a fresh session with a new session ID and cleared agent state.\"\"\"\n         if self.agent and self.conversation_history:","@@ -5837,6 +5875,9 @@ def new_session(self, silent=False, title=None):\n                 self._session_db.end_session(old_session_id, \"new_session\")\n             except Exception:\n                 pass\n+            # Don't let immediately-rotated empty sessions pile up in\n+            # /resume and `hermes sessions list` (gemini-cli#27770 port).\n+            self._discard_session_if_empty(old_session_id)\n \n         self.session_start = datetime.now()\n         timestamp_str = self.session_start.strftime(\"%Y%m%d_%H%M%S\")","@@ -13074,6 +13115,15 @@ def new_event_loop(self):\n                     self._session_db.end_session(self.agent.session_id, \"cli_close\")\n                 except (Exception, KeyboardInterrupt) as e:\n                     logger.debug(\"Could not close session in DB: %s\", e)\n+                # Started-and-immediately-quit sessions never gained content;\n+                # drop the empty row so /resume and `hermes sessions list`\n+                # stay clean (gemini-cli#27770 port). No-op for resumed or\n+                # titled sessions and anything with messages or children.\n+                if not getattr(self, '_delete_session_on_exit', False):\n+                    try:\n+                        self._discard_session_if_empty(self.agent.session_id)\n+                    except (Exception, KeyboardInterrupt) as e:\n+                        logger.debug(\"Could not prune empty session: %s\", e)\n                 # /exit --delete: also remove the current session's transcripts\n                 # and SQLite history. Ported from google-gemini/gemini-cli#19332.\n                 if getattr(self, '_delete_session_on_exit', False):"]},{"file":"hermes_state.py","hunks":["@@ -3658,6 +3658,48 @@ def _do(conn):\n             self._remove_session_files(sessions_dir, session_id)\n         return deleted\n \n+    def delete_session_if_empty(\n+        self,\n+        session_id: str,\n+        sessions_dir: Optional[Path] = None,\n+    ) -> bool:\n+        \"\"\"Delete *session_id* only when it never gained resumable content.\n+\n+        A session is considered empty when it has no messages and no\n+        user-assigned title. Used by CLI exit / session-rotation paths so\n+        immediately-started-and-quit sessions don't pile up in ``/resume``\n+        and ``hermes sessions list`` output. (Pattern ported from\n+        google-gemini/gemini-cli#27770.)\n+\n+        The emptiness check and delete run in one transaction, so a message\n+        flushed concurrently by another writer can't be lost. Sessions with\n+        children (delegate subagent runs) are preserved — a parent that\n+        spawned work is not \"empty\" even if its own transcript never\n+        flushed. Returns True if the session was deleted.\n+        \"\"\"\n+        def _do(conn):\n+            cursor = conn.execute(\n+                \"\"\"\n+                DELETE FROM sessions\n+                WHERE id = ?\n+                  AND title IS NULL\n+                  AND NOT EXISTS (\n+                      SELECT 1 FROM messages WHERE messages.session_id = sessions.id\n+                  )\n+                  AND NOT EXISTS (\n+                      SELECT 1 FROM sessions child\n+                      WHERE child.parent_session_id = sessions.id\n+                  )\n+                \"\"\",\n+                (session_id,),\n+            )\n+            return cursor.rowcount > 0\n+\n+        deleted = self._execute_write(_do)\n+        if deleted:\n+            self._remove_session_files(sessions_dir, session_id)\n+        return bool(deleted)\n+\n     def delete_sessions(\n         self,\n         session_ids: List[str],"]},{"file":"scripts/release.py","hunks":["@@ -75,6 +75,7 @@\n     \"129007007+HeLLGURD@users.noreply.github.com\": \"HeLLGURD\",\n     \"290859878+synapsesx@users.noreply.github.com\": \"synapsesx\",\n     \"dirtyren@users.noreply.github.com\": \"dirtyren\",\n+    \"[redacted:email]\": \"mvanhorn\",\n     \"[redacted:email]\": \"youjunxiaji\",\n     \"[redacted:email]\": \"NormallyGaussian\",\n     \"[redacted:email]\": \"mollusk\","]},{"file":"tests/test_empty_session_hygiene.py","hunks":["@@ -0,0 +1,161 @@\n+\"\"\"Tests for empty-session hygiene — gemini-cli#27770 port.\n+\n+Starting the CLI and immediately quitting (or rotating sessions with /new)\n+used to leave empty untitled rows in the session DB that clutter /resume\n+and `hermes sessions list`. ``SessionDB.delete_session_if_empty`` removes\n+a just-ended session row only when it never gained resumable content:\n+no messages, no title, and no child sessions.\n+\"\"\"\n+\n+import pytest\n+\n+from hermes_state import SessionDB\n+\n+\n[redacted:email]()\n+def db(tmp_path):\n+    session_db = SessionDB(db_path=tmp_path / \"state.db\")\n+    yield session_db\n+    session_db.close()\n+\n+\n+class TestDeleteSessionIfEmpty:\n+    def test_deletes_empty_untitled_session(self, db):\n+        db.create_session(session_id=\"empty\", source=\"cli\", model=\"test\")\n+        db.end_session(\"empty\", \"cli_close\")\n+\n+        assert db.delete_session_if_empty(\"empty\") is True\n+        assert db.get_session(\"empty\") is None\n+\n+    def test_keeps_session_with_messages(self, db):\n+        db.create_session(session_id=\"busy\", source=\"cli\", model=\"test\")\n+        db.append_message(\"busy\", role=\"user\", content=\"hello\")\n+        db.end_session(\"busy\", \"cli_close\")\n+\n+        assert db.delete_session_if_empty(\"busy\") is False\n+        assert db.get_session(\"busy\") is not None\n+\n+    def test_keeps_titled_session(self, db):\n+        \"\"\"A user-assigned title is resumable content even without messages.\"\"\"\n+        db.create_session(session_id=\"titled\", source=\"cli\", model=\"test\")\n+        db.set_session_title(\"titled\", \"Important plans\")\n+        db.end_session(\"titled\", \"cli_close\")\n+\n+        assert db.delete_session_if_empty(\"titled\") is False\n+        assert db.get_session(\"titled\") is not None\n+\n+    def test_keeps_session_with_children(self, db):\n+        \"\"\"A parent that spawned delegate subagent runs is not empty.\"\"\"\n+        db.create_session(session_id=\"parent\", source=\"cli\", model=\"test\")\n+        db.create_session(\n+            session_id=\"child\",\n+            source=\"tool\",\n+            model=\"test\",\n+            parent_session_id=\"parent\",\n+        )\n+        db.end_session(\"parent\", \"cli_close\")\n+\n+        assert db.delete_session_if_empty(\"parent\") is False\n+        assert db.get_session(\"parent\") is not None\n+        assert db.get_session(\"child\") is not None\n+\n+    def test_unknown_session_returns_false(self, db):\n+        assert db.delete_session_if_empty(\"nope\") is False\n+\n+    def test_removes_on_disk_transcripts(self, db, tmp_path):\n+        sessions_dir = tmp_path / \"sessions\"\n+        sessions_dir.mkdir()\n+        (sessions_dir / \"empty.json\").write_text(\"{}\", encoding=\"utf-8\")\n+        (sessions_dir / \"empty.jsonl\").write_text(\"\", encoding=\"utf-8\")\n+\n+        db.create_session(session_id=\"empty\", source=\"cli\", model=\"test\")\n+        db.end_session(\"empty\", \"cli_close\")\n+\n+        assert db.delete_session_if_empty(\"empty\", sessions_dir=sessions_dir)\n+        assert not (sessions_dir / \"empty.json\").exists()\n+        assert not (sessions_dir / \"empty.jsonl\").exists()\n+\n+    def test_no_file_cleanup_when_kept(self, db, tmp_path):\n+        sessions_dir = tmp_path / \"sessions\"\n+        sessions_dir.mkdir()\n+        (sessions_dir / \"busy.json\").write_text(\"{}\", encoding=\"utf-8\")\n+\n+        db.create_session(session_id=\"busy\", source=\"cli\", model=\"test\")\n+        db.append_message(\"busy\", role=\"user\", content=\"hello\")\n+\n+        assert not db.delete_session_if_empty(\"busy\", sessions_dir=sessions_dir)\n+        assert (sessions_dir / \"busy.json\").exists()\n+\n+    def test_empty_session_disappears_from_listing(self, db):\n+        \"\"\"The user-facing symptom: empty rows polluting session lists.\"\"\"\n+        db.create_session(session_id=\"real\", source=\"cli\", model=\"test\")\n+        db.append_message(\"real\", role=\"user\", content=\"do the thing\")\n+        db.end_session(\"real\", \"cli_close\")\n+\n+        db.create_session(session_id=\"ghost\", source=\"cli\", model=\"test\")\n+        db.end_session(\"ghost\", \"cli_close\")\n+\n+        ids_before = {s[\"id\"] for s in db.list_sessions_rich(source=\"cli\")}\n+        assert {\"real\", \"ghost\"} <= ids_before\n+\n+        db.delete_session_if_empty(\"ghost\")\n+\n+        ids_after = {s[\"id\"] for s in db.list_sessions_rich(source=\"cli\")}\n+        assert \"real\" in ids_after\n+        assert \"ghost\" not in ids_after\n+\n+\n+class TestCLIDiscardSessionIfEmpty:\n+    \"\"\"Wiring tests for HermesCLI._discard_session_if_empty.\"\"\"\n+\n+    def _make_cli(self, db):\n+        from cli import HermesCLI\n+\n+        cli = HermesCLI.__new__(HermesCLI)\n+        cli._session_db = db\n+        cli.conversation_history = []\n+        return cli\n+\n+    def test_discards_empty(self, db):\n+        db.create_session(session_id=\"empty\", source=\"cli\", model=\"test\")\n+        db.end_session(\"empty\", \"cli_close\")\n+\n+        cli = self._make_cli(db)\n+        assert cli._discard_session_if_empty(\"empty\") is True\n+        assert db.get_session(\"empty\") is None\n+\n+    def test_keeps_nonempty(self, db):\n+        db.create_session(session_id=\"busy\", source=\"cli\", model=\"test\")\n+        db.append_message(\"busy\", role=\"user\", content=\"hi\")\n+\n+        cli = self._make_cli(db)\n+        assert cli._discard_session_if_empty(\"busy\") is False\n+        assert db.get_session(\"busy\") is not None\n+\n+    def test_no_db_is_noop(self):\n+        cli = self._make_cli(None)\n+        assert cli._discard_session_if_empty(\"anything\") is False\n+\n+    def test_none_session_id_is_noop(self, db):\n+        cli = self._make_cli(db)\n+        assert cli._discard_session_if_empty(None) is False\n+\n+    def test_db_error_swallowed(self, db):\n+        class Boom:\n+            def delete_session_if_empty(self, *a, **k):\n+                raise RuntimeError(\"locked\")\n+\n+        cli = self._make_cli(Boom())\n+        assert cli._discard_session_if_empty(\"x\") is False\n+\n+    def test_in_memory_history_blocks_prune(self, db):\n+        \"\"\"The live transcript is authoritative: even if the DB row has no\n+        flushed messages yet, a CLI holding conversation history must not\n+        prune the session (covers flush-failed / not-yet-flushed turns).\"\"\"\n+        db.create_session(session_id=\"unflushed\", source=\"cli\", model=\"test\")\n+        db.end_session(\"unflushed\", \"new_session\")\n+\n+        cli = self._make_cli(db)\n+        cli.conversation_history = [{\"role\": \"user\", \"content\": \"hello\"}]\n+        assert cli._discard_session_if_empty(\"unflushed\") is False\n+        assert db.get_session(\"unflushed\") is not None\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}