From fbaea9bddc72c705527f3532fedda88d8e3b52a2 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sat, 15 Aug 2026 00:31:37 -0700 Subject: [PATCH] feat(sessions): generic 'hidden' session flag (sidebar-hide, still resumable) (#86797) * feat(sessions): generic 'hidden' session flag (sidebar-hide, still resumable) Adds a source-orthogonal, archive-orthogonal 'hidden' session flag meaning 'don't show in the global Sessions sidebar, but stay fully resumable by the surface that owns it'. Mirrors the existing archived/pinned capability end to end, so it's a generic widening (any plugin that owns its own session lifecycle - kanban, Bot Mode, future plugins - can keep its sessions out of the shared recents list) rather than a per-plugin special-case. - Schema: hidden INTEGER NOT NULL DEFAULT 0 on sessions (additive; lands on existing DBs via the declarative _reconcile_columns ADD COLUMN path, same as archived/pinned - no version-gated migration). - DB: SessionDB.set_session_hidden(session_id, hidden) (clones set_session_pinned incl. the compression-lineage recursive CTE); list_sessions_rich gains include_hidden=False, appending 's.hidden = 0' by default so hidden rows drop from every listing path (and the REST sidebar endpoints inherit it with no change). - Gateway: session.set_hidden RPC (mirrors session.title); session.create accepts hidden=true, deferred via pending_hidden and applied in _ensure_session_db_row when the row is lazily created (mirrors pending_title). - REST parity: PATCH /api/sessions/{id} accepts+bool-validates 'hidden' -> set_session_hidden; _session_response exposes it. Enables Hermes-Bot-Mode to hide canonical 'Bot Chat' sessions from the sidebar (NousResearch/Hermes-Bot-Mode#46) WITHOUT retagging source (which would mis-set the agent platform). Bot Chats keep source=desktop. Gateway RPC needs a SERVE-backend restart to take effect live. 1 focused test (default-exclude / include_hidden / unhide round-trip). * fix: teach lost-and-found recovery about the 55-column sessions layout Adding the 'hidden' column makes the current sessions table 55 columns. The SQLite lost-and-found recovery classifier keys off the physical field count (SESSIONS_LAYOUT_NFIELDS) to identify a salvaged sessions row, so a recovered current-layout row (nfield=55) would otherwise be unrecognized and dropped. Add 55 to the frozenset (54/52 stay as historical prefixes) and update the column-count assertions + synthetic current-layout insert in the recovery test. --------- Co-authored-by: Teknium --- gateway/platforms/api_server.py | 10 ++-- hermes_cli/session_lost_and_found.py | 2 +- hermes_state.py | 57 +++++++++++++++++++ hermes_state_common.py | 1 + .../test_session_recovery_lost_and_found.py | 8 +-- tests/hermes_state/test_session_hidden.py | 44 ++++++++++++++ tui_gateway/methods_session.py | 32 +++++++++++ tui_gateway/server.py | 8 +++ 8 files changed, 153 insertions(+), 9 deletions(-) create mode 100644 tests/hermes_state/test_session_hidden.py diff --git a/gateway/platforms/api_server.py b/gateway/platforms/api_server.py index 841d7609fe..4fc3b1a91b 100644 --- a/gateway/platforms/api_server.py +++ b/gateway/platforms/api_server.py @@ -3308,11 +3308,11 @@ class APIServerAdapter(BasePlatformAdapter): "output_tokens", "cache_read_tokens", "cache_write_tokens", "reasoning_tokens", "estimated_cost_usd", "actual_cost_usd", "api_call_count", "parent_session_id", "last_active", "preview", - "_lineage_root_id", "pinned", "archived", + "_lineage_root_id", "pinned", "archived", "hidden", ) payload = {key: session.get(key) for key in safe_keys if key in session} # SQLite stores these as 0/1; clients reconcile against a real boolean. - for flag in ("pinned", "archived"): + for flag in ("pinned", "archived", "hidden"): if flag in payload: payload[flag] = bool(payload[flag]) # Avoid exposing full system prompts/model_config through the client API; @@ -3534,12 +3534,12 @@ class APIServerAdapter(BasePlatformAdapter): # sidebar owns (the "keep" flag exempts a chat from the auto-archive # sweep). Rejecting them here was silently 400ing every pin the desktop # made, so pins only ever lived in that one app's localStorage. - allowed = {"title", "end_reason", "pinned", "archived"} + allowed = {"title", "end_reason", "pinned", "archived", "hidden"} unknown = sorted(set(body) - allowed) if unknown: return web.json_response(_openai_error(f"Unsupported session fields: {', '.join(unknown)}", code="unsupported_session_field"), status=400) - for flag in ("pinned", "archived"): + for flag in ("pinned", "archived", "hidden"): if flag in body and not isinstance(body[flag], bool): return web.json_response(_openai_error(f"'{flag}' must be a boolean", code="invalid_session_field"), status=400) @@ -3553,6 +3553,8 @@ class APIServerAdapter(BasePlatformAdapter): await asyncio.to_thread(db.set_session_pinned, session_id, body["pinned"]) if "archived" in body: await asyncio.to_thread(db.set_session_archived, session_id, body["archived"]) + if "hidden" in body: + await asyncio.to_thread(db.set_session_hidden, session_id, body["hidden"]) if body.get("end_reason"): await asyncio.to_thread(db.end_session, session_id, str(body["end_reason"])) session = await asyncio.to_thread(db.get_session, session_id) or session diff --git a/hermes_cli/session_lost_and_found.py b/hermes_cli/session_lost_and_found.py index 9b7fd81bde..90d8acba9a 100644 --- a/hermes_cli/session_lost_and_found.py +++ b/hermes_cli/session_lost_and_found.py @@ -47,7 +47,7 @@ KNOWN_SOURCES = frozenset({ # Historical physical layouts of the sessions table. Columns are only ever # appended (ALTER TABLE ADD COLUMN), so an older record is a strict prefix of # the current column order. -SESSIONS_LAYOUT_NFIELDS = frozenset({54, 52}) +SESSIONS_LAYOUT_NFIELDS = frozenset({55, 54, 52}) SESSIONS_LEGACY_MINIMAL_NFIELD = 14 SESSION_MODEL_USAGE_NFIELD = 18 diff --git a/hermes_state.py b/hermes_state.py index 3711e91f49..3a4459f275 100644 --- a/hermes_state.py +++ b/hermes_state.py @@ -7599,6 +7599,60 @@ class SessionDB(SessionSearchMixin, SessionSchemaMixin, SessionPortabilityMixin) rowcount = self._execute_write(_do) return rowcount > 0 + def set_session_hidden(self, session_id: str, hidden: bool) -> bool: + """Hide or unhide a session (and its whole compression lineage). + + ``hidden`` is a generic "don't show in the global Sessions sidebar" + flag: a hidden session is dropped from the default + :meth:`list_sessions_rich` listing (which omits ``include_hidden``) but + stays fully resumable by the surface that owns it — useful for plugins + that manage their own sessions (e.g. kanban) and don't want them + cluttering the shared recents list. Like :meth:`set_session_archived` + / :meth:`set_session_pinned` the whole compression chain is flipped as + a unit, so hiding the surfaced tip hides the root (and vice-versa) no + matter which id the caller holds. Returns True when at least one row + changed. + """ + def _do(conn): + cursor = conn.execute( + """ + WITH RECURSIVE + ancestors(id) AS ( + SELECT ? + UNION + SELECT parent.id + FROM ancestors a + JOIN sessions child ON child.id = a.id + JOIN sessions parent ON parent.id = child.parent_session_id + WHERE parent.end_reason = 'compression' + ), + descendants(id) AS ( + SELECT ? + UNION + SELECT child.id + FROM descendants d + JOIN sessions parent ON parent.id = d.id + JOIN sessions child ON child.parent_session_id = parent.id + WHERE parent.end_reason = 'compression' + ), + lineage(id) AS ( + SELECT id FROM ancestors + UNION + SELECT id FROM descendants + ) + UPDATE sessions + SET hidden = ? + WHERE id IN (SELECT id FROM lineage) + """, + (session_id, session_id, 1 if hidden else 0), + ) + rowcount = cursor.rowcount + if rowcount is None or rowcount < 0: + rowcount = conn.execute("SELECT changes()").fetchone()[0] + return rowcount + rowcount = self._execute_write(_do) + return rowcount > 0 + def set_session_read(self, session_id: str, read: bool = True) -> bool: """Mark a session read or unread (and its whole compression lineage). @@ -7870,6 +7924,7 @@ class SessionDB(SessionSearchMixin, SessionSchemaMixin, SessionPortabilityMixin) compact_rows: bool = False, include_pinned: bool = False, session_key: str = None, + include_hidden: bool = False, ) -> List[Dict[str, Any]]: """List sessions with preview (first user message) and last active timestamp. @@ -7972,6 +8027,8 @@ class SessionDB(SessionSearchMixin, SessionSchemaMixin, SessionPortabilityMixin) where_clauses.append("s.archived = 1") elif not include_archived: where_clauses.append("s.archived = 0") + if not include_hidden: + where_clauses.append("s.hidden = 0") where_sql = f"WHERE {' AND '.join(where_clauses)}" if where_clauses else "" # Snapshot the filter params before the query builders below extend diff --git a/hermes_state_common.py b/hermes_state_common.py index 28f3a63cdb..5a9e4893e6 100644 --- a/hermes_state_common.py +++ b/hermes_state_common.py @@ -310,6 +310,7 @@ CREATE TABLE IF NOT EXISTS sessions ( rewind_count INTEGER NOT NULL DEFAULT 0, archived INTEGER NOT NULL DEFAULT 0, pinned INTEGER NOT NULL DEFAULT 0, + hidden INTEGER NOT NULL DEFAULT 0, last_read_at REAL, FOREIGN KEY (parent_session_id) REFERENCES sessions(id), FOREIGN KEY (system_prompt_hash) REFERENCES system_prompts(hash) diff --git a/tests/hermes_cli/test_session_recovery_lost_and_found.py b/tests/hermes_cli/test_session_recovery_lost_and_found.py index 7f273ff701..0307eb28ae 100644 --- a/tests/hermes_cli/test_session_recovery_lost_and_found.py +++ b/tests/hermes_cli/test_session_recovery_lost_and_found.py @@ -324,10 +324,10 @@ def _make_synthetic_lost_and_found( ] finally: schema.close() - assert len(sessions_columns) == 54 + assert len(sessions_columns) == 55 assert len(usage_columns) == 18 - max_fields = 54 + max_fields = 55 conn = sqlite3.connect(str(path), isolation_level=None) try: cells = ", ".join(f"c{i}" for i in range(max_fields)) @@ -354,8 +354,8 @@ def _make_synthetic_lost_and_found( } return [base.get(column) for column in sessions_columns[:ncols]] - # Current 54-column layout and historical 52-column layout. - insert(54, 1, session_row("20260101_010101_aaa001", 54)) + # Current 55-column layout and historical 52-column layout. + insert(55, 1, session_row("20260101_010101_aaa001", 55)) insert(52, 2, session_row("20260202_020202_bbb002", 52)) # 14-column legacy layout: identity + a plausible epoch timestamp. legacy = ["20250303_030303_ccc003", "cli", 1_741_000_000.0] + [None] * 11 diff --git a/tests/hermes_state/test_session_hidden.py b/tests/hermes_state/test_session_hidden.py new file mode 100644 index 0000000000..cbb1c472f6 --- /dev/null +++ b/tests/hermes_state/test_session_hidden.py @@ -0,0 +1,44 @@ +import pytest + +from hermes_state import SessionDB + + +@pytest.fixture +def db(tmp_path): + database = SessionDB(tmp_path / "state.db") + try: + yield database + finally: + database.close() + + +def test_hidden_excluded_by_default_included_on_request(db): + db.create_session("visible", source="cli") + db.create_session("secret", source="cli") + # Give both a message so the default min_message_count filter keeps them. + for sid in ("visible", "secret"): + db._conn.execute( + "UPDATE sessions SET message_count = 1 WHERE id = ?", (sid,) + ) + db._conn.commit() + + # Flip the hidden flag on one session. + assert db.set_session_hidden("secret", True) is True + assert db.get_session("secret")["hidden"] == 1 + assert db.get_session("visible")["hidden"] == 0 + + # Default listing drops the hidden row; include_hidden=True surfaces it. + default_ids = {s["id"] for s in db.list_sessions_rich(min_message_count=1)} + assert default_ids == {"visible"} + + all_ids = { + s["id"] + for s in db.list_sessions_rich(min_message_count=1, include_hidden=True) + } + assert all_ids == {"visible", "secret"} + + # Unhiding brings it back into the default listing. + assert db.set_session_hidden("secret", False) is True + assert db.get_session("secret")["hidden"] == 0 + unhidden_ids = {s["id"] for s in db.list_sessions_rich(min_message_count=1)} + assert unhidden_ids == {"visible", "secret"} diff --git a/tui_gateway/methods_session.py b/tui_gateway/methods_session.py index 32b202f539..a05b303486 100644 --- a/tui_gateway/methods_session.py +++ b/tui_gateway/methods_session.py @@ -98,6 +98,7 @@ def _(rid, params: dict) -> dict: "create_service_tier_override": create_service_tier_override, "parent_session_id": parent_session_id, "pending_title": title or None, + "pending_hidden": is_truthy_value(params.get("hidden", False)), "profile_home": str(profile_home) if profile_home is not None else None, "running": False, "session_key": key, @@ -1105,6 +1106,37 @@ def _(rid, params: dict) -> dict: return _err(rid, 5007, str(e)) +@method("session.set_hidden") +def _(rid, params: dict) -> dict: + """Set/clear the generic ``hidden`` flag on a session (and its lineage). + + Mirrors the durable ``pinned``/``archived`` setters: a hidden session is + dropped from the default global Sessions list (``list_sessions_rich`` + without ``include_hidden``) but stays fully resumable by the surface that + owns it — for plugins that manage their own sessions and don't want them + cluttering the shared recents list. Flips the whole compression chain as a + unit in the DB layer. + """ + session, err = _sess_nowait(params, rid) + if err: + return err + hidden = is_truthy_value(params.get("hidden", True)) + with _session_db(session) as db: + if db is None: + return _db_unavailable_error(rid, code=5007) + key = session["session_key"] + try: + changed = db.set_session_hidden(key, hidden) + if not changed: + # No row yet (write deferred to the first prompt): remember the + # intent so _ensure_session_db_row is born hidden, mirroring the + # pending_title deferral. + session["pending_hidden"] = hidden + return _ok(rid, {"hidden": hidden, "session_key": key}) + except Exception as e: + return _err(rid, 5007, str(e)) + + @method("message.react") def _(rid, params: dict) -> dict: """Set or clear one author's emoji reaction on a persisted message. diff --git a/tui_gateway/server.py b/tui_gateway/server.py index 6be948282d..44bc566384 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -2976,6 +2976,14 @@ def _ensure_session_db_row(session: dict) -> None: # means the launch/default profile (matches run_agent's convention). profile_name=Path(profile_home).name if profile_home else None, ) + # A session can be born hidden (session.create hidden=true, or a + # session.set_hidden that arrived before the row existed): apply the + # deferred intent now that the row exists, mirroring pending_title. + if session.get("pending_hidden"): + try: + db.set_session_hidden(key, True) + except Exception: + logger.debug("failed to apply pending hidden flag", exc_info=True) except Exception as exc: # Disk-full is not a soft failure: if we swallow it here, prompt.submit # returns {"status":"streaming"} and the user's message vanishes with