NousResearch/hermes-agent

Prevent empty ghost sessions cluttering resume list

Cluster 18269. Data as of

Nothing open on GitHub. Fix merged in #18370 on 1 May 2026. Check it covers your case.

The short answer

0open issues
0open PRs
4items grouped
Known?
Yes. 4 related items grouped here, 1 issue already closed.
Fix event
A fix was merged to main in PR #18370. Check it covers your case. No release data is tracked.
Tracker
The maintainers' tracker marks this resolved, deciding PR #18370. That is separate from the fix event.
Still open
Nothing open on GitHub.

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.

Open issues0

No open issues.

Open PRs0

No open pull requests.

Fixes on record

Merged to main. A stamp counts the issues a fix is linked to.

  • #18370 fix: lazy session creation — defer DB row until first message

    merged to main

    files: cli.py, hermes_cli/main.py, hermes_state.py, run_agent.py, tests/run_agent/test_860_dedup.py, tui_gateway/server.py

    Show the diff
    • cli.py
      @@ -934,6 +934,20 @@ def _run_state_db_auto_maintenance(session_db) -> None:
           try:
               from hermes_cli.config import load_config as _load_full_config
               from hermes_constants import get_hermes_home as _get_hermes_home
      +        _hermes_home_maint = _get_hermes_home()
      +
      +        # One-time prune of empty TUI ghost sessions.
      +        try:
      +            if not session_db.get_meta("ghost_session_prune_v1"):
      +                pruned = session_db.prune_empty_ghost_sessions(
      +                    sessions_dir=_hermes_home_maint / "sessions"
      +                )
      +                session_db.set_meta("ghost_session_prune_v1", "1")
      +                if pruned:
      +                    logger.info("Pruned %d empty TUI ghost sessions", pruned)
      +        except Exception as _prune_exc:
      +            logger.debug("Ghost session prune skipped: %s", _prune_exc)
      +
               cfg = (_load_full_config().get("sessions") or {})
               if not cfg.get("auto_prune", False):
                   return
      @@ -941,7 +955,7 @@ def _run_state_db_auto_maintenance(session_db) -> None:
                   retention_days=int(cfg.get("retention_days", 90)),
                   min_interval_hours=int(cfg.get("min_interval_hours", 24)),
                   vacuum=bool(cfg.get("vacuum_after_prune", True)),
      -            sessions_dir=_get_hermes_home() / "sessions",
      +            sessions_dir=_hermes_home_maint / "sessions",
               )
           except Exception as exc:
               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
                       tuple(runtime.get("args") or ()),
                   )
       
      -            if self._pending_title and self._session_db:
      +            # Force-create DB row on /title intent, then apply title.
      +            if self._pending_title and self._session_db and self.agent:
                       try:
      -                    self._session_db.set_session_title(self.session_id, self._pending_title)
      -                    _cprint(f"  Session title applied: {self._pending_title}")
      -                    self._pending_title = None
      +                    self.agent._ensure_db_session()
      +                    if self.agent._session_db_created:
      +                        self._session_db.set_session_title(self.session_id, self._pending_title)
      +                        _cprint(f"  Session title applied: {self._pending_title}")
      +                        self._pending_title = None
      +                    # else: row creation failed transiently — keep _pending_title for retry
                       except (ValueError, Exception) as e:
                           _cprint(f"  Could not apply pending title: {e}")
      -                    self._pending_title = None
      +                    # Keep _pending_title so it can be retried after row creation succeeds
                   return True
               except Exception as e:
                   ChatConsole().print(f"[bold red]Failed to initialize agent: {e}[/]")
      @@ -4953,6 +4971,7 @@ def new_session(self, silent=False):
       
                   if self._session_db:
                       try:
      +                    self.agent._session_db_created = False
                           self._session_db.create_session(
                               session_id=self.session_id,
                               source=os.environ.get("HERMES_SESSION_SOURCE", "cli"),
      @@ -4962,6 +4981,7 @@ def new_session(self, silent=False):
                                   "reasoning_config": self.reasoning_config,
                               },
                           )
      +                    self.agent._session_db_created = True
                       except Exception:
                           pass
                   # Notify memory providers that session_id rotated to a fresh
    • hermes_cli/main.py
      @@ -800,6 +800,8 @@ def _print_tui_exit_summary(session_id: Optional[str], active_session_file: Opti
       
               title = db.get_session_title(target)
               message_count = int(session.get("message_count") or 0)
      +        if message_count == 0:
      +            return  # No real conversation — don't show resume info
               input_tokens = [redacted:secret-assignment]"input_tokens") or 0)
               output_tokens = [redacted:secret-assignment]"output_tokens") or 0)
               cache_read_tokens = [redacted:secret-assignment]"cache_read_tokens") or 0)
    • hermes_state.py
      @@ -514,7 +514,7 @@ def _init_schema(self):
           # Session lifecycle
           # =========================================================================
       
      -    def create_session(
      +    def _insert_session_row(
               self,
               session_id: str,
               source: str,
      @@ -523,8 +523,8 @@ def create_session(
               system_prompt: str = None,
               user_id: str = None,
               parent_session_id: str = None,
      -    ) -> str:
      -        """Create a new session record. Returns the session_id."""
      +    ) -> None:
      +        """Shared INSERT OR IGNORE for session rows."""
               def _do(conn):
                   conn.execute(
                       """INSERT OR IGNORE INTO sessions (id, source, user_id, model, model_config,
      @@ -542,8 +542,11 @@ def _do(conn):
                       ),
                   )
               self._execute_write(_do)
      -        return session_id
       
      +    def create_session(self, session_id: str, source: str, **kwargs) -> str:
      +        """Create a new session record. Returns the session_id."""
      +        self._insert_session_row(session_id, source, **kwargs)
      +        return session_id
           def end_session(self, session_id: str, end_reason: str) -> None:
               """Mark a session as ended.
       
      @@ -679,21 +682,41 @@ def ensure_session(
               session_id: str,
               source: str = "unknown",
               model: str = None,
      -    ) -> None:
      -        """Ensure a session row exists, creating it with minimal metadata if absent.
      +        **kwargs,
      +    ) -> str:
      +        """Ensure a session row exists (INSERT OR IGNORE). Accepts optional kwargs."""
      +        self._insert_session_row(session_id, source, model=model, **kwargs)
      +        return session_id
      +
      +    def prune_empty_ghost_sessions(self, sessions_dir: "Optional[Path]" = None) -> int:
      +        """Remove empty TUI ghost sessions (no messages, no title, >24hr old)."""
      +        cutoff = time.time() - 86400  # Only sessions older than 24 hours
       
      -        Used by _flush_messages_to_session_db to recover from a failed
      -        create_session() call (e.g. transient SQLite lock at agent startup).
      -        INSERT OR IGNORE is safe to call even when the row already exists.
      -        """
               def _do(conn):
      -            conn.execute(
      -                """INSERT OR IGNORE INTO sessions
      -                   (id, source, model, started_at)
      -                   VALUES (?, ?, ?, ?)""",
      -                (session_id, source, model, time.time()),
      -            )
      -        self._execute_write(_do)
      +            rows = conn.execute("""
      +                SELECT id FROM sessions
      +                WHERE source = 'tui'
      +                  AND title IS NULL
      +                  AND ended_at IS NOT NULL
      +                  AND started_at < ?
      +                  AND NOT EXISTS (
      +                      SELECT 1 FROM messages WHERE messages.session_id = sessions.id
      +                  )
      +            """, (cutoff,)).fetchall()
      +            ids = [r[0] if isinstance(r, (tuple, list)) else r["id"] for r in rows]
      +            if ids:
      +                placeholders = ",".join("?" * len(ids))
      +                conn.execute(
      +                    f"DELETE FROM sessions WHERE id IN ({placeholders})", ids
      +                )
      +            return ids
      +
      +        removed_ids = self._execute_write(_do) or []
      +        # Clean up any on-disk session files (belt-and-suspenders)
      +        if sessions_dir and removed_ids:
      +            for sid in removed_ids:
      +                self._remove_session_files(sessions_dir, sid)
      +        return len(removed_ids)
       
           def get_session(self, session_id: str) -> Optional[Dict[str, Any]]:
               """Get a session by ID."""
    • run_agent.py
      @@ -1632,30 +1632,12 @@ def __init__(
               self._session_db = session_db
               self._parent_session_id = parent_session_id
               self._last_flushed_db_idx = 0  # tracks DB-write cursor to prevent duplicate writes
      -        if self._session_db:
      -            try:
      -                self._session_db.create_session(
      -                    session_id=self.session_id,
      -                    source=self.platform or os.environ.get("HERMES_SESSION_SOURCE", "cli"),
      -                    model=self.model,
      -                    model_config={
      -                        "max_iterations": self.max_iterations,
      -                        "reasoning_config": reasoning_config,
      -                        "max_tokens": [redacted:secret-assignment]
      -                    },
      -                    user_id=None,
      -                    parent_session_id=self._parent_session_id,
      -                )
      -            except Exception as e:
      -                # Transient SQLite lock contention (e.g. CLI and gateway writing
      -                # concurrently) must NOT permanently disable session_search for
      -                # this agent.  Keep _session_db alive — subsequent message
      -                # flushes and session_search calls will still work once the
      -                # lock clears.  The session row may be missing from the index
      -                # for this run, but that is recoverable (flushes upsert rows).
      -                logger.warning(
      -                    "Session DB create_session failed (session_search still available): %s", e
      -                )
      +        self._session_db_created = False  # DB row deferred to run_conversation()
      +        self._session_init_model_config = {
      +            "max_iterations": self.max_iterations,
      +            "reasoning_config": reasoning_config,
      +            "max_tokens": [redacted:secret-assignment]
      +        }
               
               # In-memory todo list for task planning (one per agent/session)
               from tools.todo_tool import TodoStore
      @@ -2170,6 +2152,28 @@ def __init__(
                       "is_anthropic_oauth": self._is_anthropic_oauth,
                   })
       
      +    def _ensure_db_session(self) -> None:
      +        """Create session DB row on first use. Disables _session_db on failure."""
      +        if self._session_db_created or not self._session_db:
      +            return
      +        try:
      +            self._session_db.create_session(
      +                session_id=self.session_id,
      +                source=self.platform or os.environ.get("HERMES_SESSION_SOURCE", "cli"),
      +                model=self.model,
      +                model_config=self._session_init_model_config,
      +                system_prompt=self._cached_system_prompt,
      +                user_id=None,
      +                parent_session_id=self._parent_session_id,
      +            )
      +            self._session_db_created = True
      +        except Exception as e:
      +            # Transient failure (e.g. SQLite lock). Keep _session_db alive —
      +            # _session_db_created stays False so next run_conversation() retries.
      +            logger.warning(
      +                "Session DB creation failed (will retry next turn): %s", e
      +            )
      +
           def reset_session_state(self):
               """Reset all session-scoped token counters to 0 for a fresh session.
               
      @@ -3719,14 +3723,9 @@ def _flush_messages_to_session_db(self, messages: List[Dict], conversation_histo
                   return
               self._apply_persist_user_message_override(messages)
               try:
      -            # If create_session() failed at startup (e.g. transient lock), the
      -            # session row may not exist yet.  ensure_session() uses INSERT OR
      -            # IGNORE so it is a no-op when the row is already there.
      -            self._session_db.ensure_session(
      -                self.session_id,
      -                source=self.platform or "cli",
      -                model=self.model,
      -            )
      +            # Retry row creation if the earlier attempt failed transiently.
      +            if not self._session_db_created:
      +                self._ensure_db_session()
                   start_idx = len(conversation_history) if conversation_history else 0
                   flush_from = max(start_idx, self._last_flushed_db_idx)
                   for msg in messages[flush_from:]:
      @@ -9056,12 +9055,15 @@ def _compress_context(self, messages: list, system_message: str, *, approx_token
                       self.session_id = f"{datetime.now().strftime('%Y%m%d_%H%M%S')}_{uuid.uuid4().hex[:6]}"
                       # Update session_log_file to point to the new session's JSON file
                       self.session_log_file = self.logs_dir / f"session_{self.session_id}.json"
      +                self._session_db_created = False
                       self._session_db.create_session(
                           session_id=self.session_id,
                           source=self.platform or os.environ.get("HERMES_SESSION_SOURCE", "cli"),
                           model=self.model,
      +                    model_config=self._session_init_model_config,
                           parent_session_id=old_session_id,
                       )
      +                self._session_db_created = True
                       # Auto-number the title for the continuation session
                       if old_title:
                           try:
      @@ -10351,6 +10353,8 @@ def run_conversation(
               # Installed once, transparent when streams are healthy, prevents crash on write.
               _install_safe_stdio()
       
      +        self._ensure_db_session()
      +
               # Tag all log records on this thread with the session ID so
               # ``hermes logs --session <id>`` can filter a single conversation.
               from hermes_logging import set_session_context
    • tests/run_agent/test_860_dedup.py
      @@ -38,6 +38,8 @@ def _make_agent(self, session_db):
                       skip_context_files=True,
                       skip_memory=True,
                   )
      +        # Simulate lazy session creation (normally done by run_conversation)
      +        agent._ensure_db_session()
               return agent
       
           def test_flush_writes_only_new_messages(self):
    • tui_gateway/server.py
      @@ -280,7 +280,7 @@ def _notify_session_boundary(event_type: str, session_id: str | None) -> None:
               pass
       
       
      -def _finalize_session(session: dict | None) -> None:
      +def _finalize_session(session: dict | None, end_reason: str = "tui_close") -> None:
           """Best-effort finalize hook + memory commit for a session."""
           if not session or session.get("_finalized"):
               return
      @@ -299,13 +299,24 @@ def _finalize_session(session: dict | None) -> None:
               except Exception:
                   pass
       
      -    session_id = getattr(agent, "session_id", None) or session.get("session_key")
      +    session_key = session.get("session_key")
      +    session_id = getattr(agent, "session_id", None) or session_key
           _notify_session_boundary("on_session_finalize", session_id)
       
      +    # Mark session ended in DB so it doesn't linger as a ghost row in /resume.
      +    # Adapted from #18283 (luyao618) and #18299 (Bartok9).
      +    if session_key:
      +        try:
      +            db = _get_db()
      +            if db is not None:
      +                db.end_session(session_key, end_reason)
      +        except Exception:
      +            pass
      +
       
       def _shutdown_sessions() -> None:
           for session in list(_sessions.values()):
      -        _finalize_session(session)
      +        _finalize_session(session, end_reason="tui_shutdown")
               try:
                   worker = session.get("slash_worker")
                   if worker:
      @@ -539,32 +550,8 @@ def _build() -> None:
                   finally:
                       _clear_session_context(tokens)
       
      -            db = _get_db()
      -            if db is not None:
      -                db.create_session(key, source="tui", model=_resolve_model())
      -                pending_title = (current.get("pending_title") or "").strip()
      -                if pending_title:
      -                    try:
      -                        title_applied = db.set_session_title(key, pending_title)
      -                        if title_applied:
      -                            current["pending_title"] = None
      -                        else:
      -                            existing_row = db.get_session(key)
      -                            existing_title = ((existing_row or {}).get("title") or "").strip()
      -                            if existing_title == pending_title:
      -                                current["pending_title"] = None
      -                            else:
      -                                logger.info(
      -                                    "Pending title still queued for session %s (wanted=%r, current=%r)",
      -                                    sid,
      -                                    pending_title,
      -                                    existing_title,
      -                                )
      -                    except ValueError as e:
      -                        current["pending_title"] = None
      -                        logger.info("Dropping pending title for session %s: %s", sid, e)
      -                    except Exception:
      -                        logger.warning("Failed to apply pending title for session %s", sid, exc_info=True)
      +            # Session DB row deferred to first run_conversation() call.
      +            # pending_title applied post-first-message (see cli.exec handler).
                   current["agent"] = agent
       
                   try:
      @@ -2996,6 +2983,17 @@ def _stream(delta):
                       payload["rendered"] = rendered
                   _emit("message.complete", sid, payload)
       
      +            # Apply pending_title now that the DB row exists.
      +            _pending = session.get("pending_title")
      +            if _pending and status == "complete":
      +                _pdb = _get_db()
      +                if _pdb:
      +                    try:
      +                        if _pdb.set_session_title(session.get("session_key") or sid, _pending):
      +                            session["pending_title"] = None
      +                    except Exception:
      +                        pass  # Best effort — auto-title will handle it below
      +
                   if (
                       status == "complete"
                       and isinstance(raw, str)
    • uv.lock
      @@ -9,7 +9,7 @@ resolution-markers = [
       ]
       
       [options]
      -exclude-newer = "2026-04-17T16:49:45.944715922Z"
      +exclude-newer = "0001-01-01T00:00:00Z" # This has no effect and is included for backwards compatibility when using relative exclude-newer values.
       exclude-newer-span = "P7D"
       
       [[package]]
      @@ -156,6 +156,19 @@ wheels = [
           { 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" },
       ]
       
      +[[package]]
      +name = "aiohttp-socks"
      +version = "0.11.0"
      +source = { registry = "https://pypi.org/simple" }
      +dependencies = [
      +    { name = "aiohttp" },
      +    { name = "python-socks" },
      +]
      +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" }
      +wheels = [
      +    { 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" },
      +]
      +
       [[package]]
       name = "aiosignal"
       version = "1.4.0"
      @@ -1759,6 +1772,77 @@ wheels = [
           { 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" },
       ]
       
      +[[package]]
      +name = "google-api-core"
      +version = "2.30.3"
      +source = { registry = "https://pypi.org/simple" }
      +dependencies = [
      +    { name = "google-auth" },
      +    { name = "googleapis-common-protos" },
      +    { name = "proto-plus" },
      +    { name = "protobuf" },
      +    { name = "requests" },
      +]
      +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" }
      +wheels = [
      +    { 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" },
      +]
      +
      +[[package]]
      +name = "google-api-python-client"
      +version = "2.194.0"
      +source = { registry = "https://pypi.org/simple" }
      +dependencies = [
      +    { name = "google-api-core" },
      +    { name = "google-auth" },
      +    { name = "google-auth-httplib2" },
      +    { name = "httplib2" },
      +    { name = "uritemplate" },
      +]
      +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" }
      +wheels = [
      +    { 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" },
      +]
      +
      +[[package]]
      +name = "google-auth"
      +version = "2.49.2"
      +source = { registry = "https://pypi.org/simple" }
      +dependencies = [
      +    { name = "cryptography" },
      +    { name = "pyasn1-modules" },
      +]
      +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" }
      +wheels = [
      +    { 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" },
      +]
      +
      +[[package]]
      +name = "google-auth-httplib2"
      +version = "0.3.1"
      +source = { registry = "https://pypi.org/simple" }
      +dependencies = [
      +    { name = "google-auth" },
      +    { name = "httplib2" },
      +]
      +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" }
      +wheels = [
      +    { 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" },
      +]
      +
      +[[package]]
      +name = "google-auth-oauthlib"
      +version = "1.3.1"
      +source = { registry = "https://pypi.org/simple" }
      +dependencies = [
      +    { name = "google-auth" },
      +    { name = "requests-oauthlib" },
      +]
      +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" }
      +wheels = [
      +    { 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" },
      +]
      +
       [[package]]
       name = "googleapis-common-protos"
       version = "1.73.0"
      @@ -1870,10 +1954,11 @@ wheels = [
       
       [[package]]
       name = "hermes-agent"
      -version = "0.11.0"
      +version = "0.12.0"
       source = { editable = "." }
       dependencies = [
           { name = "anthropic" },
      +    { name = "croniter" },
           { name = "edge-tts" },
           { name = "exa-py" },
           { name = "fal-client" },
      @@ -1900,11 +1985,11 @@ acp = [
       all = [
           { name = "agent-client-protocol" },
           { name = "aiohttp" },
      +    { name = "aiohttp-socks", marker = "sys_platform == 'linux'" },
           { name = "aiosqlite", marker = "sys_platform == 'linux'" },
           { name = "alibabacloud-dingtalk" },
           { name = "asyncpg", marker = "sys_platform == 'linux'" },
           { name = "boto3" },
      -    { name = "croniter" },
           { name = "daytona" },
           { name = "debugpy" },
           { name = "dingtalk-stream" },
      @@ -1912,6 +1997,9 @@ all = [
           { name = "elevenlabs" },
           { name = "fastapi" },
           { name = "faster-whisper" },
      +    { name = "google-api-python-client" },
      +    { name = "google-auth-httplib2" },
      +    { name = "google-auth-oauthlib" },
           { name = "honcho-ai" },
           { name = "lark-oapi" },
           { name = "markdown", marker = "sys_platform == 'linux'" },
      @@ -1942,9 +2030,6 @@ bedrock = [
       cli = [
           { name = "simple-term-menu" },
       ]
      -cron = [
      -    { name = "croniter" },
      -]
       daytona = [
           { name = "daytona" },
       ]
      @@ -1966,6 +2051,11 @@ feishu = [
           { name = "lark-oapi" },
           { name = "qrcode" },
       ]
      +google = [
      +    { name = "google-api-python-client" },
      +    { name = "google-auth-httplib2" },
      +    { name = "google-auth-oauthlib" },
      +]
       homeassistant = [
           { name = "aiohttp" },
       ]
      @@ -1973,6 +2063,7 @@ honcho = [
           { name = "honcho-ai" },
       ]
       matrix = [
      +    { name = "aiohttp-socks" },
           { name = "aiosqlite" },
           { name = "asyncpg" },
           { name = "markdown" },
      @@ -2015,7 +2106,6 @@ sms = [
       ]
       termux = [
           { name = "agent-client-protocol" },
      -    { name = "croniter" },
           { name = "honcho-ai" },
           { name = "mcp" },
           { name = "ptyprocess", marker = "sys_platform != 'win32'" },
      @@ -2048,13 +2138,14 @@ requires-dist = [
           { name = "aiohttp", marker = "extra == 'homeassistant'", specifier = ">=3.9.0,<4" },
           { name = "aiohttp", marker = "extra == 'messaging'", specifier = ">=3.13.3,<4" },
           { name = "aiohttp", marker = "extra == 'sms'", specifier = ">=3.9.0,<4" },
      +    { name = "aiohttp-socks", marker = "extra == 'matrix'", specifier = ">=0.10,<1" },
           { name = "aiosqlite", marker = "extra == 'matrix'", specifier = ">=0.20" },
           { name = "alibabacloud-dingtalk", marker = "extra == 'dingtalk'", specifier = ">=2.0.0" },
           { name = "anthropic", specifier = ">=0.39.0,<1" },
           { name = "asyncpg", marker = "extra == 'matrix'", specifier = ">=0.29" },
           { name = "atroposlib", marker = "extra == 'rl'", git = "https://github.com/NousResearch/atropos.git?rev=c20c85256e5a45ad31edf8b7276e9c5ee1995a30" },
           { name = "boto3", marker = "extra == 'bedrock'", specifier = ">=1.35.0,<2" },
      -    { name = "croniter", marker = "extra == 'cron'", specifier = ">=6.0.0,<7" },
      +    { name = "croniter", specifier = ">=6.0.0,<7" },
           { name = "daytona", marker = "extra == 'daytona'", specifier = ">=0.148.0,<1" },
           { name = "debugpy", marker = "extra == 'dev'", specifier = ">=1.8.0,<2" },
           { name = "dingtalk-stream", marker = "extra == 'dingtalk'", specifier = ">=0.20,<1" },
      @@ -2068,6 +2159,9 @@ requires-dist = [
           { name = "faster-whisper", marker = "extra == 'voice'", specifier = ">=1.0.0,<2" },
           { name = "fire", specifier = ">=0.7.1,<1" },
           { name = "firecrawl-py", specifier = ">=4.16.0,<5" },
      +    { name = "google-api-python-client", marker = "extra == 'google'", specifier = ">=2.100,<3" },
      +    { name = "google-auth-httplib2", marker = "extra == 'google'", specifier = ">=0.2,<1" },
      +    { name = "google-auth-oauthlib", marker = "extra == 'google'", specifier = ">=1.0,<2" },
           { name = "hermes-agent", extras = ["acp"], marker = "extra == 'all'" },
           { name = "hermes-agent", extras = ["acp"], marker = "extra == 'termux'" },
           { name = "hermes-agent", extras = ["bedrock"], marker = "extra == 'all'" },
      @@ -2079,6 +2173,7 @@ requires-dist = [
           { name = "hermes-agent", extras = ["dev"], marker = "extra == 'all'" },
           { name = "hermes-agent", extras = ["dingtalk"], marker = "extra == 'all'" },
           { name = "hermes-agent", extras = ["feishu"], marker = "extra == 'all'" },
      +    { name = "hermes-agent", extras = ["google"], marker = "extra == 'all'" },
           { name = "hermes-agent", extras = ["homeassistant"], marker = "extra == 'all'" },
           { name = "hermes-agent", extras = ["honcho"], marker = "extra == 'all'" },
           { name = "hermes-agent", extras = ["honcho"], marker = "extra == 'termux'" },
      @@ -2142,7 +2237,7 @@ requires-dist = [
           { name = "wandb", marker = "extra == 'rl'", specifier = ">=0.15.0,<1" },
           { name = "yc-bench", marker = "python_full_version >= '3.12' and extra == 'yc-bench'", git = "https://github.com/collinear-ai/yc-bench.git?rev=bfb0c88062450f46341bd9a5298903fc2e952a5c" },
       ]
      -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"]
      +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"]
       
       [[package]]
       name = "hf-transfer"
      @@ -2244,6 +2339,18 @@ wheels = [
           { 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" },
       ]
       
      +[[package]]
      +name = "httplib2"
      +version = "0.31.2"
      +source = { registry = "https://pypi.org/simple" }
      +dependencies = [
      +    { name = "pyparsing" },
      +]
      +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" }
      +wheels = [
      +    { 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" },
      +]
      +
       [[package]]
       name = "httptools"
       version = "0.7.1"
      @@ -3283,6 +3390,15 @@ wheels = [
           { 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" },
       ]
       
      +[[package]]
      +name = "oauthlib"
      +version = "3.3.1"
      +source = { registry = "https://pypi.org/simple" }
      +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" }
      +wheels = [
      +    { 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" },
      +]
      +
       [[package]]
       name = "obstore"
       version = "0.8.2"
      @@ -3861,6 +3977,18 @@ wheels = [
           { 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" },
       ]
       
      +[[package]]
      +name = "proto-plus"
      +version = "1.27.2"
      +source = { registry = "https://pypi.org/simple" }
      +dependencies = [
      +    { name = "protobuf" },
      +]
      +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" }
      +wheels = [
      +    { 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" },
      +]
      +
       [[package]]
       name = "protobuf"
       version = "6.33.5"
      @@ -3935,6 +4063,27 @@ wheels = [
           { 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" },
       ]
       
      +[[package]]
      +name = "pyasn1"
      +version = "0.6.3"
      +source = { registry = "https://pypi.org/simple" }
      +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" }
      +wheels = [
      +    { 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" },
      +]
      +
      +[[package]]
      +name = "pyasn1-modules"
      +version = "0.4.2"
      +source = { registry = "https://pypi.org/simple" }
      +dependencies = [
      +    { name = "pyasn1" },
      +]
      +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" }
      +wheels = [
      +    { 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" },
      +]
      +
       [[package]]
       name = "pycparser"
       version = "3.0"
      @@ -4275,6 +4424,15 @@ wheels = [
           { 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" },
       ]
       
      +[[package]]
      +name = "python-socks"
      +version = "2.8.1"
      +source = { registry = "https://pypi.org/simple" }
      +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" }
      +wheels = [
      +    { 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" },
      +]
      +
       [[package]]
       name = "python-telegram-bot"
       version = "22.6"
      @@ -4535,6 +4693,19 @@ wheels = [
           { 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" },
       ]
       
      +[[package]]
      +name = "requests-oauthlib"
      +version = "2.0.0"
      +source = { registry = "https://pypi.org/simple" }
      +dependencies = [
      +    { name = "oauthlib" },
      +    { name = "requests" },
      +]
      +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" }
      +wheels = [
      +    { 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" },
      +]
      +
       [[package]]
       name = "requests-toolbelt"
       version = "1.0.0"
      @@ -5274,6 +5445,15 @@ wheels = [
           { 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" },
       ]
       
      +[[package]]
      +name = "uritemplate"
      +version = "4.2.0"
      +source = { registry = "https://pypi.org/simple" }
      +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" }
      +wheels = [
      +    { 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" },
      +]
      +
       [[package]]
       name = "urllib3"
       version = "2.6.3"

2 PRs closed without merging

From the close comments: superseded by another PR 2

  • #18283 fix: end TUI sessions in state.db to prevent ghost rows in /resume · superseded by another PR → #18370
  • #18299 fix(tui): end_session on finalize to prevent ghost sessions in /resume (#18269) · superseded by another PR → #18370
Closed issues · 1
  • Closed#18269[Bug]: TUI eagerly persists empty sessions to DB — ghost sessions clutter /resume
How this cluster was grouped
  • Link evidence (structural links throughout): every member is connected by structural evidence: shared changed files, or a closing/superseded reference.
  • Pairwise link density 0.50: the share of item pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36. 1.00 means every pair is linked.

Pick up a Lab task

Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.

Open the Lab

At a glance

Merged PRs
#18370
Closed issues
1
Attempts
2 PRs closed without merging
Fix link checked
1 May 2026

For your agent

The same facts, typed.