# NousResearch/hermes-agent · Filter and classify sessions by origin or type

## Open now

- 6 issues, 3 PRs open on GitHub.
- #17926: Expose canonical user-visible session classification for dashboards
- #23114: feat(cli): separate cron sessions from regular sessions in \`hermes sessions list\`
- #28412: \[Feature\]: persist session kind/provenance metadata
- #39372: Background/integration agent runs should not pollute user-visible session lists
- #49991: Kanban worker sessions appear in owner-facing Desktop history
- #57527: API Server: filter out cron sessions from session listing
- PR #39506: \[codex\] Hide internal agent sessions by default
- PR #54238: Hide kanban worker sessions from Desktop recents
- PR #57529: fix(api-server): add exclude_source query param to session listing

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.

## TL;DR

- 13 member(s): 6 open issue(s), 1 closed issue(s), 3 open PR(s), 1 merged PR(s), 2 PR(s) closed without merging.
- A fix was merged to main in PR #41724. Verify it addresses your case.
- **Recommended:** read PR #41724 (and any competing PRs below) before filing. Confirm the merged change covers your symptom.

## Maintainer resolution verdict

- Internal-tracker verdict over this cluster's issue members: `unresolved`. This is distinct from the fix status above. Weigh it yourself.

## How this cluster was grouped

- Link evidence (`STRUCTURAL_PARTIAL`): some members are connected by structural evidence (shared changed files, or a closing/superseded reference); the rest are joined by an automated relevance judgement over their text.
- Members outside that structural core may belong to a different underlying problem.
- Pairwise link density `0.10`: the share of member 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.

## Open related issues

- #17926: Expose canonical user-visible session classification for dashboards
- #23114: feat(cli): separate cron sessions from regular sessions in \`hermes sessions list\`
- #28412: \[Feature\]: persist session kind/provenance metadata
- #39372: Background/integration agent runs should not pollute user-visible session lists
- #49991: Kanban worker sessions appear in owner-facing Desktop history
- #57527: API Server: filter out cron sessions from session listing

## Closed / duplicate issues

- #38894: \[Feature\]: Separate cron/autonomous sessions from manual chats in Desktop

## Prior & competing PRs

- **#39362: feat(desktop): manual-only session filter in sidebar**. closed without merge · fix tier `suggested`
  - maintainer direction: category `superseded_by_pr`; implication `superseded`; refs `628f9040df438182578381a4fc72e4044509d5bd`, `#38894`
- **#41724: feat(acp): emit session provenance metadata for compression rotation**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`; implication `superseded`
  - files: `acp_adapter/provenance.py`, `acp_adapter/server.py`, `tests/acp/test_session_provenance.py`
  - fix hunks:
  - `acp_adapter/provenance.py`

    ```diff
    @@ -0,0 +1,127 @@
    +"""Derive ACP session-provenance metadata from the existing compression chain.
    +
    +This is an additive Hermes extension surfaced under ACP ``_meta.hermes`` so
    +existing ACP clients ignore it. It carries no new persisted state: everything
    +is derived on demand from the ``sessions`` table (``parent_session_id`` /
    +``end_reason``), which already models compression-continuation chains.
    +
    +The ACP/editor ``session_id`` stays the stable public handle. When context
    +compression rotates the internal Hermes head, ``build_session_provenance`` lets
    +a client see the previous/current internal ids and the lineage root without
    +parsing status text, guessing from token drops, or reading ``state.db``.
    +"""
    +
    +from __future__ import annotations
    +
    +from typing import Any, Dict, Optional
    +
    +# Bound defensive walks; compression chains this deep are pathological.
    +_MAX_WALK = 100
    +
    +
    +def build_session_provenance(
    +    db: Any,
    +    acp_session_id: str,
    +    current_hermes_session_id: str,
    +    *,
    +    previous_hermes_session_id: Optional[str] = None,
    +) -> Optional[Dict[str, Any]]:
    +    """Build ``_meta.hermes.sessionProvenance`` for an ACP session.
    +
    +    Args:
    +        db: A ``SessionDB`` (must expose ``get_session``).
    +        acp_session_id: The stable ACP/editor-facing session handle.
    +        current_hermes_session_id: The live internal Hermes DB session id
    +            (``state.agent.session_id``).
    +        previous_hermes_session_id: The internal id from before the most recent
    +            turn, when known. Supplied by ``prompt()`` to flag a rotation.
    +
    +    Returns:
    +        A dict suitable for ``{"hermes": {"sessionProvenance": <dict>}}`` under
    +        ACP ``_meta``, or ``None`` if the session can't be read.
    +    """
    +    try:
    +        row = db.get_session(current_hermes_session_id)
    +    except Exception:
    +        return None
    +    if not row:
    +        return None
    +
    +    parent_id = row.get("parent_session_id")
    +    end_reason = row.get("end_reason")
    +
    +    # Walk parents to the lineage root and count compression depth. Only
    +    # compression-split parents (parent.end_reason == 'compression') count
    +    # toward depth — delegate/branch children share the parent_session_id
    +    # column but are not compaction boundaries.
    +    root_id = current_hermes_session_id
    +    compression_depth = 0
    +    cursor_parent = parent_id
    +    seen = {current_hermes_session_id}
    +    for _ in range(_MAX_WALK):
    +        if not cursor_parent or cursor_parent in seen:
    +            break
    +        seen.add(cursor_parent)
    +        try:
    +            prow = db.get_session(cursor_parent)
    +        except Exception:
    +            prow = None
    +        if not prow:
    +            break
    +        root_id = cursor_parent
    +        if prow.get("end_reason") == "compression":
    +            compression_depth += 1
    +        cursor_parent = prow.get("parent_session_id")
    +
    +    # A session is a compression continuation when its parent was ended with
    +    # end_reason='compression'. Determine that from the immediate parent.
    +    is_continuation = False
    +    if parent_id:
    +        try:
    +            immediate_parent = db.get_session(parent_id)
    +        except Exception:
    +            immediate_parent = None
    +        if immediate_parent and immediate_parent.get("end_reason") == "compression":
    +            is_continuation = True
    +
    +    rotated = bool(
    +        previous_hermes_session_id
    +        and previous_hermes_session_id != current_hermes_session_id
    +    )
    +
    +    provenance: Dict[str, Any] = {
    +        "acpSessionId": acp_session_id,
    +        "currentHermesSessionId": current_hermes_session_id,
    +        "rootHermesSessionId": root_id,
    +        "parentHermesSessionId": parent_id,
    +        "sessionKind": "continuation" if is_continuation else "root",
    +        "compressionDepth": compression_depth,
    +    }
    +    if previous_hermes_session_id:
    +        provenance["previousHermesSessionId"] = previous_hermes_session_id
    +    if rotated:
    +        # The head moved during the last turn. The only mechanism that rotates
    +        # the internal id mid-turn is compression-driven session splitting.
    +        provenance["reason"] = "compression"
    +        provenance["creatorKind"] = "compression"
    +
    +    return provenance
    +
    +
    +def session_provenance_meta(
    +    db: Any,
    +    acp_session_id: str,
    +    current_hermes_session_id: str,
    +    *,
    +    previous_hermes_session_id: Optional[str] = None,
    +) -> Optional[Dict[str, Any]]:
    +    """Return a ready ``_meta`` payload: ``{"hermes": {"sessionProvenance": ...}}``."""
    +    prov = build_session_provenance(
    +        db,
    +        acp_session_id,
    +        current_hermes_session_id,
    +        previous_hermes_session_id=previous_hermes_session_id,
    +    )
    +    if prov is None:
    +        return None
    +    return {"hermes": {"sessionProvenance": prov}}
    ```
  - `acp_adapter/server.py`

    ```diff
    @@ -71,6 +71,7 @@
         make_tool_progress_cb,
     )
     from acp_adapter.permissions import make_approval_callback
    +from acp_adapter.provenance import session_provenance_meta
     from acp_adapter.session import SessionManager, SessionState, _expand_acp_enabled_toolsets
     from acp_adapter.tools import build_tool_complete, build_tool_start
     
    ```

    ```diff
    @@ -709,8 +710,39 @@ async def _send_usage_update(self, state: SessionState) -> None:
                     exc_info=True,
                 )
     
    -    async def _send_session_info_update(self, session_id: str) -> None:
    -        """Send ACP native session metadata after Hermes changes it."""
    +    def _provenance_meta(
    +        self,
    +        acp_session_id: str,
    +        current_hermes_session_id: str,
    +        previous_hermes_session_id: Optional[str] = None,
    +    ) -> Optional[dict]:
    +        """Best-effort ``_meta.hermes.sessionProvenance`` for an ACP session."""
    +        try:
    +            return session_provenance_meta(
    +                self.session_manager._get_db(),
    +                acp_session_id,
    +                current_hermes_session_id,
    +                previous_hermes_session_id=previous_hermes_session_id,
    +            )
    +        except Exception:
    +            logger.debug(
    +                "Could not build ACP session provenance for %s", acp_session_id, exc_info=True
    +            )
    +            return None
    +
    +    async def _send_session_info_update(
    +        self,
    +        session_id: str,
    +        *,
    +        current_hermes_session_id: Optional[str] = None,
    +        previous_hermes_session_id: Optional[str] = None,
    +    ) -> None:
    +        """Send ACP native session metadata after Hermes changes it.
    +
    +        When the internal Hermes head rotated (e.g. compression-driven session
    +        split during a turn), pass ``previous_hermes_session_id`` so the
    +        attached ``_meta.hermes.sessionProvenance`` flags the rotation reason.
    +        """
             if not self._conn:
                 return
             try:
    ```

    ```diff
    @@ -727,10 +759,16 @@ async def _send_session_info_update(self, session_id: str) -> None:
             # the updated_at since we're emitting this notification precisely
             # because the title was just refreshed.
             updated_at = datetime.now(timezone.utc).isoformat()
    +        meta = self._provenance_meta(
    +            session_id,
    +            current_hermes_session_id or session_id,
    +            previous_hermes_session_id,
    +        )
             update = SessionInfoUpdate(
                 session_update="session_info_update",
                 title=title if isinstance(title, str) and title.strip() else None,
                 updated_at=updated_at,
    +            field_meta=meta,
             )
             try:
                 await self._conn.session_update(
    ```

    ```diff
    @@ -1081,6 +1119,9 @@ async def new_session(
                 session_id=state.session_id,
                 models=self._build_model_state(state),
                 modes=self._session_modes(state),
    +            field_meta=self._provenance_meta(
    +                state.session_id, getattr(state.agent, "session_id", state.session_id)
    +            ),
             )
     
         async def load_session(
    ```

    ```diff
    @@ -1125,6 +1166,9 @@ async def load_session(
             return LoadSessionResponse(
                 models=self._build_model_state(state),
                 modes=self._session_modes(state),
    +            field_meta=self._provenance_meta(
    +                session_id, getattr(state.agent, "session_id", session_id)
    +            ),
             )
     
         async def resume_session(
    ```

    ```diff
    @@ -1157,6 +1201,9 @@ async def resume_session(
             return ResumeSessionResponse(
                 models=self._build_model_state(state),
                 modes=self._session_modes(state),
    +            field_meta=self._provenance_meta(
    +                state.session_id, getattr(state.agent, "session_id", state.session_id)
    +            ),
             )
     
         async def cancel(self, session_id: str, **kwargs: Any) -> None:
    ```

    ```diff
    @@ -1494,6 +1541,11 @@ def _run_agent() -> dict:
                             logger.debug("Could not clear ACP session context", exc_info=True)
     
             try:
    +            # Snapshot the internal Hermes DB session id before the turn so we
    +            # can detect a compression-driven session rotation afterwards. The
    +            # ACP `session_id` stays the stable client handle; agent.session_id
    +            # is the live internal head that compression may rotate.
    +            pre_turn_hermes_id = getattr(state.agent, "session_id", None)
                 # Wrap the executor call in a fresh copy of the current context so
                 # concurrent ACP sessions on the shared ThreadPoolExecutor don't
                 # stomp on each other's ContextVar writes (HERMES_SESSION_KEY in
    ```

    ```diff
    @@ -1512,6 +1564,30 @@ def _run_agent() -> dict:
                 # Persist updated history so sessions survive process restarts.
                 self.session_manager.save_session(session_id)
     
    +        # Detect a compression-driven internal session rotation. If the agent's
    +        # DB head moved during the turn, emit a session_info_update carrying
    +        # _meta.hermes.sessionProvenance so ACP clients can render the boundary
    +        # and keep old/new ids in lineage. The ACP session_id is unchanged.
    +        post_turn_hermes_id = getattr(state.agent, "session_id", None)
    +        if (
    +            conn
    +            and post_turn_hermes_id
    +            and pre_turn_hermes_id
    +            and post_turn_hermes_id != pre_turn_hermes_id
    +        ):
    +            try:
    +                await self._send_session_info_update(
    +                    session_id,
    +                    current_hermes_session_id=post_turn_hermes_id,
    +                    previous_hermes_session_id=pre_turn_hermes_id,
    +                )
    +            except Exception:
    +                logger.debug(
    +                    "Could not emit ACP provenance update after rotation for %s",
    +                    session_id,
    +                    exc_info=True,
    +                )
    +
             final_response = result.get("final_response", "")
             if final_response:
                 try:
    ```
  - `tests/acp/test_session_provenance.py`

    ```diff
    @@ -0,0 +1,103 @@
    +"""Tests for ACP session-provenance derivation (issue #33617).
    +
    +Exercises acp_adapter.provenance against a real SessionDB — no mocks — covering
    +the acceptance-criteria matrix: root session, compression-split continuation,
    +multi-depth chains, rotation flagging, and graceful handling of unknown ids.
    +"""
    +
    +import time
    +
    +import pytest
    +
    +from acp_adapter.provenance import build_session_provenance, session_provenance_meta
    +from hermes_state import SessionDB
    +
    +
    [redacted:email]()
    +def db(tmp_path):
    +    d = SessionDB(db_path=tmp_path / "state.db")
    +    yield d
    +
    +
    +def _mk(db, sid, parent=None):
    +    db.create_session(session_id=sid, source="acp", parent_session_id=parent)
    +
    +
    +def test_root_session_no_compression(db):
    +    _mk(db, "root1")
    +    prov = build_session_provenance(db, "acp-1", "root1")
    +    assert prov["acpSessionId"] == "acp-1"
    +    assert prov["currentHermesSessionId"] == "root1"
    +    assert prov["rootHermesSessionId"] == "root1"
    +    assert prov["parentHermesSessionId"] is None
    +    assert prov["sessionKind"] == "root"
    +    assert prov["compressionDepth"] == 0
    +    assert "reason" not in prov  # no rotation signalled
    +
    +
    +def test_compression_split_continuation(db):
    +    # Parent ended with compression, child created afterwards.
    +    _mk(db, "old")
    +    db.end_session("old", "compression")
    +    time.sleep(0.001)
    +    _mk(db, "new", parent="old")
    +
    +    prov = build_session_provenance(
    +        db, "acp-1", "new", previous_hermes_session_id="old"
    +    )
    +    assert prov["sessionKind"] == "continuation"
    +    assert prov["parentHermesSessionId"] == "old"
    +    assert prov["rootHermesSessionId"] == "old"
    +    assert prov["compressionDepth"] == 1
    +    assert prov["previousHermesSessionId"] == "old"
    +    # Head rotated this turn → reason/creatorKind flagged.
    +    assert prov["reason"] == "compression"
    +    assert prov["creatorKind"] == "compression"
    +
    +
    +def test_multi_depth_chain(db):
    +    _mk(db, "s0")
    +    db.end_session("s0", "compression")
    +    _mk(db, "s1", parent="s0")
    +    db.end_session("s1", "compression")
    +    _mk(db, "s2", parent="s1")
    +
    +    prov = build_session_provenance(db, "acp-1", "s2")
    +    assert prov["rootHermesSessionId"] == "s0"
    +    assert prov["compressionDepth"] == 2
    +    assert prov["sessionKind"] == "continuation"
    +
    +
    +def test_non_compression_parent_is_root_not_continuation(db):
    +    # A child with a parent that did NOT end via compression (e.g. delegate
    +    # or branch child) must not be reported as a compression continuation.
    +    _mk(db, "p")
    +    _mk(db, "c", parent="p")  # parent still live, no end_reason
    +    prov = build_session_provenance(db, "acp-1", "c")
    +    assert prov["sessionKind"] == "root"
    +    assert prov["compressionDepth"] == 0
    +    assert prov["rootHermesSessionId"] == "p"  # lineage root still walked
    +
    +
    +def test_no_false_rotation_when_head_unchanged(db):
    +    _mk(db, "s")
    +    # previous == current → no rotation reason emitted.
    +    prov = build_session_provenance(
    +        db, "acp-1", "s", previous_hermes_session_id="s"
    +    )
    +    assert "reason" not in prov
    +    assert "creatorKind" not in prov
    +    assert prov["previousHermesSessionId"] == "s"
    +
    +
    +def test_unknown_session_returns_none(db):
    +    assert build_session_provenance(db, "acp-1", "does-not-exist") is None
    +    assert session_provenance_meta(db, "acp-1", "does-not-exist") is None
    +
    +
    +def test_meta_wrapper_shape(db):
    +    _mk(db, "root1")
    +    meta = session_provenance_meta(db, "acp-1", "root1")
    +    assert set(meta.keys()) == {"hermes"}
    +    assert "sessionProvenance" in meta["hermes"]
    +    assert meta["hermes"]["sessionProvenance"]["currentHermesSessionId"] == "root1"
    ```
- **#30069: feat: persist session provenance and correlate delegate children**. closed without merge
  - maintainer direction: category `implemented_elsewhere`; implication `superseded`; refs `777dc9da6`, `#41724`
- **#39506: \[codex\] Hide internal agent sessions by default**. open
  - maintainer direction: category `uncategorized`; implication `needs_llm`
  - files: `agent/agent_init.py`, `agent/conversation_compression.py`, `apps/desktop/src/types/hermes.ts`, `cli.py`, `cron/scheduler.py`, `gateway/platforms/api_server.py`, `hermes_cli/_parser.py`, `hermes_cli/main.py`, `hermes_cli/oneshot.py`, `hermes_cli/web_server.py`, `hermes_state.py`, `run_agent.py`, `tests/hermes_cli/test_web_server.py`, `tests/hermes_cli/test_web_server_session_search.py`, `tests/run_agent/test_session_id_env.py`, `tests/test_hermes_state.py`
- **#54238: Hide kanban worker sessions from Desktop recents**. open
  - maintainer direction: category `uncategorized`; implication `needs_llm`; refs `25c7900fb`
  - files: `apps/desktop/src/app/desktop-controller.tsx`, `hermes_cli/kanban_db.py`, `tests/hermes_cli/test_kanban_db.py`
- **#57529: fix(api-server): add exclude_source query param to session listing**. open
  - maintainer direction: category `uncategorized`; implication `needs_llm`
  - files: `gateway/platforms/api_server.py`, `tests/gateway/test_session_api.py`

## Fix status

- Merged to main in PR #41724 (cited above). No release data is tracked. Verify downstream.
- Fix last verified (merged) `2026-06-08`.
- Subsystem: `agent`
