{"repo":"NousResearch/hermes-agent","public_id":"17926","display_label":"Filter and classify sessions by origin or type","summary":"These issues and PRs concern distinguishing user-initiated sessions from background, cron, or agent-driven sessions, and controlling which session types appear in UI lists, APIs, and CLI output. Work spans metadata persistence, provenance tracking, and filtering mechanisms across Desktop, API server, and CLI.","subsystem":"agent","counts":{"members":13,"open_issues":6,"open_issues_distinct":6,"open_prs":3,"closed_issues":1,"merged_prs":1,"attempt_prs":5},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":41724},"resolution":{"state":"unresolved","by":null},"freshness":{"state":"verified","last_verified_at":"2026-06-08T05:22:22+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_PARTIAL","link_density":0.10461538461538462},"fix_edges":[{"issue_number":28412,"pr_number":41724,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":33617,"pr_number":41724,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true}],"members":[{"kind":"issue","number":17926,"title":"Expose canonical user-visible session classification for dashboards","url":null,"state":"open"},{"kind":"issue","number":23114,"title":"feat(cli): separate cron sessions from regular sessions in `hermes sessions list`","url":null,"state":"open"},{"kind":"issue","number":28412,"title":"[Feature]: persist session kind/provenance metadata","url":null,"state":"open"},{"kind":"issue","number":38894,"title":"[Feature]: Separate cron/autonomous sessions from manual chats in Desktop","url":null,"state":"closed"},{"kind":"issue","number":39372,"title":"Background/integration agent runs should not pollute user-visible session lists","url":null,"state":"open"},{"kind":"issue","number":49991,"title":"Kanban worker sessions appear in owner-facing Desktop history","url":null,"state":"open"},{"kind":"issue","number":57527,"title":"API Server: filter out cron sessions from session listing","url":null,"state":"open"},{"kind":"pr","number":30069,"title":"feat: persist session provenance and correlate delegate children","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"777dc9da6","number":0},{"kind":"pr","value":"#41724","number":41724}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":39362,"title":"feat(desktop): manual-only session filter in sidebar","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"628f9040df438182578381a4fc72e4044509d5bd","number":0},{"kind":"pr","value":"#38894","number":38894}],"category":"superseded_by_pr","implication":"superseded"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":39506,"title":"[codex] Hide internal agent sessions by default","url":null,"state":"open","close_reason":{"category":"uncategorized","implication":"needs_llm"},"merged":false},{"kind":"pr","number":41724,"title":"feat(acp): emit session provenance metadata for compression rotation","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["acp_adapter/provenance.py","acp_adapter/server.py","tests/acp/test_session_provenance.py"],"fix_hunks":[{"file":"acp_adapter/provenance.py","hunks":["@@ -0,0 +1,127 @@\n+\"\"\"Derive ACP session-provenance metadata from the existing compression chain.\n+\n+This is an additive Hermes extension surfaced under ACP ``_meta.hermes`` so\n+existing ACP clients ignore it. It carries no new persisted state: everything\n+is derived on demand from the ``sessions`` table (``parent_session_id`` /\n+``end_reason``), which already models compression-continuation chains.\n+\n+The ACP/editor ``session_id`` stays the stable public handle. When context\n+compression rotates the internal Hermes head, ``build_session_provenance`` lets\n+a client see the previous/current internal ids and the lineage root without\n+parsing status text, guessing from token drops, or reading ``state.db``.\n+\"\"\"\n+\n+from __future__ import annotations\n+\n+from typing import Any, Dict, Optional\n+\n+# Bound defensive walks; compression chains this deep are pathological.\n+_MAX_WALK = 100\n+\n+\n+def build_session_provenance(\n+    db: Any,\n+    acp_session_id: str,\n+    current_hermes_session_id: str,\n+    *,\n+    previous_hermes_session_id: Optional[str] = None,\n+) -> Optional[Dict[str, Any]]:\n+    \"\"\"Build ``_meta.hermes.sessionProvenance`` for an ACP session.\n+\n+    Args:\n+        db: A ``SessionDB`` (must expose ``get_session``).\n+        acp_session_id: The stable ACP/editor-facing session handle.\n+        current_hermes_session_id: The live internal Hermes DB session id\n+            (``state.agent.session_id``).\n+        previous_hermes_session_id: The internal id from before the most recent\n+            turn, when known. Supplied by ``prompt()`` to flag a rotation.\n+\n+    Returns:\n+        A dict suitable for ``{\"hermes\": {\"sessionProvenance\": <dict>}}`` under\n+        ACP ``_meta``, or ``None`` if the session can't be read.\n+    \"\"\"\n+    try:\n+        row = db.get_session(current_hermes_session_id)\n+    except Exception:\n+        return None\n+    if not row:\n+        return None\n+\n+    parent_id = row.get(\"parent_session_id\")\n+    end_reason = row.get(\"end_reason\")\n+\n+    # Walk parents to the lineage root and count compression depth. Only\n+    # compression-split parents (parent.end_reason == 'compression') count\n+    # toward depth — delegate/branch children share the parent_session_id\n+    # column but are not compaction boundaries.\n+    root_id = current_hermes_session_id\n+    compression_depth = 0\n+    cursor_parent = parent_id\n+    seen = {current_hermes_session_id}\n+    for _ in range(_MAX_WALK):\n+        if not cursor_parent or cursor_parent in seen:\n+            break\n+        seen.add(cursor_parent)\n+        try:\n+            prow = db.get_session(cursor_parent)\n+        except Exception:\n+            prow = None\n+        if not prow:\n+            break\n+        root_id = cursor_parent\n+        if prow.get(\"end_reason\") == \"compression\":\n+            compression_depth += 1\n+        cursor_parent = prow.get(\"parent_session_id\")\n+\n+    # A session is a compression continuation when its parent was ended with\n+    # end_reason='compression'. Determine that from the immediate parent.\n+    is_continuation = False\n+    if parent_id:\n+        try:\n+            immediate_parent = db.get_session(parent_id)\n+        except Exception:\n+            immediate_parent = None\n+        if immediate_parent and immediate_parent.get(\"end_reason\") == \"compression\":\n+            is_continuation = True\n+\n+    rotated = bool(\n+        previous_hermes_session_id\n+        and previous_hermes_session_id != current_hermes_session_id\n+    )\n+\n+    provenance: Dict[str, Any] = {\n+        \"acpSessionId\": acp_session_id,\n+        \"currentHermesSessionId\": current_hermes_session_id,\n+        \"rootHermesSessionId\": root_id,\n+        \"parentHermesSessionId\": parent_id,\n+        \"sessionKind\": \"continuation\" if is_continuation else \"root\",\n+        \"compressionDepth\": compression_depth,\n+    }\n+    if previous_hermes_session_id:\n+        provenance[\"previousHermesSessionId\"] = previous_hermes_session_id\n+    if rotated:\n+        # The head moved during the last turn. The only mechanism that rotates\n+        # the internal id mid-turn is compression-driven session splitting.\n+        provenance[\"reason\"] = \"compression\"\n+        provenance[\"creatorKind\"] = \"compression\"\n+\n+    return provenance\n+\n+\n+def session_provenance_meta(\n+    db: Any,\n+    acp_session_id: str,\n+    current_hermes_session_id: str,\n+    *,\n+    previous_hermes_session_id: Optional[str] = None,\n+) -> Optional[Dict[str, Any]]:\n+    \"\"\"Return a ready ``_meta`` payload: ``{\"hermes\": {\"sessionProvenance\": ...}}``.\"\"\"\n+    prov = build_session_provenance(\n+        db,\n+        acp_session_id,\n+        current_hermes_session_id,\n+        previous_hermes_session_id=previous_hermes_session_id,\n+    )\n+    if prov is None:\n+        return None\n+    return {\"hermes\": {\"sessionProvenance\": prov}}"]},{"file":"acp_adapter/server.py","hunks":["@@ -71,6 +71,7 @@\n     make_tool_progress_cb,\n )\n from acp_adapter.permissions import make_approval_callback\n+from acp_adapter.provenance import session_provenance_meta\n from acp_adapter.session import SessionManager, SessionState, _expand_acp_enabled_toolsets\n from acp_adapter.tools import build_tool_complete, build_tool_start\n ","@@ -709,8 +710,39 @@ async def _send_usage_update(self, state: SessionState) -> None:\n                 exc_info=True,\n             )\n \n-    async def _send_session_info_update(self, session_id: str) -> None:\n-        \"\"\"Send ACP native session metadata after Hermes changes it.\"\"\"\n+    def _provenance_meta(\n+        self,\n+        acp_session_id: str,\n+        current_hermes_session_id: str,\n+        previous_hermes_session_id: Optional[str] = None,\n+    ) -> Optional[dict]:\n+        \"\"\"Best-effort ``_meta.hermes.sessionProvenance`` for an ACP session.\"\"\"\n+        try:\n+            return session_provenance_meta(\n+                self.session_manager._get_db(),\n+                acp_session_id,\n+                current_hermes_session_id,\n+                previous_hermes_session_id=previous_hermes_session_id,\n+            )\n+        except Exception:\n+            logger.debug(\n+                \"Could not build ACP session provenance for %s\", acp_session_id, exc_info=True\n+            )\n+            return None\n+\n+    async def _send_session_info_update(\n+        self,\n+        session_id: str,\n+        *,\n+        current_hermes_session_id: Optional[str] = None,\n+        previous_hermes_session_id: Optional[str] = None,\n+    ) -> None:\n+        \"\"\"Send ACP native session metadata after Hermes changes it.\n+\n+        When the internal Hermes head rotated (e.g. compression-driven session\n+        split during a turn), pass ``previous_hermes_session_id`` so the\n+        attached ``_meta.hermes.sessionProvenance`` flags the rotation reason.\n+        \"\"\"\n         if not self._conn:\n             return\n         try:","@@ -727,10 +759,16 @@ async def _send_session_info_update(self, session_id: str) -> None:\n         # the updated_at since we're emitting this notification precisely\n         # because the title was just refreshed.\n         updated_at = datetime.now(timezone.utc).isoformat()\n+        meta = self._provenance_meta(\n+            session_id,\n+            current_hermes_session_id or session_id,\n+            previous_hermes_session_id,\n+        )\n         update = SessionInfoUpdate(\n             session_update=\"session_info_update\",\n             title=title if isinstance(title, str) and title.strip() else None,\n             updated_at=updated_at,\n+            field_meta=meta,\n         )\n         try:\n             await self._conn.session_update(","@@ -1081,6 +1119,9 @@ async def new_session(\n             session_id=state.session_id,\n             models=self._build_model_state(state),\n             modes=self._session_modes(state),\n+            field_meta=self._provenance_meta(\n+                state.session_id, getattr(state.agent, \"session_id\", state.session_id)\n+            ),\n         )\n \n     async def load_session(","@@ -1125,6 +1166,9 @@ async def load_session(\n         return LoadSessionResponse(\n             models=self._build_model_state(state),\n             modes=self._session_modes(state),\n+            field_meta=self._provenance_meta(\n+                session_id, getattr(state.agent, \"session_id\", session_id)\n+            ),\n         )\n \n     async def resume_session(","@@ -1157,6 +1201,9 @@ async def resume_session(\n         return ResumeSessionResponse(\n             models=self._build_model_state(state),\n             modes=self._session_modes(state),\n+            field_meta=self._provenance_meta(\n+                state.session_id, getattr(state.agent, \"session_id\", state.session_id)\n+            ),\n         )\n \n     async def cancel(self, session_id: str, **kwargs: Any) -> None:","@@ -1494,6 +1541,11 @@ def _run_agent() -> dict:\n                         logger.debug(\"Could not clear ACP session context\", exc_info=True)\n \n         try:\n+            # Snapshot the internal Hermes DB session id before the turn so we\n+            # can detect a compression-driven session rotation afterwards. The\n+            # ACP `session_id` stays the stable client handle; agent.session_id\n+            # is the live internal head that compression may rotate.\n+            pre_turn_hermes_id = getattr(state.agent, \"session_id\", None)\n             # Wrap the executor call in a fresh copy of the current context so\n             # concurrent ACP sessions on the shared ThreadPoolExecutor don't\n             # stomp on each other's ContextVar writes (HERMES_SESSION_KEY in","@@ -1512,6 +1564,30 @@ def _run_agent() -> dict:\n             # Persist updated history so sessions survive process restarts.\n             self.session_manager.save_session(session_id)\n \n+        # Detect a compression-driven internal session rotation. If the agent's\n+        # DB head moved during the turn, emit a session_info_update carrying\n+        # _meta.hermes.sessionProvenance so ACP clients can render the boundary\n+        # and keep old/new ids in lineage. The ACP session_id is unchanged.\n+        post_turn_hermes_id = getattr(state.agent, \"session_id\", None)\n+        if (\n+            conn\n+            and post_turn_hermes_id\n+            and pre_turn_hermes_id\n+            and post_turn_hermes_id != pre_turn_hermes_id\n+        ):\n+            try:\n+                await self._send_session_info_update(\n+                    session_id,\n+                    current_hermes_session_id=post_turn_hermes_id,\n+                    previous_hermes_session_id=pre_turn_hermes_id,\n+                )\n+            except Exception:\n+                logger.debug(\n+                    \"Could not emit ACP provenance update after rotation for %s\",\n+                    session_id,\n+                    exc_info=True,\n+                )\n+\n         final_response = result.get(\"final_response\", \"\")\n         if final_response:\n             try:"]},{"file":"tests/acp/test_session_provenance.py","hunks":["@@ -0,0 +1,103 @@\n+\"\"\"Tests for ACP session-provenance derivation (issue #33617).\n+\n+Exercises acp_adapter.provenance against a real SessionDB — no mocks — covering\n+the acceptance-criteria matrix: root session, compression-split continuation,\n+multi-depth chains, rotation flagging, and graceful handling of unknown ids.\n+\"\"\"\n+\n+import time\n+\n+import pytest\n+\n+from acp_adapter.provenance import build_session_provenance, session_provenance_meta\n+from hermes_state import SessionDB\n+\n+\n[redacted:email]()\n+def db(tmp_path):\n+    d = SessionDB(db_path=tmp_path / \"state.db\")\n+    yield d\n+\n+\n+def _mk(db, sid, parent=None):\n+    db.create_session(session_id=sid, source=\"acp\", parent_session_id=parent)\n+\n+\n+def test_root_session_no_compression(db):\n+    _mk(db, \"root1\")\n+    prov = build_session_provenance(db, \"acp-1\", \"root1\")\n+    assert prov[\"acpSessionId\"] == \"acp-1\"\n+    assert prov[\"currentHermesSessionId\"] == \"root1\"\n+    assert prov[\"rootHermesSessionId\"] == \"root1\"\n+    assert prov[\"parentHermesSessionId\"] is None\n+    assert prov[\"sessionKind\"] == \"root\"\n+    assert prov[\"compressionDepth\"] == 0\n+    assert \"reason\" not in prov  # no rotation signalled\n+\n+\n+def test_compression_split_continuation(db):\n+    # Parent ended with compression, child created afterwards.\n+    _mk(db, \"old\")\n+    db.end_session(\"old\", \"compression\")\n+    time.sleep(0.001)\n+    _mk(db, \"new\", parent=\"old\")\n+\n+    prov = build_session_provenance(\n+        db, \"acp-1\", \"new\", previous_hermes_session_id=\"old\"\n+    )\n+    assert prov[\"sessionKind\"] == \"continuation\"\n+    assert prov[\"parentHermesSessionId\"] == \"old\"\n+    assert prov[\"rootHermesSessionId\"] == \"old\"\n+    assert prov[\"compressionDepth\"] == 1\n+    assert prov[\"previousHermesSessionId\"] == \"old\"\n+    # Head rotated this turn → reason/creatorKind flagged.\n+    assert prov[\"reason\"] == \"compression\"\n+    assert prov[\"creatorKind\"] == \"compression\"\n+\n+\n+def test_multi_depth_chain(db):\n+    _mk(db, \"s0\")\n+    db.end_session(\"s0\", \"compression\")\n+    _mk(db, \"s1\", parent=\"s0\")\n+    db.end_session(\"s1\", \"compression\")\n+    _mk(db, \"s2\", parent=\"s1\")\n+\n+    prov = build_session_provenance(db, \"acp-1\", \"s2\")\n+    assert prov[\"rootHermesSessionId\"] == \"s0\"\n+    assert prov[\"compressionDepth\"] == 2\n+    assert prov[\"sessionKind\"] == \"continuation\"\n+\n+\n+def test_non_compression_parent_is_root_not_continuation(db):\n+    # A child with a parent that did NOT end via compression (e.g. delegate\n+    # or branch child) must not be reported as a compression continuation.\n+    _mk(db, \"p\")\n+    _mk(db, \"c\", parent=\"p\")  # parent still live, no end_reason\n+    prov = build_session_provenance(db, \"acp-1\", \"c\")\n+    assert prov[\"sessionKind\"] == \"root\"\n+    assert prov[\"compressionDepth\"] == 0\n+    assert prov[\"rootHermesSessionId\"] == \"p\"  # lineage root still walked\n+\n+\n+def test_no_false_rotation_when_head_unchanged(db):\n+    _mk(db, \"s\")\n+    # previous == current → no rotation reason emitted.\n+    prov = build_session_provenance(\n+        db, \"acp-1\", \"s\", previous_hermes_session_id=\"s\"\n+    )\n+    assert \"reason\" not in prov\n+    assert \"creatorKind\" not in prov\n+    assert prov[\"previousHermesSessionId\"] == \"s\"\n+\n+\n+def test_unknown_session_returns_none(db):\n+    assert build_session_provenance(db, \"acp-1\", \"does-not-exist\") is None\n+    assert session_provenance_meta(db, \"acp-1\", \"does-not-exist\") is None\n+\n+\n+def test_meta_wrapper_shape(db):\n+    _mk(db, \"root1\")\n+    meta = session_provenance_meta(db, \"acp-1\", \"root1\")\n+    assert set(meta.keys()) == {\"hermes\"}\n+    assert \"sessionProvenance\" in meta[\"hermes\"]\n+    assert meta[\"hermes\"][\"sessionProvenance\"][\"currentHermesSessionId\"] == \"root1\"\n"]}]},{"kind":"pr","number":54238,"title":"Hide kanban worker sessions from Desktop recents","url":null,"state":"open","close_reason":{"refs":[{"kind":"commit","value":"25c7900fb","number":0}],"category":"uncategorized","implication":"needs_llm"},"merged":false},{"kind":"pr","number":57529,"title":"fix(api-server): add exclude_source query param to session listing","url":null,"state":"open","close_reason":{"category":"uncategorized","implication":"needs_llm"},"merged":false}],"trust":{"default_tier":"verified","includes_suggested":false}}