fix(bot-mode): always hide Bot Mode sessions from the global Sessions sidebar

Bot Mode's group chats spawned one per-member session per room, and those
"Group: ..." rows (plus canonical Bot Chats when the old eye-toggle pref was
off) flooded the global Sessions sidebar — a 6-bot room dumped six identical
rows into recents (reported with screenshot, Aug 17).

Plugin (apps/desktop/src/plugins/hermes-bots/plugin.js):
- session.create now passes hidden:true UNCONDITIONALLY for both canonical
  Bot Chats and group-room member sessions; the $hideBotChats pref, its eye
  toggle, and its storage hydrate are removed (Bot Mode sessions are plumbing
  or plugin-owned forever-chats, never scratch conversations).
- hideOwnedBotSessions(): idempotent reconciliation sweep over every owned
  session id (bot meta canonical chats + each room's member sessions) via
  session.set_hidden, run on plugin load and on each gateway reconnect, so
  rows born visible under the old pref get cleaned up.
- The Bots session browser and canonical-chat recovery scan pass
  include_hidden:true so they still see the rows they own.

Gateway (tui_gateway/methods_session.py):
- session.list honors an include_hidden param (default off — the resume
  picker and all global callers keep dropping hidden rows).
- session.set_hidden gains a durable fallback: when no LIVE runtime session
  matches, resolve the stored session id in the target profile's state.db
  (via resolve_session_id) and flip the flag there. The sweep holds stored
  ids for chats that aren't live; the old live-only lookup 4001'd them.

Validated E2E with real imports against a temp HERMES_HOME: born-hidden row
(hidden=1), profile-scoped session.list default vs include_hidden (0 vs 1),
and stored-id sweep on a non-live legacy row (hidden=1). Plugin suite
167/167; new RPC regression tests in tests/tui_gateway/test_session_hidden_rpc.py.
This commit is contained in:
Teknium
2026-08-17 14:42:34 -07:00
parent bb92473074
commit 66221397a1
6 changed files with 222 additions and 87 deletions
+57 -54
View File
@@ -152,13 +152,14 @@ function trackInboundActivity(roster) {
/** Last good cron list, same idea as the roster snapshot. */
const $lastJobs = atom([])
/** User pref: hide canonical "Bot Chat" sessions from the global Sessions
* sidebar (they always remain in the Bots roster). Persisted via ctx.storage.
* Default ON — Bot Chats are plugin-owned forever-chats, not scratch sessions,
* so keeping them out of the shared recents list is the expected behavior.
* Backed by the core generic `hidden` session flag (session.create hidden:true
* / session.set_hidden); older gateways ignore it and Bot Chats stay visible. */
const $hideBotChats = atom(true)
// Bot Mode sessions are ALWAYS hidden from the global Sessions sidebar:
// canonical Bot Chats are plugin-owned forever-chats and group-chat member
// sessions are room plumbing — neither is a scratch conversation, and a
// 6-member room would otherwise dump six identical "Group: ..." rows into
// recents. Backed by the core generic `hidden` session flag (session.create
// hidden:true / session.set_hidden); the Bots pane browses them via
// session.list include_hidden. Older gateways ignore the flag and the
// sessions simply stay visible there.
/** Bot the Routines tile is scoped to. Follows the live gateway profile
* (the bot you're actually chatting with) and roster clicks. */
@@ -268,27 +269,25 @@ async function saveBotMeta(name, patch) {
return { serverPersisted: serverOutcome === 'persisted', serverOutcome }
}
/** Flip the "hide Bot Chats from the sidebar" pref, persist it, and reconcile
* every known canonical chat via the core session.set_hidden RPC so the change
* applies to already-created Bot Chats (not just future ones). Feature-detected:
* older gateways lack session.set_hidden and simply keep the chats visible. */
async function setHideBotChats(hidden) {
$hideBotChats.set(hidden)
try {
Promise.resolve(pluginCtx?.storage?.set?.('hide-bot-chats', hidden)).catch(() => undefined)
} catch {
/* storage unavailable — pref holds for this window only */
}
const meta = $botMeta.get()
const ids = Object.values(meta)
/** One-time reconciliation: Bot Mode sessions are always hidden, but rooms
* and Bot Chats created before this policy (or while the old pref was off)
* left visible rows behind. On every plugin load, sweep every session id we
* own — canonical chats from bot meta plus each group room's member
* sessions — through the core session.set_hidden RPC. Idempotent (the DB
* setter is a no-op on already-hidden rows) and feature-detected: older
* gateways lack session.set_hidden and simply keep the rows visible. */
function hideOwnedBotSessions() {
const canonical = Object.values($botMeta.get())
.map(m => m && m.chat)
.filter(Boolean)
const rooms = Object.values($groupChats.get())
.flatMap(room => Object.values(room?.sessions || {}))
.filter(sid => Boolean(sid) && sid !== true)
const ids = [...new Set([...canonical, ...rooms])]
await Promise.all(
return Promise.all(
ids.map(sid =>
Promise.resolve(host.request('session.set_hidden', { session_id: sid, hidden })).catch(() => undefined)
Promise.resolve(host.request('session.set_hidden', { session_id: sid, hidden: true })).catch(() => undefined)
)
)
}
@@ -2526,7 +2525,8 @@ async function ensureRemoteCanonicalChat(route, profile) {
const created = await host.requestProfile(route, 'session.create', {
profile,
title: 'Bot Chat',
...($hideBotChats.get() ? { hidden: true } : {})
// Bot Mode sessions are always hidden from the global sidebar.
hidden: true
})
return { runtime: created?.session_id || null, stored: created?.stored_session_id || null }
@@ -2744,10 +2744,11 @@ function createCanonicalChat(name) {
const res = await host.request('session.create', {
profile: name,
title: 'Bot Chat',
// Born hidden from the global sidebar when the pref is on. Core applies
// this via the generic `hidden` flag (deferred as pending_hidden until the
// row exists); older gateways ignore the unknown param and it stays visible.
...($hideBotChats.get() ? { hidden: true } : {})
// Always born hidden from the global sidebar — Bot Mode sessions are
// plugin-owned. Core applies this via the generic `hidden` flag
// (deferred as pending_hidden until the row exists); older gateways
// ignore the unknown param and it stays visible.
hidden: true
})
const sid = res?.stored_session_id
const runtime = res?.session_id
@@ -3353,7 +3354,8 @@ async function ensureGroupChatSession(group, member) {
const created = await requestForBot(member, 'session.create', {
profile: member.name,
title,
...($hideBotChats.get() ? { hidden: true } : {})
// Room member sessions are plumbing — always hidden from the sidebar.
hidden: true
})
const stored = created?.stored_session_id || null
@@ -6673,7 +6675,9 @@ function useProfileSessions(botName, gatewayGeneration) {
return useQuery({
queryKey: [ID, 'profile-sessions', botName, gatewayGeneration],
enabled: Boolean(botName),
queryFn: () => host.request('session.list', { profile: botName, limit: PROFILE_SESSION_LIST_LIMIT }),
// include_hidden: this browser exists precisely to see the profile's own
// (always-hidden) Bot Mode sessions alongside its regular ones.
queryFn: () => host.request('session.list', { profile: botName, limit: PROFILE_SESSION_LIST_LIMIT, include_hidden: true }),
refetchInterval: 8000,
staleTime: 4000,
retry: false
@@ -7306,7 +7310,6 @@ function BotsPane() {
const [deleting, setDeleting] = useState(null)
const [grouping, setGrouping] = useState(null)
const [query, setQuery] = useState('')
const hideBotChats = useValue($hideBotChats)
const activityToasts = useValue($activityToasts)
const sessionsWorkspaceName = useValue($botSessionsWorkspace)
const groupChatName = useValue($groupChatWorkspace)
@@ -7424,16 +7427,6 @@ function BotsPane() {
children: jsx(Codicon, { name: activityToasts ? 'bell' : 'bell-slash' })
})
}),
jsx(Tip, {
label: hideBotChats ? 'Bot Chats hidden from Sessions — click to show' : 'Bot Chats shown in Sessions — click to hide',
children: jsx('button', {
type: 'button',
className:
'flex size-6 items-center justify-center rounded-md text-(--ui-text-tertiary) transition-colors hover:bg-(--chrome-action-hover) hover:text-foreground',
onClick: () => void setHideBotChats(!hideBotChats),
children: jsx(Codicon, { name: hideBotChats ? 'eye-closed' : 'eye' })
})
}),
jsxs(DropdownMenu, {
children: [
jsx(Tip, {
@@ -7792,18 +7785,9 @@ export default {
/* no storage on this shell — defaults stay */
}
// Hydrate the "hide Bot Chats from the sidebar" pref (default ON).
try {
Promise.resolve(ctx.storage?.get?.('hide-bot-chats'))
.then(value => {
if (typeof value === 'boolean') {
$hideBotChats.set(value)
}
})
.catch(() => undefined)
} catch {
/* no storage — default (hide) stays */
}
// Bot Mode sessions are always hidden now — the old "hide Bot Chats"
// pref is gone (its stored key is simply ignored). The reconciliation
// sweep below hides any rows born visible under the old pref.
// Hydrate the activity-toast pref (default OFF).
try {
@@ -7855,6 +7839,25 @@ export default {
})
host.state.gateway.listen(handleSessionsGatewayTransition)
// Reconciliation sweep: hide every Bot Mode session we know about, on
// load and again on each reconnect (a swap can land on a gateway whose
// rows were created before the always-hidden policy). Deferred a tick so
// the meta/room storage hydrates above have landed; idempotent after that.
// (Feature-guarded: bare vm test harnesses have no setTimeout global.)
const scheduleHideSweep = () => {
try {
setTimeout(() => void hideOwnedBotSessions(), 0)
} catch {
void hideOwnedBotSessions()
}
}
host.state.gateway.listen(state => {
if (state === 'open') {
scheduleHideSweep()
}
})
scheduleHideSweep()
ctx.register({
id: 'pane',
area: 'panes',
@@ -3,14 +3,16 @@ import { readFileSync } from 'node:fs'
import test from 'node:test'
import vm from 'node:vm'
// #46: canonical "Bot Chat" sessions can be hidden from the global Sessions
// sidebar via the core generic `hidden` session flag, while staying in the Bots
// roster. The plugin passes hidden:true on session.create when the pref is on,
// and setHideBotChats reconciles existing chats via session.set_hidden.
// Bot Mode sessions are ALWAYS hidden from the global Sessions sidebar
// (canonical Bot Chats and group-chat member sessions alike) via the core
// generic `hidden` session flag. There is no user pref: session.create
// passes hidden:true unconditionally, and hideOwnedBotSessions() sweeps
// every known plugin-owned session id through session.set_hidden so rows
// born visible under the old pref get reconciled.
const source = readFileSync(new URL('../plugin.js', import.meta.url), 'utf8')
function loadCreate(hidePref) {
function loadCreate() {
const start = source.indexOf('const canonicalCreations = new Map()')
const end = source.indexOf('function displayName(', start)
const created = []
@@ -26,7 +28,6 @@ function loadCreate(hidePref) {
}
},
saveBotMeta: () => {},
$hideBotChats: { get: () => hidePref },
window: { setTimeout: cb => cb() }
}
const section = source.slice(start, end).concat('\nglobalThis.__c = { createCanonicalChat };\n')
@@ -34,26 +35,54 @@ function loadCreate(hidePref) {
return { create: context.__c.createCanonicalChat, created }
}
test('createCanonicalChat passes hidden:true when the pref is on', async () => {
const { create, created } = loadCreate(true)
test('createCanonicalChat always passes hidden:true — no pref gate', async () => {
const { create, created } = loadCreate()
await create('alpha')
assert.equal(created.length, 1)
assert.equal(created[0].hidden, true)
assert.equal(created[0].title, 'Bot Chat')
})
test('createCanonicalChat omits hidden when the pref is off', async () => {
const { create, created } = loadCreate(false)
await create('beta')
assert.equal(created.length, 1)
assert.ok(!('hidden' in created[0]), 'hidden should be omitted, not false')
test('group member session.create is unconditionally hidden too', () => {
// Source contract on ensureGroupChatSession: the create carries a literal
// hidden:true, with no $hideBotChats conditional anywhere in the plugin.
const fn = source.slice(source.indexOf('async function ensureGroupChatSession('), source.indexOf('const GROUP_TURN_TIMEOUT_MS'))
assert.match(fn, /hidden: true/)
assert.equal(source.includes('$hideBotChats'), false, 'the old pref atom must be gone')
})
test('setHideBotChats persists the pref and reconciles known chats via session.set_hidden', () => {
// Source-level: the toggle calls session.set_hidden for each known canonical
// chat id and persists the pref to storage.
const fn = source.slice(source.indexOf('async function setHideBotChats('), source.indexOf('const avatarFetchInflight'))
assert.match(fn, /storage\?\.set\?\.\('hide-bot-chats', hidden\)/)
assert.match(fn, /session\.set_hidden.*session_id: sid, hidden/)
assert.match(fn, /\.map\(m => m && m\.chat\)/)
test('hideOwnedBotSessions sweeps canonical chats AND room member sessions', async () => {
const start = source.indexOf('function hideOwnedBotSessions()')
const end = source.indexOf('/** Fetch server-side avatars', start)
const calls = []
const context = {
host: {
request: async (method, params) => {
calls.push({ method, params })
return {}
}
},
$botMeta: { get: () => ({ alpha: { chat: 'chat-a' }, beta: { chat: 'chat-b' }, gamma: {} }) },
$groupChats: {
get: () => ({
Core: { sessions: { alpha: 'room-core-a', beta: 'room-core-b' } },
Quiet: { sessions: { alpha: 'chat-a' } }, // duplicate id — must dedupe
Legacy: {} // pre-sessions room shape
})
}
}
const section = source.slice(start, end).concat('\nglobalThis.__h = { hideOwnedBotSessions };\n')
vm.runInNewContext(section, context, { filename: 'h.js' })
await context.__h.hideOwnedBotSessions()
const ids = calls.map(c => c.params.session_id).sort()
assert.deepEqual(ids, ['chat-a', 'chat-b', 'room-core-a', 'room-core-b'])
assert.ok(calls.every(c => c.method === 'session.set_hidden' && c.params.hidden === true))
})
test('the Bots session browser lists with include_hidden', () => {
// The one session.list consumer that must see the always-hidden rows.
// (Canonical-chat recovery now goes through profiles.list
// preferred_session_ids, whose resolver already sees hidden rows.)
assert.match(source, /session\.list', \{ profile: botName, limit: PROFILE_SESSION_LIST_LIMIT, include_hidden: true \}/)
})
@@ -134,7 +134,7 @@ test('source contract: bot rows and Active now activate the owner before canonic
test('source contract: workspaces disclose the bounded recent-session inventory', () => {
assert.match(pluginSource, /queryKey: \[ID, 'profile-sessions', botName, gatewayGeneration\]/)
assert.match(pluginSource, /enabled: Boolean\(botName\)/)
assert.match(pluginSource, /host\.request\('session\.list', \{ profile: botName, limit: PROFILE_SESSION_LIST_LIMIT \}\)/)
assert.match(pluginSource, /host\.request\('session\.list', \{ profile: botName, limit: PROFILE_SESSION_LIST_LIMIT, include_hidden: true \}\)/)
assert.match(pluginSource, /Showing the \$\{PROFILE_SESSION_LIST_LIMIT\} most recent sessions\./)
assert.match(pluginSource, /No matching sessions in the \$\{PROFILE_SESSION_LIST_LIMIT\} most recent\./)
assert.doesNotMatch(pluginSource, /children: 'Activate profile'/)
@@ -0,0 +1,71 @@
"""RPC-level tests for the generic hidden-session surface (tui_gateway).
Covers the two seams Bot Mode's "sessions are always hidden" policy leans on:
* ``session.set_hidden`` resolves a DURABLE stored session id when no live
runtime session matches — plugins reconciling sessions they own (Bot Mode's
hide sweep) hold stored ids for chats that aren't live right now. The old
live-only lookup failed those with 4001 and the sweep silently no-opped.
* ``session.list`` honors ``include_hidden`` so owning surfaces (the Bots
pane's per-profile browser) can still enumerate the rows they hid, while
every default caller keeps the hidden rows dropped.
"""
import pytest
import tui_gateway.server as srv
import tui_gateway.methods_session # noqa: F401 (registers the RPC methods)
from hermes_state import SessionDB
@pytest.fixture
def db(tmp_path, monkeypatch):
database = SessionDB(tmp_path / "state.db")
monkeypatch.setattr(srv, "_get_db", lambda: database)
try:
yield database
finally:
database.close()
def _call(method: str, params: dict) -> dict:
return srv._methods[method](1, params)
def _seed(db, sid: str) -> None:
db.create_session(sid, source="desktop")
db._conn.execute("UPDATE sessions SET message_count = 1 WHERE id = ?", (sid,))
db._conn.commit()
def test_set_hidden_resolves_stored_id_without_live_session(db):
"""A stored (non-live) session id must be hideable — the sweep path."""
_seed(db, "stored-chat")
assert srv._find_live_session_by_key("stored-chat") is None
envelope = _call("session.set_hidden", {"session_id": "stored-chat", "hidden": True})
assert "error" not in envelope, envelope
assert envelope["result"]["hidden"] is True
assert db.get_session("stored-chat")["hidden"] == 1
# And back — unhide through the same durable path.
envelope = _call("session.set_hidden", {"session_id": "stored-chat", "hidden": False})
assert "error" not in envelope, envelope
assert db.get_session("stored-chat")["hidden"] == 0
def test_set_hidden_unknown_id_still_errors(db):
envelope = _call("session.set_hidden", {"session_id": "no-such-session", "hidden": True})
assert envelope.get("error"), envelope
def test_session_list_include_hidden(db):
_seed(db, "plain-chat")
_seed(db, "bot-chat")
assert db.set_session_hidden("bot-chat", True) is True
default_rows = _call("session.list", {})["result"]["sessions"]
assert {s["id"] for s in default_rows} == {"plain-chat"}
all_rows = _call("session.list", {"include_hidden": True})["result"]["sessions"]
assert {s["id"] for s in all_rows} == {"plain-chat", "bot-chat"}
+39 -12
View File
@@ -178,6 +178,11 @@ def _(rid, params: dict) -> dict:
deny = frozenset({"kanban", "tool"})
limit = int(params.get("limit", 200) or 200)
# ``include_hidden``: surfaces that OWN hidden sessions (the Bots
# pane's per-profile browser, plugin session pickers) need to list
# them; the flag stays off for the resume picker and every other
# global caller so `hidden` keeps meaning "not in shared lists".
include_hidden = is_truthy_value(params.get("include_hidden", False))
# Over-fetch modestly so per-source filtering doesn't leave us
# short; the compression-tip projection in ``list_sessions_rich``
# can also merge rows.
@@ -189,6 +194,7 @@ def _(rid, params: dict) -> dict:
limit=fetch_limit,
order_by_last_active=True,
compact_rows=True,
include_hidden=include_hidden,
)
if (s.get("source") or "").strip().lower() not in deny
][:limit]
@@ -1190,23 +1196,44 @@ def _(rid, params: dict) -> dict:
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.
Resolution is two-tier: a LIVE runtime session id first (which also
covers the not-yet-persisted draft via the ``pending_hidden`` deferral),
then a durable stored id/key against the target profile's state.db —
plugins reconciling sessions they own (e.g. Bot Mode's hide sweep) hold
stored ids for chats that aren't live right now, and the live-only
lookup silently failed those with 4001.
"""
session, err = _sess_nowait(params, rid)
if err:
return err
hidden = is_truthy_value(params.get("hidden", True))
with _session_db(session) as db:
session, err = _sess_nowait(params, rid)
if session is not None:
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))
# Durable fallback: a stored session id (or key) in the requested
# profile's db. ``resolve_session_id`` follows key/title aliases the
# same way the REST pin/archive path does.
target = str(params.get("session_id") or "").strip()
with _profile_db(params) 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})
resolved = db.resolve_session_id(target) if hasattr(db, "resolve_session_id") else target
if not resolved:
return err
db.set_session_hidden(resolved, hidden)
return _ok(rid, {"hidden": hidden, "session_key": resolved})
except Exception as e:
return _err(rid, 5007, str(e))
+5
View File
@@ -182,6 +182,11 @@ when a teammate bot opens it headlessly from the CLI — so bot-to-bot
replies and handoffs work without touching your SOUL.md, and your regular
sessions stay untouched.
Bot Mode's sessions — each bot's canonical Bot Chat and every group-chat
member session — are always hidden from the global Sessions sidebar. They
live in the Bots pane (roster rows, room views, and each bot's session
browser) instead of interleaving with your own conversations.
Don't want it? Flip it off in **Settings → Plugins → Bots** — the roster,
routines pane, and composer middleware unregister live, no restart needed.