From e96ca1a0d35a8661ab5315c25c369073ae64022e Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 10 Jun 2026 17:22:27 -0700 Subject: [PATCH 1/4] feat(sessions): drop empty sessions on CLI exit and session rotation Port from google-gemini/gemini-cli#27770: starting the CLI and immediately quitting (or rotating with /new, /clear) left an empty untitled session row behind. These ghost rows pile up in /resume, `hermes sessions list`, and the in-chat recent-sessions browser. - SessionDB.delete_session_if_empty(): transactional check-and-delete that only removes rows with no messages, no title, and no child sessions (delegate subagent parents are preserved). Also removes on-disk transcript files via the existing _remove_session_files. - HermesCLI._discard_session_if_empty(): thin wrapper, wired into the cli_close shutdown path and the new_session() rotation path. Skipped when /exit --delete already handles removal. Unlike the one-shot prune_empty_ghost_sessions migration (TUI-only, 24h-old rows), this prevents new ghost rows from accumulating at the moment they would be created. --- cli.py | 35 +++++++ hermes_state.py | 42 ++++++++ tests/test_empty_session_hygiene.py | 148 ++++++++++++++++++++++++++++ 3 files changed, 225 insertions(+) create mode 100644 tests/test_empty_session_hygiene.py diff --git a/cli.py b/cli.py index 641c200ad3d..2cc1a8f16db 100644 --- a/cli.py +++ b/cli.py @@ -5821,6 +5821,29 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin): except Exception: pass + def _discard_session_if_empty(self, session_id: Optional[str]) -> bool: + """Drop a just-ended session row when it never gained content. + + Starting the CLI and immediately quitting (or rotating with /new, + /clear) used to leave an empty untitled row behind that clutters + ``/resume`` and ``hermes sessions list``. Delegates the + check-and-delete to ``SessionDB.delete_session_if_empty``, which + only removes rows with no messages, no title, and no child + sessions. Ported from google-gemini/gemini-cli#27770. + """ + if not self._session_db or not session_id: + return False + try: + from hermes_constants import get_hermes_home as _ghh + return self._session_db.delete_session_if_empty( + session_id, sessions_dir=_ghh() / "sessions" + ) + except Exception: + logger.debug( + "Could not prune empty session %s", session_id, exc_info=True + ) + return False + def new_session(self, silent=False, title=None): """Start a fresh session with a new session ID and cleared agent state.""" if self.agent and self.conversation_history: @@ -5837,6 +5860,9 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin): self._session_db.end_session(old_session_id, "new_session") except Exception: pass + # Don't let immediately-rotated empty sessions pile up in + # /resume and `hermes sessions list` (gemini-cli#27770 port). + self._discard_session_if_empty(old_session_id) self.session_start = datetime.now() timestamp_str = self.session_start.strftime("%Y%m%d_%H%M%S") @@ -13074,6 +13100,15 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin): self._session_db.end_session(self.agent.session_id, "cli_close") except (Exception, KeyboardInterrupt) as e: logger.debug("Could not close session in DB: %s", e) + # Started-and-immediately-quit sessions never gained content; + # drop the empty row so /resume and `hermes sessions list` + # stay clean (gemini-cli#27770 port). No-op for resumed or + # titled sessions and anything with messages or children. + if not getattr(self, '_delete_session_on_exit', False): + try: + self._discard_session_if_empty(self.agent.session_id) + except (Exception, KeyboardInterrupt) as e: + logger.debug("Could not prune empty session: %s", e) # /exit --delete: also remove the current session's transcripts # and SQLite history. Ported from google-gemini/gemini-cli#19332. if getattr(self, '_delete_session_on_exit', False): diff --git a/hermes_state.py b/hermes_state.py index bda6eeacd62..40341a69733 100644 --- a/hermes_state.py +++ b/hermes_state.py @@ -3658,6 +3658,48 @@ class SessionDB: self._remove_session_files(sessions_dir, session_id) return deleted + def delete_session_if_empty( + self, + session_id: str, + sessions_dir: Optional[Path] = None, + ) -> bool: + """Delete *session_id* only when it never gained resumable content. + + A session is considered empty when it has no messages and no + user-assigned title. Used by CLI exit / session-rotation paths so + immediately-started-and-quit sessions don't pile up in ``/resume`` + and ``hermes sessions list`` output. (Pattern ported from + google-gemini/gemini-cli#27770.) + + The emptiness check and delete run in one transaction, so a message + flushed concurrently by another writer can't be lost. Sessions with + children (delegate subagent runs) are preserved — a parent that + spawned work is not "empty" even if its own transcript never + flushed. Returns True if the session was deleted. + """ + def _do(conn): + cursor = conn.execute( + """ + DELETE FROM sessions + WHERE id = ? + AND title IS NULL + AND NOT EXISTS ( + SELECT 1 FROM messages WHERE messages.session_id = sessions.id + ) + AND NOT EXISTS ( + SELECT 1 FROM sessions child + WHERE child.parent_session_id = sessions.id + ) + """, + (session_id,), + ) + return cursor.rowcount > 0 + + deleted = self._execute_write(_do) + if deleted: + self._remove_session_files(sessions_dir, session_id) + return bool(deleted) + def delete_sessions( self, session_ids: List[str], diff --git a/tests/test_empty_session_hygiene.py b/tests/test_empty_session_hygiene.py new file mode 100644 index 00000000000..7166ce936e1 --- /dev/null +++ b/tests/test_empty_session_hygiene.py @@ -0,0 +1,148 @@ +"""Tests for empty-session hygiene — gemini-cli#27770 port. + +Starting the CLI and immediately quitting (or rotating sessions with /new) +used to leave empty untitled rows in the session DB that clutter /resume +and `hermes sessions list`. ``SessionDB.delete_session_if_empty`` removes +a just-ended session row only when it never gained resumable content: +no messages, no title, and no child sessions. +""" + +import pytest + +from hermes_state import SessionDB + + +@pytest.fixture() +def db(tmp_path): + session_db = SessionDB(db_path=tmp_path / "state.db") + yield session_db + session_db.close() + + +class TestDeleteSessionIfEmpty: + def test_deletes_empty_untitled_session(self, db): + db.create_session(session_id="empty", source="cli", model="test") + db.end_session("empty", "cli_close") + + assert db.delete_session_if_empty("empty") is True + assert db.get_session("empty") is None + + def test_keeps_session_with_messages(self, db): + db.create_session(session_id="busy", source="cli", model="test") + db.append_message("busy", role="user", content="hello") + db.end_session("busy", "cli_close") + + assert db.delete_session_if_empty("busy") is False + assert db.get_session("busy") is not None + + def test_keeps_titled_session(self, db): + """A user-assigned title is resumable content even without messages.""" + db.create_session(session_id="titled", source="cli", model="test") + db.set_session_title("titled", "Important plans") + db.end_session("titled", "cli_close") + + assert db.delete_session_if_empty("titled") is False + assert db.get_session("titled") is not None + + def test_keeps_session_with_children(self, db): + """A parent that spawned delegate subagent runs is not empty.""" + db.create_session(session_id="parent", source="cli", model="test") + db.create_session( + session_id="child", + source="tool", + model="test", + parent_session_id="parent", + ) + db.end_session("parent", "cli_close") + + assert db.delete_session_if_empty("parent") is False + assert db.get_session("parent") is not None + assert db.get_session("child") is not None + + def test_unknown_session_returns_false(self, db): + assert db.delete_session_if_empty("nope") is False + + def test_removes_on_disk_transcripts(self, db, tmp_path): + sessions_dir = tmp_path / "sessions" + sessions_dir.mkdir() + (sessions_dir / "empty.json").write_text("{}", encoding="utf-8") + (sessions_dir / "empty.jsonl").write_text("", encoding="utf-8") + + db.create_session(session_id="empty", source="cli", model="test") + db.end_session("empty", "cli_close") + + assert db.delete_session_if_empty("empty", sessions_dir=sessions_dir) + assert not (sessions_dir / "empty.json").exists() + assert not (sessions_dir / "empty.jsonl").exists() + + def test_no_file_cleanup_when_kept(self, db, tmp_path): + sessions_dir = tmp_path / "sessions" + sessions_dir.mkdir() + (sessions_dir / "busy.json").write_text("{}", encoding="utf-8") + + db.create_session(session_id="busy", source="cli", model="test") + db.append_message("busy", role="user", content="hello") + + assert not db.delete_session_if_empty("busy", sessions_dir=sessions_dir) + assert (sessions_dir / "busy.json").exists() + + def test_empty_session_disappears_from_listing(self, db): + """The user-facing symptom: empty rows polluting session lists.""" + db.create_session(session_id="real", source="cli", model="test") + db.append_message("real", role="user", content="do the thing") + db.end_session("real", "cli_close") + + db.create_session(session_id="ghost", source="cli", model="test") + db.end_session("ghost", "cli_close") + + ids_before = {s["id"] for s in db.list_sessions_rich(source="cli")} + assert {"real", "ghost"} <= ids_before + + db.delete_session_if_empty("ghost") + + ids_after = {s["id"] for s in db.list_sessions_rich(source="cli")} + assert "real" in ids_after + assert "ghost" not in ids_after + + +class TestCLIDiscardSessionIfEmpty: + """Wiring tests for HermesCLI._discard_session_if_empty.""" + + def _make_cli(self, db): + from cli import HermesCLI + + cli = HermesCLI.__new__(HermesCLI) + cli._session_db = db + return cli + + def test_discards_empty(self, db): + db.create_session(session_id="empty", source="cli", model="test") + db.end_session("empty", "cli_close") + + cli = self._make_cli(db) + assert cli._discard_session_if_empty("empty") is True + assert db.get_session("empty") is None + + def test_keeps_nonempty(self, db): + db.create_session(session_id="busy", source="cli", model="test") + db.append_message("busy", role="user", content="hi") + + cli = self._make_cli(db) + assert cli._discard_session_if_empty("busy") is False + assert db.get_session("busy") is not None + + def test_no_db_is_noop(self): + cli = self._make_cli(None) + assert cli._discard_session_if_empty("anything") is False + + def test_none_session_id_is_noop(self, db): + cli = self._make_cli(db) + assert cli._discard_session_if_empty(None) is False + + def test_db_error_swallowed(self, db): + class Boom: + def delete_session_if_empty(self, *a, **k): + raise RuntimeError("locked") + + cli = self._make_cli(Boom()) + assert cli._discard_session_if_empty("x") is False From 4490c7cf8de4aef8abb61e5e4cb748bbd960e5c2 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 10 Jun 2026 17:37:34 -0700 Subject: [PATCH 2/4] fix: in-memory transcript blocks empty-session prune CI caught tests/cli/test_cli_new_session.py asserting that /new keeps the old session row when conversation history exists in memory. The live transcript is authoritative: a session whose messages haven't flushed to the DB yet (or whose flush failed) must not be pruned. Guard _discard_session_if_empty on self.conversation_history and pin the behavior with a test. --- cli.py | 6 ++++++ tests/test_empty_session_hygiene.py | 13 +++++++++++++ 2 files changed, 19 insertions(+) diff --git a/cli.py b/cli.py index 2cc1a8f16db..126d0a1038a 100644 --- a/cli.py +++ b/cli.py @@ -5833,6 +5833,12 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin): """ if not self._session_db or not session_id: return False + # In-memory transcript is authoritative: if this CLI object holds + # conversation messages (flushed to the DB or not), the session is + # not empty. Protects against pruning a real conversation whose DB + # flush failed or hasn't happened yet. + if getattr(self, "conversation_history", None): + return False try: from hermes_constants import get_hermes_home as _ghh return self._session_db.delete_session_if_empty( diff --git a/tests/test_empty_session_hygiene.py b/tests/test_empty_session_hygiene.py index 7166ce936e1..3576e7dce72 100644 --- a/tests/test_empty_session_hygiene.py +++ b/tests/test_empty_session_hygiene.py @@ -113,6 +113,7 @@ class TestCLIDiscardSessionIfEmpty: cli = HermesCLI.__new__(HermesCLI) cli._session_db = db + cli.conversation_history = [] return cli def test_discards_empty(self, db): @@ -146,3 +147,15 @@ class TestCLIDiscardSessionIfEmpty: cli = self._make_cli(Boom()) assert cli._discard_session_if_empty("x") is False + + def test_in_memory_history_blocks_prune(self, db): + """The live transcript is authoritative: even if the DB row has no + flushed messages yet, a CLI holding conversation history must not + prune the session (covers flush-failed / not-yet-flushed turns).""" + db.create_session(session_id="unflushed", source="cli", model="test") + db.end_session("unflushed", "new_session") + + cli = self._make_cli(db) + cli.conversation_history = [{"role": "user", "content": "hello"}] + assert cli._discard_session_if_empty("unflushed") is False + assert db.get_session("unflushed") is not None From 0b5b7ddfd27e65d0fc5ff4fd4429f49be19c77b9 Mon Sep 17 00:00:00 2001 From: Matt Van Horn Date: Mon, 30 Mar 2026 23:46:32 -0700 Subject: [PATCH 3/4] fix(cli): show quick commands in /help output User-defined quick_commands from config.yaml now appear in the /help output under a "Quick Commands" section, between skill commands and tips. Fixes https://github.com/NousResearch/hermes-agent/issues/4090 Co-Authored-By: Claude Opus 4.6 (1M context) --- cli.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/cli.py b/cli.py index 126d0a1038a..b2d0316c1f7 100644 --- a/cli.py +++ b/cli.py @@ -5552,6 +5552,15 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin): f"{_escape(desc)} [dim]({skill_count} skills)[/]" ) + quick_commands = self.config.get("quick_commands", {}) + if quick_commands: + _cprint(f"\n ⚡ {_BOLD}Quick Commands{_RST} ({len(quick_commands)} configured):") + for name, qcmd in sorted(quick_commands.items()): + desc = qcmd.get("description", qcmd.get("type", "")) + ChatConsole().print( + f" [bold {_accent_hex()}]{('/' + name):<22}[/] [dim]-[/] {_escape(desc)}" + ) + _cprint(f"\n {_DIM}Tip: Just type your message to chat with Hermes!{_RST}") _cprint(f" {_DIM}Multi-line: Alt+Enter for a new line{_RST}") _cprint(f" {_DIM}Draft editor: Ctrl+G (Alt+G in VSCode/Cursor){_RST}") From 2450fd7066dd90302e6c19ed53db6b60723bf72c Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 10 Jun 2026 22:56:17 -0700 Subject: [PATCH 4/4] chore: add mvanhorn to AUTHOR_MAP --- scripts/release.py | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/release.py b/scripts/release.py index cd0fe475d5d..dc3521d41a2 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -75,6 +75,7 @@ AUTHOR_MAP = { "129007007+HeLLGURD@users.noreply.github.com": "HeLLGURD", "290859878+synapsesx@users.noreply.github.com": "synapsesx", "dirtyren@users.noreply.github.com": "dirtyren", + "mvanhorn@MacBook-Pro.local": "mvanhorn", "ted.malone@outlook.com": "temalo", "adityamalik2833@gmail.com": "alarcritty", "islam666@users.noreply.github.com": "islam666",