{"repo":"NousResearch/hermes-agent","public_id":"18269","display_label":"Prevent empty ghost sessions cluttering resume list","summary":"These issues and PRs address TUI and CLI behavior that eagerly writes empty sessions to the database, causing ghost rows to appear in /resume. Fixes involve lazy session creation, dropping empty sessions on exit, and ensuring sessions are properly ended on finalize.","subsystem":null,"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":18370,"merged_at":"2026-05-01T13:09:13+00:00"},"resolution":{"state":"resolved","by":"pr-18370"},"freshness":{"state":"verified","last_verified_at":"2026-05-01T13:09:13+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.5},"fix_edges":[{"issue_number":18269,"pr_number":18370,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true,"pr_merged_at":"2026-05-01T13:09:13+00:00"}],"members":[{"kind":"issue","number":18269,"title":"[Bug]: TUI eagerly persists empty sessions to DB — ghost sessions clutter /resume","url":null,"state":"closed"},{"kind":"pr","number":18283,"title":"fix: end TUI sessions in state.db to prevent ghost rows in /resume","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#18370","number":18370}],"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"kind":"pr","number":18299,"title":"fix(tui): end_session on finalize to prevent ghost sessions in /resume (#18269)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#18370","number":18370}],"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"kind":"pr","number":18370,"title":"fix: lazy session creation — defer DB row until first message","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["cli.py","hermes_cli/main.py","hermes_state.py","run_agent.py","tests/run_agent/test_860_dedup.py","tui_gateway/server.py"],"fix_hunks":[{"file":"cli.py","hunks":["@@ -934,6 +934,20 @@ def _run_state_db_auto_maintenance(session_db) -> None:\n     try:\n         from hermes_cli.config import load_config as _load_full_config\n         from hermes_constants import get_hermes_home as _get_hermes_home\n+        _hermes_home_maint = _get_hermes_home()\n+\n+        # One-time prune of empty TUI ghost sessions.\n+        try:\n+            if not session_db.get_meta(\"ghost_session_prune_v1\"):\n+                pruned = session_db.prune_empty_ghost_sessions(\n+                    sessions_dir=_hermes_home_maint / \"sessions\"\n+                )\n+                session_db.set_meta(\"ghost_session_prune_v1\", \"1\")\n+                if pruned:\n+                    logger.info(\"Pruned %d empty TUI ghost sessions\", pruned)\n+        except Exception as _prune_exc:\n+            logger.debug(\"Ghost session prune skipped: %s\", _prune_exc)\n+\n         cfg = (_load_full_config().get(\"sessions\") or {})\n         if not cfg.get(\"auto_prune\", False):\n             return","@@ -941,7 +955,7 @@ def _run_state_db_auto_maintenance(session_db) -> None:\n             retention_days=int(cfg.get(\"retention_days\", 90)),\n             min_interval_hours=int(cfg.get(\"min_interval_hours\", 24)),\n             vacuum=bool(cfg.get(\"vacuum_after_prune\", True)),\n-            sessions_dir=_get_hermes_home() / \"sessions\",\n+            sessions_dir=_hermes_home_maint / \"sessions\",\n         )\n     except Exception as exc:\n         logger.debug(\"state.db auto-maintenance skipped: %s\", exc)","@@ -3618,14 +3632,18 @@ def _init_agent(self, *, model_override: str = None, runtime_override: dict = No\n                 tuple(runtime.get(\"args\") or ()),\n             )\n \n-            if self._pending_title and self._session_db:\n+            # Force-create DB row on /title intent, then apply title.\n+            if self._pending_title and self._session_db and self.agent:\n                 try:\n-                    self._session_db.set_session_title(self.session_id, self._pending_title)\n-                    _cprint(f\"  Session title applied: {self._pending_title}\")\n-                    self._pending_title = None\n+                    self.agent._ensure_db_session()\n+                    if self.agent._session_db_created:\n+                        self._session_db.set_session_title(self.session_id, self._pending_title)\n+                        _cprint(f\"  Session title applied: {self._pending_title}\")\n+                        self._pending_title = None\n+                    # else: row creation failed transiently — keep _pending_title for retry\n                 except (ValueError, Exception) as e:\n                     _cprint(f\"  Could not apply pending title: {e}\")\n-                    self._pending_title = None\n+                    # Keep _pending_title so it can be retried after row creation succeeds\n             return True\n         except Exception as e:\n             ChatConsole().print(f\"[bold red]Failed to initialize agent: {e}[/]\")","@@ -4953,6 +4971,7 @@ def new_session(self, silent=False):\n \n             if self._session_db:\n                 try:\n+                    self.agent._session_db_created = False\n                     self._session_db.create_session(\n                         session_id=self.session_id,\n                         source=os.environ.get(\"HERMES_SESSION_SOURCE\", \"cli\"),","@@ -4962,6 +4981,7 @@ def new_session(self, silent=False):\n                             \"reasoning_config\": self.reasoning_config,\n                         },\n                     )\n+                    self.agent._session_db_created = True\n                 except Exception:\n                     pass\n             # Notify memory providers that session_id rotated to a fresh"]},{"file":"hermes_cli/main.py","hunks":["@@ -800,6 +800,8 @@ def _print_tui_exit_summary(session_id: Optional[str], active_session_file: Opti\n \n         title = db.get_session_title(target)\n         message_count = int(session.get(\"message_count\") or 0)\n+        if message_count == 0:\n+            return  # No real conversation — don't show resume info\n         input_tokens = [redacted:secret-assignment]\"input_tokens\") or 0)\n         output_tokens = [redacted:secret-assignment]\"output_tokens\") or 0)\n         cache_read_tokens = [redacted:secret-assignment]\"cache_read_tokens\") or 0)"]},{"file":"hermes_state.py","hunks":["@@ -514,7 +514,7 @@ def _init_schema(self):\n     # Session lifecycle\n     # =========================================================================\n \n-    def create_session(\n+    def _insert_session_row(\n         self,\n         session_id: str,\n         source: str,","@@ -523,8 +523,8 @@ def create_session(\n         system_prompt: str = None,\n         user_id: str = None,\n         parent_session_id: str = None,\n-    ) -> str:\n-        \"\"\"Create a new session record. Returns the session_id.\"\"\"\n+    ) -> None:\n+        \"\"\"Shared INSERT OR IGNORE for session rows.\"\"\"\n         def _do(conn):\n             conn.execute(\n                 \"\"\"INSERT OR IGNORE INTO sessions (id, source, user_id, model, model_config,","@@ -542,8 +542,11 @@ def _do(conn):\n                 ),\n             )\n         self._execute_write(_do)\n-        return session_id\n \n+    def create_session(self, session_id: str, source: str, **kwargs) -> str:\n+        \"\"\"Create a new session record. Returns the session_id.\"\"\"\n+        self._insert_session_row(session_id, source, **kwargs)\n+        return session_id\n     def end_session(self, session_id: str, end_reason: str) -> None:\n         \"\"\"Mark a session as ended.\n ","@@ -679,21 +682,41 @@ def ensure_session(\n         session_id: str,\n         source: str = \"unknown\",\n         model: str = None,\n-    ) -> None:\n-        \"\"\"Ensure a session row exists, creating it with minimal metadata if absent.\n+        **kwargs,\n+    ) -> str:\n+        \"\"\"Ensure a session row exists (INSERT OR IGNORE). Accepts optional kwargs.\"\"\"\n+        self._insert_session_row(session_id, source, model=model, **kwargs)\n+        return session_id\n+\n+    def prune_empty_ghost_sessions(self, sessions_dir: \"Optional[Path]\" = None) -> int:\n+        \"\"\"Remove empty TUI ghost sessions (no messages, no title, >24hr old).\"\"\"\n+        cutoff = time.time() - 86400  # Only sessions older than 24 hours\n \n-        Used by _flush_messages_to_session_db to recover from a failed\n-        create_session() call (e.g. transient SQLite lock at agent startup).\n-        INSERT OR IGNORE is safe to call even when the row already exists.\n-        \"\"\"\n         def _do(conn):\n-            conn.execute(\n-                \"\"\"INSERT OR IGNORE INTO sessions\n-                   (id, source, model, started_at)\n-                   VALUES (?, ?, ?, ?)\"\"\",\n-                (session_id, source, model, time.time()),\n-            )\n-        self._execute_write(_do)\n+            rows = conn.execute(\"\"\"\n+                SELECT id FROM sessions\n+                WHERE source = 'tui'\n+                  AND title IS NULL\n+                  AND ended_at IS NOT NULL\n+                  AND started_at < ?\n+                  AND NOT EXISTS (\n+                      SELECT 1 FROM messages WHERE messages.session_id = sessions.id\n+                  )\n+            \"\"\", (cutoff,)).fetchall()\n+            ids = [r[0] if isinstance(r, (tuple, list)) else r[\"id\"] for r in rows]\n+            if ids:\n+                placeholders = \",\".join(\"?\" * len(ids))\n+                conn.execute(\n+                    f\"DELETE FROM sessions WHERE id IN ({placeholders})\", ids\n+                )\n+            return ids\n+\n+        removed_ids = self._execute_write(_do) or []\n+        # Clean up any on-disk session files (belt-and-suspenders)\n+        if sessions_dir and removed_ids:\n+            for sid in removed_ids:\n+                self._remove_session_files(sessions_dir, sid)\n+        return len(removed_ids)\n \n     def get_session(self, session_id: str) -> Optional[Dict[str, Any]]:\n         \"\"\"Get a session by ID.\"\"\""]},{"file":"run_agent.py","hunks":["@@ -1632,30 +1632,12 @@ def __init__(\n         self._session_db = session_db\n         self._parent_session_id = parent_session_id\n         self._last_flushed_db_idx = 0  # tracks DB-write cursor to prevent duplicate writes\n-        if self._session_db:\n-            try:\n-                self._session_db.create_session(\n-                    session_id=self.session_id,\n-                    source=self.platform or os.environ.get(\"HERMES_SESSION_SOURCE\", \"cli\"),\n-                    model=self.model,\n-                    model_config={\n-                        \"max_iterations\": self.max_iterations,\n-                        \"reasoning_config\": reasoning_config,\n-                        \"max_tokens\": [redacted:secret-assignment]\n-                    },\n-                    user_id=None,\n-                    parent_session_id=self._parent_session_id,\n-                )\n-            except Exception as e:\n-                # Transient SQLite lock contention (e.g. CLI and gateway writing\n-                # concurrently) must NOT permanently disable session_search for\n-                # this agent.  Keep _session_db alive — subsequent message\n-                # flushes and session_search calls will still work once the\n-                # lock clears.  The session row may be missing from the index\n-                # for this run, but that is recoverable (flushes upsert rows).\n-                logger.warning(\n-                    \"Session DB create_session failed (session_search still available): %s\", e\n-                )\n+        self._session_db_created = False  # DB row deferred to run_conversation()\n+        self._session_init_model_config = {\n+            \"max_iterations\": self.max_iterations,\n+            \"reasoning_config\": reasoning_config,\n+            \"max_tokens\": [redacted:secret-assignment]\n+        }\n         \n         # In-memory todo list for task planning (one per agent/session)\n         from tools.todo_tool import TodoStore","@@ -2170,6 +2152,28 @@ def __init__(\n                 \"is_anthropic_oauth\": self._is_anthropic_oauth,\n             })\n \n+    def _ensure_db_session(self) -> None:\n+        \"\"\"Create session DB row on first use. Disables _session_db on failure.\"\"\"\n+        if self._session_db_created or not self._session_db:\n+            return\n+        try:\n+            self._session_db.create_session(\n+                session_id=self.session_id,\n+                source=self.platform or os.environ.get(\"HERMES_SESSION_SOURCE\", \"cli\"),\n+                model=self.model,\n+                model_config=self._session_init_model_config,\n+                system_prompt=self._cached_system_prompt,\n+                user_id=None,\n+                parent_session_id=self._parent_session_id,\n+            )\n+            self._session_db_created = True\n+        except Exception as e:\n+            # Transient failure (e.g. SQLite lock). Keep _session_db alive —\n+            # _session_db_created stays False so next run_conversation() retries.\n+            logger.warning(\n+                \"Session DB creation failed (will retry next turn): %s\", e\n+            )\n+\n     def reset_session_state(self):\n         \"\"\"Reset all session-scoped token counters to 0 for a fresh session.\n         ","@@ -3719,14 +3723,9 @@ def _flush_messages_to_session_db(self, messages: List[Dict], conversation_histo\n             return\n         self._apply_persist_user_message_override(messages)\n         try:\n-            # If create_session() failed at startup (e.g. transient lock), the\n-            # session row may not exist yet.  ensure_session() uses INSERT OR\n-            # IGNORE so it is a no-op when the row is already there.\n-            self._session_db.ensure_session(\n-                self.session_id,\n-                source=self.platform or \"cli\",\n-                model=self.model,\n-            )\n+            # Retry row creation if the earlier attempt failed transiently.\n+            if not self._session_db_created:\n+                self._ensure_db_session()\n             start_idx = len(conversation_history) if conversation_history else 0\n             flush_from = max(start_idx, self._last_flushed_db_idx)\n             for msg in messages[flush_from:]:","@@ -9056,12 +9055,15 @@ def _compress_context(self, messages: list, system_message: str, *, approx_token\n                 self.session_id = f\"{datetime.now().strftime('%Y%m%d_%H%M%S')}_{uuid.uuid4().hex[:6]}\"\n                 # Update session_log_file to point to the new session's JSON file\n                 self.session_log_file = self.logs_dir / f\"session_{self.session_id}.json\"\n+                self._session_db_created = False\n                 self._session_db.create_session(\n                     session_id=self.session_id,\n                     source=self.platform or os.environ.get(\"HERMES_SESSION_SOURCE\", \"cli\"),\n                     model=self.model,\n+                    model_config=self._session_init_model_config,\n                     parent_session_id=old_session_id,\n                 )\n+                self._session_db_created = True\n                 # Auto-number the title for the continuation session\n                 if old_title:\n                     try:","@@ -10351,6 +10353,8 @@ def run_conversation(\n         # Installed once, transparent when streams are healthy, prevents crash on write.\n         _install_safe_stdio()\n \n+        self._ensure_db_session()\n+\n         # Tag all log records on this thread with the session ID so\n         # ``hermes logs --session <id>`` can filter a single conversation.\n         from hermes_logging import set_session_context"]},{"file":"tests/run_agent/test_860_dedup.py","hunks":["@@ -38,6 +38,8 @@ def _make_agent(self, session_db):\n                 skip_context_files=True,\n                 skip_memory=True,\n             )\n+        # Simulate lazy session creation (normally done by run_conversation)\n+        agent._ensure_db_session()\n         return agent\n \n     def test_flush_writes_only_new_messages(self):"]},{"file":"tui_gateway/server.py","hunks":["@@ -280,7 +280,7 @@ def _notify_session_boundary(event_type: str, session_id: str | None) -> None:\n         pass\n \n \n-def _finalize_session(session: dict | None) -> None:\n+def _finalize_session(session: dict | None, end_reason: str = \"tui_close\") -> None:\n     \"\"\"Best-effort finalize hook + memory commit for a session.\"\"\"\n     if not session or session.get(\"_finalized\"):\n         return","@@ -299,13 +299,24 @@ def _finalize_session(session: dict | None) -> None:\n         except Exception:\n             pass\n \n-    session_id = getattr(agent, \"session_id\", None) or session.get(\"session_key\")\n+    session_key = session.get(\"session_key\")\n+    session_id = getattr(agent, \"session_id\", None) or session_key\n     _notify_session_boundary(\"on_session_finalize\", session_id)\n \n+    # Mark session ended in DB so it doesn't linger as a ghost row in /resume.\n+    # Adapted from #18283 (luyao618) and #18299 (Bartok9).\n+    if session_key:\n+        try:\n+            db = _get_db()\n+            if db is not None:\n+                db.end_session(session_key, end_reason)\n+        except Exception:\n+            pass\n+\n \n def _shutdown_sessions() -> None:\n     for session in list(_sessions.values()):\n-        _finalize_session(session)\n+        _finalize_session(session, end_reason=\"tui_shutdown\")\n         try:\n             worker = session.get(\"slash_worker\")\n             if worker:","@@ -539,32 +550,8 @@ def _build() -> None:\n             finally:\n                 _clear_session_context(tokens)\n \n-            db = _get_db()\n-            if db is not None:\n-                db.create_session(key, source=\"tui\", model=_resolve_model())\n-                pending_title = (current.get(\"pending_title\") or \"\").strip()\n-                if pending_title:\n-                    try:\n-                        title_applied = db.set_session_title(key, pending_title)\n-                        if title_applied:\n-                            current[\"pending_title\"] = None\n-                        else:\n-                            existing_row = db.get_session(key)\n-                            existing_title = ((existing_row or {}).get(\"title\") or \"\").strip()\n-                            if existing_title == pending_title:\n-                                current[\"pending_title\"] = None\n-                            else:\n-                                logger.info(\n-                                    \"Pending title still queued for session %s (wanted=%r, current=%r)\",\n-                                    sid,\n-                                    pending_title,\n-                                    existing_title,\n-                                )\n-                    except ValueError as e:\n-                        current[\"pending_title\"] = None\n-                        logger.info(\"Dropping pending title for session %s: %s\", sid, e)\n-                    except Exception:\n-                        logger.warning(\"Failed to apply pending title for session %s\", sid, exc_info=True)\n+            # Session DB row deferred to first run_conversation() call.\n+            # pending_title applied post-first-message (see cli.exec handler).\n             current[\"agent\"] = agent\n \n             try:","@@ -2996,6 +2983,17 @@ def _stream(delta):\n                 payload[\"rendered\"] = rendered\n             _emit(\"message.complete\", sid, payload)\n \n+            # Apply pending_title now that the DB row exists.\n+            _pending = session.get(\"pending_title\")\n+            if _pending and status == \"complete\":\n+                _pdb = _get_db()\n+                if _pdb:\n+                    try:\n+                        if _pdb.set_session_title(session.get(\"session_key\") or sid, _pending):\n+                            session[\"pending_title\"] = None\n+                    except Exception:\n+                        pass  # Best effort — auto-title will handle it below\n+\n             if (\n                 status == \"complete\"\n                 and isinstance(raw, str)"]},{"file":"uv.lock","hunks":["@@ -9,7 +9,7 @@ resolution-markers = [\n ]\n \n [options]\n-exclude-newer = \"2026-04-17T16:49:45.944715922Z\"\n+exclude-newer = \"0001-01-01T00:00:00Z\" # This has no effect and is included for backwards compatibility when using relative exclude-newer values.\n exclude-newer-span = \"P7D\"\n \n [[package]]","@@ -156,6 +156,19 @@ wheels = [\n     { url = \"https://files.pythonhosted.org/packages/1a/99/84ba7273339d0f3dfa57901b846489d2e5c2cd731470167757f1935fffbd/aiohttp_retry-2.9.1-py3-none-any.whl\", hash = \"sha256:66d2759d1921838256a05a3f80ad7e724936f083e35be5abb5e16eed6be6dc54\", size = 9981, upload-time = \"2024-11-06T10:44:52.917Z\" },\n ]\n \n+[[package]]\n+name = \"aiohttp-socks\"\n+version = \"0.11.0\"\n+source = { registry = \"https://pypi.org/simple\" }\n+dependencies = [\n+    { name = \"aiohttp\" },\n+    { name = \"python-socks\" },\n+]\n+sdist = { url = \"https://files.pythonhosted.org/packages/1f/cc/e5bbd54f76bd56291522251e47267b645dac76327b2657ade9545e30522c/aiohttp_socks-0.11.0.tar.gz\", hash = \"sha256:0afe51638527c79077e4bd6e57052c87c4824233d6e20bb061c53766421b10f0\", size = 11196, upload-time = \"2025-12-09T13:35:52.564Z\" }\n+wheels = [\n+    { url = \"https://files.pythonhosted.org/packages/bf/7d/4b633d709b8901d59444d2e512b93e72fe62d2b492a040097c3f7ba017bb/aiohttp_socks-0.11.0-py3-none-any.whl\", hash = \"sha256:9aacce57c931b8fbf8f6d333cf3cafe4c35b971b35430309e167a35a8aab9ec1\", size = 10556, upload-time = \"2025-12-09T13:35:50.18Z\" },\n+]\n+\n [[package]]\n name = \"aiosignal\"\n version = \"1.4.0\"","@@ -1759,6 +1772,77 @@ wheels = [\n     { url = \"https://files.pythonhosted.org/packages/6a/09/e21df6aef1e1ffc0c816f0522ddc3f6dcded766c3261813131c78a704470/gitpython-3.1.46-py3-none-any.whl\", hash = \"sha256:79812ed143d9d25b6d176a10bb511de0f9c67b1fa641d82097b0ab90398a2058\", size = 208620, upload-time = \"2026-01-01T15:37:30.574Z\" },\n ]\n \n+[[package]]\n+name = \"google-api-core\"\n+version = \"2.30.3\"\n+source = { registry = \"https://pypi.org/simple\" }\n+dependencies = [\n+    { name = \"google-auth\" },\n+    { name = \"googleapis-common-protos\" },\n+    { name = \"proto-plus\" },\n+    { name = \"protobuf\" },\n+    { name = \"requests\" },\n+]\n+sdist = { url = \"https://files.pythonhosted.org/packages/16/ce/502a57fb0ec752026d24df1280b162294b22a0afb98a326084f9a979138b/google_api_core-2.30.3.tar.gz\", hash = \"sha256:e601a37f148585319b26db36e219df68c5d07b6382cff2d580e83404e44d641b\", size = 177001, upload-time = \"2026-04-10T00:41:28.035Z\" }\n+wheels = [\n+    { url = \"https://files.pythonhosted.org/packages/03/15/e56f351cf6ef1cfea58e6ac226a7318ed1deb2218c4b3cc9bd9e4b786c5a/google_api_core-2.30.3-py3-none-any.whl\", hash = \"sha256:a85761ba72c444dad5d611c2220633480b2b6be2521eca69cca2dbb3ffd6bfe8\", size = 173274, upload-time = \"2026-04-09T22:57:16.198Z\" },\n+]\n+\n+[[package]]\n+name = \"google-api-python-client\"\n+version = \"2.194.0\"\n+source = { registry = \"https://pypi.org/simple\" }\n+dependencies = [\n+    { name = \"google-api-core\" },\n+    { name = \"google-auth\" },\n+    { name = \"google-auth-httplib2\" },\n+    { name = \"httplib2\" },\n+    { name = \"uritemplate\" },\n+]\n+sdist = { url = \"https://files.pythonhosted.org/packages/60/ab/e83af0eb043e4ccc49571ca7a6a49984e9d00f4e9e6e6f1238d60bc84dce/google_api_python_client-2.194.0.tar.gz\", hash = \"sha256:db92647bd1a90f40b79c9618461553c2b20b6a43ce7395fa6de07132dc14f023\", size = 14443469, upload-time = \"2026-04-08T23:07:35.757Z\" }\n+wheels = [\n+    { url = \"https://files.pythonhosted.org/packages/b0/34/5a624e49f179aa5b0cb87b2ce8093960299030ff40423bfbde09360eb908/google_api_python_client-2.194.0-py3-none-any.whl\", hash = \"sha256:61eaaac3b8fc8fdf11c08af87abc3d1342d1b37319cc1b57405f86ef7697e717\", size = 15016514, upload-time = \"2026-04-08T23:07:33.093Z\" },\n+]\n+\n+[[package]]\n+name = \"google-auth\"\n+version = \"2.49.2\"\n+source = { registry = \"https://pypi.org/simple\" }\n+dependencies = [\n+    { name = \"cryptography\" },\n+    { name = \"pyasn1-modules\" },\n+]\n+sdist = { url = \"https://files.pythonhosted.org/packages/c6/fc/e925290a1ad95c975c459e2df070fac2b90954e13a0370ac505dff78cb99/google_auth-2.49.2.tar.gz\", hash = \"sha256:c1ae38500e73065dcae57355adb6278cf8b5c8e391994ae9cbadbcb9631ab409\", size = 333958, upload-time = \"2026-04-10T00:41:21.888Z\" }\n+wheels = [\n+    { url = \"https://files.pythonhosted.org/packages/73/76/d241a5c927433420507215df6cac1b1fa4ac0ba7a794df42a84326c68da8/google_auth-2.49.2-py3-none-any.whl\", hash = \"sha256:c2720924dfc82dedb962c9f52cabb2ab16714fd0a6a707e40561d217574ed6d5\", size = 240638, upload-time = \"2026-04-10T00:41:14.501Z\" },\n+]\n+\n+[[package]]\n+name = \"google-auth-httplib2\"\n+version = \"0.3.1\"\n+source = { registry = \"https://pypi.org/simple\" }\n+dependencies = [\n+    { name = \"google-auth\" },\n+    { name = \"httplib2\" },\n+]\n+sdist = { url = \"https://files.pythonhosted.org/packages/ed/99/107612bef8d24b298bb5a7c8466f908ecda791d43f9466f5c3978f5b24c1/google_auth_httplib2-0.3.1.tar.gz\", hash = \"sha256:0af542e815784cb64159b4469aa5d71dd41069ba93effa006e1916b1dcd88e55\", size = 11152, upload-time = \"2026-03-30T22:50:26.766Z\" }\n+wheels = [\n+    { url = \"https://files.pythonhosted.org/packages/97/e9/93afb14d23a949acaa3f4e7cc51a0024671174e116e35f42850764b99634/google_auth_httplib2-0.3.1-py3-none-any.whl\", hash = \"sha256:682356a90ef4ba3d06548c37e9112eea6fc00395a11b0303a644c1a86abc275c\", size = 9534, upload-time = \"2026-03-30T22:49:03.384Z\" },\n+]\n+\n+[[package]]\n+name = \"google-auth-oauthlib\"\n+version = \"1.3.1\"\n+source = { registry = \"https://pypi.org/simple\" }\n+dependencies = [\n+    { name = \"google-auth\" },\n+    { name = \"requests-oauthlib\" },\n+]\n+sdist = { url = \"https://files.pythonhosted.org/packages/a6/82/62482931dcbe5266a2680d0da17096f2aab983ecb320277d9556700ce00e/google_auth_oauthlib-1.3.1.tar.gz\", hash = \"sha256:14c22c7b3dd3d06dbe44264144409039465effdd1eef94f7ce3710e486cc4bfa\", size = 21663, upload-time = \"2026-03-30T22:49:56.408Z\" }\n+wheels = [\n+    { url = \"https://files.pythonhosted.org/packages/2a/e0/cb454a95f460903e39f101e950038ec24a072ca69d0a294a6df625cc1627/google_auth_oauthlib-1.3.1-py3-none-any.whl\", hash = \"sha256:1a139ef23f1318756805b0e95f655c238bffd29655329a2978218248da4ee7f8\", size = 19247, upload-time = \"2026-03-30T20:02:23.894Z\" },\n+]\n+\n [[package]]\n name = \"googleapis-common-protos\"\n version = \"1.73.0\"","@@ -1870,10 +1954,11 @@ wheels = [\n \n [[package]]\n name = \"hermes-agent\"\n-version = \"0.11.0\"\n+version = \"0.12.0\"\n source = { editable = \".\" }\n dependencies = [\n     { name = \"anthropic\" },\n+    { name = \"croniter\" },\n     { name = \"edge-tts\" },\n     { name = \"exa-py\" },\n     { name = \"fal-client\" },","@@ -1900,11 +1985,11 @@ acp = [\n all = [\n     { name = \"agent-client-protocol\" },\n     { name = \"aiohttp\" },\n+    { name = \"aiohttp-socks\", marker = \"sys_platform == 'linux'\" },\n     { name = \"aiosqlite\", marker = \"sys_platform == 'linux'\" },\n     { name = \"alibabacloud-dingtalk\" },\n     { name = \"asyncpg\", marker = \"sys_platform == 'linux'\" },\n     { name = \"boto3\" },\n-    { name = \"croniter\" },\n     { name = \"daytona\" },\n     { name = \"debugpy\" },\n     { name = \"dingtalk-stream\" },","@@ -1912,6 +1997,9 @@ all = [\n     { name = \"elevenlabs\" },\n     { name = \"fastapi\" },\n     { name = \"faster-whisper\" },\n+    { name = \"google-api-python-client\" },\n+    { name = \"google-auth-httplib2\" },\n+    { name = \"google-auth-oauthlib\" },\n     { name = \"honcho-ai\" },\n     { name = \"lark-oapi\" },\n     { name = \"markdown\", marker = \"sys_platform == 'linux'\" },","@@ -1942,9 +2030,6 @@ bedrock = [\n cli = [\n     { name = \"simple-term-menu\" },\n ]\n-cron = [\n-    { name = \"croniter\" },\n-]\n daytona = [\n     { name = \"daytona\" },\n ]","@@ -1966,6 +2051,11 @@ feishu = [\n     { name = \"lark-oapi\" },\n     { name = \"qrcode\" },\n ]\n+google = [\n+    { name = \"google-api-python-client\" },\n+    { name = \"google-auth-httplib2\" },\n+    { name = \"google-auth-oauthlib\" },\n+]\n homeassistant = [\n     { name = \"aiohttp\" },\n ]","@@ -1973,6 +2063,7 @@ honcho = [\n     { name = \"honcho-ai\" },\n ]\n matrix = [\n+    { name = \"aiohttp-socks\" },\n     { name = \"aiosqlite\" },\n     { name = \"asyncpg\" },\n     { name = \"markdown\" },","@@ -2015,7 +2106,6 @@ sms = [\n ]\n termux = [\n     { name = \"agent-client-protocol\" },\n-    { name = \"croniter\" },\n     { name = \"honcho-ai\" },\n     { name = \"mcp\" },\n     { name = \"ptyprocess\", marker = \"sys_platform != 'win32'\" },","@@ -2048,13 +2138,14 @@ requires-dist = [\n     { name = \"aiohttp\", marker = \"extra == 'homeassistant'\", specifier = \">=3.9.0,<4\" },\n     { name = \"aiohttp\", marker = \"extra == 'messaging'\", specifier = \">=3.13.3,<4\" },\n     { name = \"aiohttp\", marker = \"extra == 'sms'\", specifier = \">=3.9.0,<4\" },\n+    { name = \"aiohttp-socks\", marker = \"extra == 'matrix'\", specifier = \">=0.10,<1\" },\n     { name = \"aiosqlite\", marker = \"extra == 'matrix'\", specifier = \">=0.20\" },\n     { name = \"alibabacloud-dingtalk\", marker = \"extra == 'dingtalk'\", specifier = \">=2.0.0\" },\n     { name = \"anthropic\", specifier = \">=0.39.0,<1\" },\n     { name = \"asyncpg\", marker = \"extra == 'matrix'\", specifier = \">=0.29\" },\n     { name = \"atroposlib\", marker = \"extra == 'rl'\", git = \"https://github.com/NousResearch/atropos.git?rev=c20c85256e5a45ad31edf8b7276e9c5ee1995a30\" },\n     { name = \"boto3\", marker = \"extra == 'bedrock'\", specifier = \">=1.35.0,<2\" },\n-    { name = \"croniter\", marker = \"extra == 'cron'\", specifier = \">=6.0.0,<7\" },\n+    { name = \"croniter\", specifier = \">=6.0.0,<7\" },\n     { name = \"daytona\", marker = \"extra == 'daytona'\", specifier = \">=0.148.0,<1\" },\n     { name = \"debugpy\", marker = \"extra == 'dev'\", specifier = \">=1.8.0,<2\" },\n     { name = \"dingtalk-stream\", marker = \"extra == 'dingtalk'\", specifier = \">=0.20,<1\" },","@@ -2068,6 +2159,9 @@ requires-dist = [\n     { name = \"faster-whisper\", marker = \"extra == 'voice'\", specifier = \">=1.0.0,<2\" },\n     { name = \"fire\", specifier = \">=0.7.1,<1\" },\n     { name = \"firecrawl-py\", specifier = \">=4.16.0,<5\" },\n+    { name = \"google-api-python-client\", marker = \"extra == 'google'\", specifier = \">=2.100,<3\" },\n+    { name = \"google-auth-httplib2\", marker = \"extra == 'google'\", specifier = \">=0.2,<1\" },\n+    { name = \"google-auth-oauthlib\", marker = \"extra == 'google'\", specifier = \">=1.0,<2\" },\n     { name = \"hermes-agent\", extras = [\"acp\"], marker = \"extra == 'all'\" },\n     { name = \"hermes-agent\", extras = [\"acp\"], marker = \"extra == 'termux'\" },\n     { name = \"hermes-agent\", extras = [\"bedrock\"], marker = \"extra == 'all'\" },","@@ -2079,6 +2173,7 @@ requires-dist = [\n     { name = \"hermes-agent\", extras = [\"dev\"], marker = \"extra == 'all'\" },\n     { name = \"hermes-agent\", extras = [\"dingtalk\"], marker = \"extra == 'all'\" },\n     { name = \"hermes-agent\", extras = [\"feishu\"], marker = \"extra == 'all'\" },\n+    { name = \"hermes-agent\", extras = [\"google\"], marker = \"extra == 'all'\" },\n     { name = \"hermes-agent\", extras = [\"homeassistant\"], marker = \"extra == 'all'\" },\n     { name = \"hermes-agent\", extras = [\"honcho\"], marker = \"extra == 'all'\" },\n     { name = \"hermes-agent\", extras = [\"honcho\"], marker = \"extra == 'termux'\" },","@@ -2142,7 +2237,7 @@ requires-dist = [\n     { name = \"wandb\", marker = \"extra == 'rl'\", specifier = \">=0.15.0,<1\" },\n     { name = \"yc-bench\", marker = \"python_full_version >= '3.12' and extra == 'yc-bench'\", git = \"https://github.com/collinear-ai/yc-bench.git?rev=bfb0c88062450f46341bd9a5298903fc2e952a5c\" },\n ]\n-provides-extras = [\"modal\", \"daytona\", \"vercel\", \"dev\", \"messaging\", \"cron\", \"slack\", \"matrix\", \"cli\", \"tts-premium\", \"voice\", \"pty\", \"honcho\", \"mcp\", \"homeassistant\", \"sms\", \"acp\", \"mistral\", \"bedrock\", \"termux\", \"dingtalk\", \"feishu\", \"web\", \"rl\", \"yc-bench\", \"all\"]\n+provides-extras = [\"modal\", \"daytona\", \"vercel\", \"dev\", \"messaging\", \"cron\", \"slack\", \"matrix\", \"cli\", \"tts-premium\", \"voice\", \"pty\", \"honcho\", \"mcp\", \"homeassistant\", \"sms\", \"acp\", \"mistral\", \"bedrock\", \"termux\", \"dingtalk\", \"feishu\", \"google\", \"web\", \"rl\", \"yc-bench\", \"all\"]\n \n [[package]]\n name = \"hf-transfer\"","@@ -2244,6 +2339,18 @@ wheels = [\n     { url = \"https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl\", hash = \"sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55\", size = 78784, upload-time = \"2025-04-24T22:06:20.566Z\" },\n ]\n \n+[[package]]\n+name = \"httplib2\"\n+version = \"0.31.2\"\n+source = { registry = \"https://pypi.org/simple\" }\n+dependencies = [\n+    { name = \"pyparsing\" },\n+]\n+sdist = { url = \"https://files.pythonhosted.org/packages/c1/1f/e86365613582c027dda5ddb64e1010e57a3d53e99ab8a72093fa13d565ec/httplib2-0.31.2.tar.gz\", hash = \"sha256:385e0869d7397484f4eab426197a4c020b606edd43372492337c0b4010ae5d24\", size = 250800, upload-time = \"2026-01-23T11:04:44.165Z\" }\n+wheels = [\n+    { url = \"https://files.pythonhosted.org/packages/2f/90/fd509079dfcab01102c0fdd87f3a9506894bc70afcf9e9785ef6b2b3aff6/httplib2-0.31.2-py3-none-any.whl\", hash = \"sha256:dbf0c2fa3862acf3c55c078ea9c0bc4481d7dc5117cae71be9514912cf9f8349\", size = 91099, upload-time = \"2026-01-23T11:04:42.78Z\" },\n+]\n+\n [[package]]\n name = \"httptools\"\n version = \"0.7.1\"","@@ -3283,6 +3390,15 @@ wheels = [\n     { url = \"https://files.pythonhosted.org/packages/57/a7/b35835e278c18b85206834b3aa3abe68e77a98769c59233d1f6300284781/numpy-2.4.3-pp311-pypy311_pp73-win_amd64.whl\", hash = \"sha256:4b42639cdde6d24e732ff823a3fa5b701d8acad89c4142bc1d0bd6dc85200ba5\", size = 12504685, upload-time = \"2026-03-09T07:58:50.525Z\" },\n ]\n \n+[[package]]\n+name = \"oauthlib\"\n+version = \"3.3.1\"\n+source = { registry = \"https://pypi.org/simple\" }\n+sdist = { url = \"https://files.pythonhosted.org/packages/0b/5f/19930f824ffeb0ad4372da4812c50edbd1434f678c90c2733e1188edfc63/oauthlib-3.3.1.tar.gz\", hash = \"sha256:0f0f8aa759826a193cf66c12ea1af1637f87b9b4622d46e866952bb022e538c9\", size = 185918, upload-time = \"2025-06-19T22:48:08.269Z\" }\n+wheels = [\n+    { url = \"https://files.pythonhosted.org/packages/be/9c/92789c596b8df838baa98fa71844d84283302f7604ed565dafe5a6b5041a/oauthlib-3.3.1-py3-none-any.whl\", hash = \"sha256:88119c938d2b8fb88561af5f6ee0eec8cc8d552b7bb1f712743136eb7523b7a1\", size = 160065, upload-time = \"2025-06-19T22:48:06.508Z\" },\n+]\n+\n [[package]]\n name = \"obstore\"\n version = \"0.8.2\"","@@ -3861,6 +3977,18 @@ wheels = [\n     { url = \"https://files.pythonhosted.org/packages/5b/5a/bc7b4a4ef808fa59a816c17b20c4bef6884daebbdf627ff2a161da67da19/propcache-0.4.1-py3-none-any.whl\", hash = \"sha256:af2a6052aeb6cf17d3e46ee169099044fd8224cbaf75c76a2ef596e8163e2237\", size = 13305, upload-time = \"2025-10-08T19:49:00.792Z\" },\n ]\n \n+[[package]]\n+name = \"proto-plus\"\n+version = \"1.27.2\"\n+source = { registry = \"https://pypi.org/simple\" }\n+dependencies = [\n+    { name = \"protobuf\" },\n+]\n+sdist = { url = \"https://files.pythonhosted.org/packages/81/0d/94dfe80193e79d55258345901acd2917523d56e8381bc4dee7fd38e3868a/proto_plus-1.27.2.tar.gz\", hash = \"sha256:b2adde53adadf75737c44d3dcb0104fde65250dfc83ad59168b4aa3e574b6a24\", size = 57204, upload-time = \"2026-03-26T22:18:57.174Z\" }\n+wheels = [\n+    { url = \"https://files.pythonhosted.org/packages/84/f3/1fba73eeffafc998a25d59703b63f8be4fe8a5cb12eaff7386a0ba0f7125/proto_plus-1.27.2-py3-none-any.whl\", hash = \"sha256:6432f75893d3b9e70b9c412f1d2f03f65b11fb164b793d14ae2ca01821d22718\", size = 50450, upload-time = \"2026-03-26T22:13:42.927Z\" },\n+]\n+\n [[package]]\n name = \"protobuf\"\n version = \"6.33.5\"","@@ -3935,6 +4063,27 @@ wheels = [\n     { url = \"https://files.pythonhosted.org/packages/50/f2/c0e76a0b451ffdf0cf788932e182758eb7558953f4f27f1aff8e2518b653/pyarrow-23.0.1-cp314-cp314t-win_amd64.whl\", hash = \"sha256:527e8d899f14bd15b740cd5a54ad56b7f98044955373a17179d5956ddb93d9ce\", size = 28365807, upload-time = \"2026-02-16T10:14:03.892Z\" },\n ]\n \n+[[package]]\n+name = \"pyasn1\"\n+version = \"0.6.3\"\n+source = { registry = \"https://pypi.org/simple\" }\n+sdist = { url = \"https://files.pythonhosted.org/packages/5c/5f/6583902b6f79b399c9c40674ac384fd9cd77805f9e6205075f828ef11fb2/pyasn1-0.6.3.tar.gz\", hash = \"sha256:697a8ecd6d98891189184ca1fa05d1bb00e2f84b5977c481452050549c8a72cf\", size = 148685, upload-time = \"2026-03-17T01:06:53.382Z\" }\n+wheels = [\n+    { url = \"https://files.pythonhosted.org/packages/5d/a0/7d793dce3fa811fe047d6ae2431c672364b462850c6235ae306c0efd025f/pyasn1-0.6.3-py3-none-any.whl\", hash = \"sha256:a80184d120f0864a52a073acc6fc642847d0be408e7c7252f31390c0f4eadcde\", size = 83997, upload-time = \"2026-03-17T01:06:52.036Z\" },\n+]\n+\n+[[package]]\n+name = \"pyasn1-modules\"\n+version = \"0.4.2\"\n+source = { registry = \"https://pypi.org/simple\" }\n+dependencies = [\n+    { name = \"pyasn1\" },\n+]\n+sdist = { url = \"https://files.pythonhosted.org/packages/e9/e6/78ebbb10a8c8e4b61a59249394a4a594c1a7af95593dc933a349c8d00964/pyasn1_modules-0.4.2.tar.gz\", hash = \"sha256:677091de870a80aae844b1ca6134f54652fa2c8c5a52aa396440ac3106e941e6\", size = 307892, upload-time = \"2025-03-28T02:41:22.17Z\" }\n+wheels = [\n+    { url = \"https://files.pythonhosted.org/packages/47/8d/d529b5d697919ba8c11ad626e835d4039be708a35b0d22de83a269a6682c/pyasn1_modules-0.4.2-py3-none-any.whl\", hash = \"sha256:29253a9207ce32b64c3ac6600edc75368f98473906e8fd1043bd6b5b1de2c14a\", size = 181259, upload-time = \"2025-03-28T02:41:19.028Z\" },\n+]\n+\n [[package]]\n name = \"pycparser\"\n version = \"3.0\"","@@ -4275,6 +4424,15 @@ wheels = [\n     { url = \"https://files.pythonhosted.org/packages/79/93/f6729f10149305262194774d6c8b438c0b084740cf239f48ab97b4df02fa/python_olm-3.2.16-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl\", hash = \"sha256:10a5e68a2f4b5a2bfa5fdb5dbfa22396a551730df6c4a572235acaa96e997d3f\", size = 297000, upload-time = \"2023-11-28T19:25:31.045Z\" },\n ]\n \n+[[package]]\n+name = \"python-socks\"\n+version = \"2.8.1\"\n+source = { registry = \"https://pypi.org/simple\" }\n+sdist = { url = \"https://files.pythonhosted.org/packages/36/0b/cd77011c1bc01b76404f7aba07fca18aca02a19c7626e329b40201217624/python_socks-2.8.1.tar.gz\", hash = \"sha256:698daa9616d46dddaffe65b87db222f2902177a2d2b2c0b9a9361df607ab3687\", size = 38909, upload-time = \"2026-02-16T05:24:00.745Z\" }\n+wheels = [\n+    { url = \"https://files.pythonhosted.org/packages/15/fe/9a58cb6eec633ff6afae150ca53c16f8cc8b65862ccb3d088051efdfceb7/python_socks-2.8.1-py3-none-any.whl\", hash = \"sha256:28232739c4988064e725cdbcd15be194743dd23f1c910f784163365b9d7be035\", size = 55087, upload-time = \"2026-02-16T05:23:59.147Z\" },\n+]\n+\n [[package]]\n name = \"python-telegram-bot\"\n version = \"22.6\"","@@ -4535,6 +4693,19 @@ wheels = [\n     { url = \"https://files.pythonhosted.org/packages/56/5d/c814546c2333ceea4ba42262d8c4d55763003e767fa169adc693bd524478/requests-2.33.0-py3-none-any.whl\", hash = \"sha256:3324635456fa185245e24865e810cecec7b4caf933d7eb133dcde67d48cee69b\", size = 65017, upload-time = \"2026-03-25T15:10:40.382Z\" },\n ]\n \n+[[package]]\n+name = \"requests-oauthlib\"\n+version = \"2.0.0\"\n+source = { registry = \"https://pypi.org/simple\" }\n+dependencies = [\n+    { name = \"oauthlib\" },\n+    { name = \"requests\" },\n+]\n+sdist = { url = \"https://files.pythonhosted.org/packages/42/f2/05f29bc3913aea15eb670be136045bf5c5bbf4b99ecb839da9b422bb2c85/requests-oauthlib-2.0.0.tar.gz\", hash = \"sha256:b3dffaebd884d8cd778494369603a9e7b58d29111bf6b41bdc2dcd87203af4e9\", size = 55650, upload-time = \"2024-03-22T20:32:29.939Z\" }\n+wheels = [\n+    { url = \"https://files.pythonhosted.org/packages/3b/5d/63d4ae3b9daea098d5d6f5da83984853c1bbacd5dc826764b249fe119d24/requests_oauthlib-2.0.0-py2.py3-none-any.whl\", hash = \"sha256:7dd8a5c40426b779b0868c404bdef9768deccf22749cde15852df527e6269b36\", size = 24179, upload-time = \"2024-03-22T20:32:28.055Z\" },\n+]\n+\n [[package]]\n name = \"requests-toolbelt\"\n version = \"1.0.0\"","@@ -5274,6 +5445,15 @@ wheels = [\n     { url = \"https://files.pythonhosted.org/packages/4c/a7/563b2d8fb7edc07320bf69ac6a7eedcd7a1a9d663a6bb90a4d9bd2eda5f7/unpaddedbase64-2.1.0-py3-none-any.whl\", hash = \"sha256:485eff129c30175d2cd6f0cd8d2310dff51e666f7f36175f738d75dfdbd0b1c6\", size = 6083, upload-time = \"2021-03-09T11:35:46.7Z\" },\n ]\n \n+[[package]]\n+name = \"uritemplate\"\n+version = \"4.2.0\"\n+source = { registry = \"https://pypi.org/simple\" }\n+sdist = { url = \"https://files.pythonhosted.org/packages/98/60/f174043244c5306c9988380d2cb10009f91563fc4b31293d27e17201af56/uritemplate-4.2.0.tar.gz\", hash = \"sha256:480c2ed180878955863323eea31b0ede668795de182617fef9c6ca09e6ec9d0e\", size = 33267, upload-time = \"2025-06-02T15:12:06.318Z\" }\n+wheels = [\n+    { url = \"https://files.pythonhosted.org/packages/a9/99/3ae339466c9183ea5b8ae87b34c0b897eda475d2aec2307cae60e5cd4f29/uritemplate-4.2.0-py3-none-any.whl\", hash = \"sha256:962201ba1c4edcab02e60f9a0d3821e82dfc5d2d6662a21abd533879bdb8a686\", size = 11488, upload-time = \"2025-06-02T15:12:03.405Z\" },\n+]\n+\n [[package]]\n name = \"urllib3\"\n version = \"2.6.3\"\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}