Opening a Bot Mode group chat painted the room twice — once as a main-window
workspace tab (host.openWorkspace) and once as the in-panel fallback, because
the Bots pane rendered off $groupChatWorkspace alone. Two live panes with
independent drafts drove one shared engine, and the roster disappeared behind
the duplicate.
The in-panel room is the fallback surface, not a second copy: it now renders
only while no main tab owns the group. The selection atom stays set either way
so the roster row still highlights, and desktops without the door — or whose
door throws — keep the in-pane room.
Consolidates #89881, #90274 and #90398, which fixed the same bug.
Closes#89788
Co-authored-by: helix4u <helix4u@users.noreply.github.com>
Worker sessions are deny-listed out of every conversation list, so a
profile grinding through a 30-minute kanban task read idle ('3 hr ago')
with no ACTIVE NOW entry the entire run.
- tui_gateway/methods_profiles.py: profiles.list rows gain worker_session
— the newest kanban/tool row (id, source, title, last_active). Workers
heartbeat last_activity_at every <=60s while running (#72016), so the
field stays fresh exactly while work is happening. last_session keeps
its deny-list contract; include_sessions:false omits the field; older
clients ignore it.
- hermes-bots plugin: workerActiveAt() (150s window, one missed heartbeat
of slack) feeds ACTIVE NOW, the row pulse dot ('Working on a task right
now'), and the row age label while a worker runs. Chat semantics are
untouched when no worker is live.
- Tests: 4 new pytest (real SessionDB on temp HERMES_HOME), 2 new node
behavior tests; sabotage-verified.
Session-list visibility of workers (the issue's first half) is left as-is
by design — auto-resume and shared lists must keep excluding workers; the
roster signal was the actionable gap.
Bot Mode passed keepAllProfilesScope:false, which re-homed the sidebar
onto the bot profile. That profile forever-chat is hidden, so Sessions
and the roster looked empty. Opening a bot is navigation, not a workspace
switch. Also restore all-profiles when the bot backend is already live.
Related: #89789
A review of the previous commit found that retrying at the plugin layer
(openStoredBotChat catching and re-calling host.openSession) didn't fix
the reported bug: host.openSession's own catch block unconditionally
calls setResumeExhaustedSessionId on a hydration timeout before
rethrowing, and only an explicit resumeSession() (the manual Retry
button) clears that latch for the currently-routed session. A
plugin-side retry is a different code path that can hydrate the
transcript fine while the full-screen "Couldn't load this session"
overlay stays latched over it.
host.openSession now takes a retryHydrationTimeoutOnce option and
retries the open+hydration-wait internally, before the latch is ever
set, so a successful retry never arms the overlay. openStoredBotChat
just opts in via that option.
The SDK now returns the registry rows per its documented contract
(salvaged #89893), while desktops predating the SDK unwrap resolve the
raw registry envelope. The plugin normalize accepts both, so the picker
works across the transition; regression test updated to pin the
dual-shape normalize.
The canonical Bot Chat is hidden from session lists by design, so
profiles.list's last_session never advances when you message a bot there.
PR #88690 moved the roster PREVIEW to preferred_session but left every
activity signal on last_session — a bot you just messaged showed '6d ago',
never pulsed, never badged, and sorted below stale bots.
New botActivitySession(bot) helper returns the fresher of preferred_session
(the pinned Bot Chat, resolved precisely by the backend) and last_session
(newest visible conversation). All four activity sites key off it now:
- row age label (relativeTime)
- active-now pulse dot + activeBots strip
- unread watermark + activity toast preview
- roster recency sort (activityOf)
Older gateways without the preferred_session resolver degrade to
last_session exactly as before. Backend untouched.
Tests: extracted the real helper into the vm harnesses (no stub drift),
5 new behavior tests; sabotage-verified they fail against the old code.
host.connections() resolves the IPC handler hermes:connections:list, which
returns the registry OBJECT ({version, primary, connections: [...]}) — not a
bare array. CreateAgentDialog did setConnections(Array.isArray(value) ? value
: []), so on a multi-connection desktop the picker gate
(Array.isArray(connections) && connections.length > 1) never fired and the
'Create on' picker stayed hidden, making cross-machine bot creation
impossible despite the multi-connection feature being documented.
Any new agent is still created on the active gateway (unchanged behaviour);
the picker is the only path that regressed. The built-in Connections UI
(refreshConnectionsRegistry) consumes the same registry object, so the IPC
handler contract is left untouched.
Adds a regression test asserting the unwrap and that the IPC contract is
preserved. Plugin suite: 309/309 pass.
Bot Mode group rooms only showed a single "is thinking…" line while bots
ran, and nothing after a turn settled or failed — no way to see what the
room did without reading the whole transcript.
- Runtime-only, bounded activity feed per room (GROUP_ACTIVITY_LIMIT),
recording truthful turn events: queued, working, replied, passed,
timed-out, failed, cancelled, settled, delivered.
- Every event is tagged with the room epoch it belongs to; the view shows
only the CURRENT run, so a superseding send (or a rename that re-keys
the room) can never surface stale activity.
- Quiet disclosure in the room header: collapsed by default, the collapsed
row shows the latest event summary; expanding lists the current run's
events newest-first with per-state glyphs and tones.
- Never persisted and never hydrated — the transcript stays the only
durable record, so activity cannot be replayed as history.
Tests: 8 new behavioral + source-contract cases in
tests/group-activity.test.mjs (settled arc, failed turn, supersede/cancel,
epoch filtering, bounded feed, runtime-only guarantee, labels, disclosure
a11y contract).
runGroupChatRounds' responder selection had no awareness of the stranded/
harvest state the previous round's harvest pass just confirmed. A member
whose turn timed out (marked stranded) but is STILL genuinely running
could be re-selected as a responder in a later round of the same
invocation — resolveGroupResponders has no busy filter, and a member's
watermark is bumped past the stranded timeout regardless of outcome, so a
fresh delta re-qualifies them.
Re-selecting them fires another prompt.submit into their live session.
tui_gateway's _handle_busy_submit treats that as a normal busy mid-turn
prompt: by default it either redirects the live turn in place or, for
older agents, hard-interrupts it and queues the new text as the next
turn. Either way the member's original in-flight work — exactly what the
stranded/harvest mechanism exists to protect — gets abandoned or killed,
undermining the "never lost, just late" guarantee.
Filter responders against the room's current stranded map (freshly
confirmed by this round's own harvest pass) before selecting who speaks.
A member with a live stranded marker is skipped; the next harvest pass
picks their reply up once it actually lands.
Added a regression test exercising runGroupChatRounds end-to-end with a
member confirmed still-busy: without the guard the round loop resubmits
into their session (asserted via prompt.submit call count); with the
guard it never does, and the marker survives untouched. Mutation-verified:
temporarily reverted the filter and confirmed the new test fails
(2 !== 0) fast, without a real wall-clock wait.
Full hermes-bots plugin suite: 246/246 pass (47 files). node --check
clean on both changed files.
Every host.openSession call in the Bot Mode plugin omitted
keepAllProfilesScope, so the SDK applied its default and flipped
$showAllProfiles back on whenever the target session belonged to a
different profile than the live gateway (sdk/index.ts:
options.keepAllProfilesScope !== false => setShowAllProfiles(true)).
For anyone running more than one profile this silently undid the sidebar
profile filter: narrow Sessions to one profile, click any other bot, and
the unified all-profiles list came back.
Bot navigation is an explicit context switch into that bot's profile, so
pass keepAllProfilesScope: false at every openSession call site (4 on
current main after the plugin.js refactor consolidated the original 7).
Salvaged from PR #89031 onto current main; includes contributor mapping.
nanostores' .listen() never replays the current value the way .subscribe()
does, so the $focusedBotProfile listener in register() only kept
$selectedBot current from the moment it was attached. A disable -> profile
switch -> re-enable cycle (Settings > Plugins) left $selectedBot pointed at
whichever bot was active before the plugin was disabled, so the roster
highlight fallback and Routines scoping could start from a stale bot.
Extract the sync into bindProfileSync(), which reseeds $selectedBot from
the profile store's current value before attaching the listener. This runs
on every register() call, so re-enabling the plugin always starts in sync.
Salvaged from PR #89637 (the pane-precedence portion was superseded on main
by the $focusedBotProfile design; this residual reseed gap remained).
Regression test mimics real nanostores get/listen semantics and fails
without the reseed.
Fixes-residual-of: #89625
`botHandle()` exists so that, per its own comment, "the word 'default'
never surfaces in the UI" — it presents the primary profile as `hermes`.
The roster rows, mention resolution and the group-chat prompt all route
through it. Two preview paths did not, and rendered the raw profile name:
- `GroupRow`'s room preview line built `@${last.from?.name}`, so a group
room read `@default: …` while the bot answers to `@hermes`.
- `previewKind()` returned the raw captured name from the bot-to-bot
delivery prefix, so the `🤖 @<name>` badge and its tooltip could show
`@default` too.
The mismatch is presentation-only, but it reads as a routing bug: the
room says the message came from `@default` while `@default` is not a
handle the mention resolver accepts, so users reasonably conclude
bot-to-bot addressing is broken when it is working correctly.
Both paths now map through `botHandle()`. `GroupRow` passes the matching
member so a bot with a custom handle keeps it; `previewKind` maps the
lowercased sender name, which leaves every non-primary profile unchanged.
Tests: the primary profile resolves to `hermes` and a named profile keeps
its own handle (behavioural, in the existing previewKind suite), plus a
source-shape assertion for the render path matching that file's
convention. Both new assertions fail against the pre-fix source.
Fixes#89484
blobatar 0.2.0 -> 2.0.0 (gen2). Ten silhouettes instead of six:
capsule, triangle, hexagon and droplet join round, organic, boxy,
nub, cloud and sun. BLOB_KIND_TRAIT repinned to gen2 band centers
(empirically verified against the published package: every pinned
value resolves to its named silhouette across seeds). The avatar
picker derives from BLOB_KINDS, so the new shapes appear there
automatically. Note: gen2 remaps most unpinned seed->face mappings
by design (upstream generation change).
The Bots roster highlight and the Routines (Cronjobs) tile were keyed off
host.state.profile — the gateway socket's home. Tab/tile focus moves without
swapping the socket, so opening one bot's chat while the socket was homed on
another highlighted the wrong bot and showed the wrong bot's cronjobs
(community report: Newsanalyst chat open, Hermes highlighted).
- sdk: new host.state.focusedSessionProfile — owner profile of the focused
chat, resolved from the focused stored session's row stamp via
rememberedSessionProfile() (same ladder as remembered navigation and the
HUD), with the gateway profile as the draft/uncached fallback.
- hermes-bots: $focusedBotProfile = focusedSessionProfile || profile
(feature-detected; older desktops keep prior behavior). BotRow highlight,
RoutinesPane scope, and the $selectedBot tracker use it. Turn-busy 'work'
mood stays keyed to the socket-home profile (only it can be mid-turn).
- tests: SDK atom behavior (vitest) + plugin source-shape suite; prewarm
harness stubs gain the new atom.
- docs: SDK page + hermes-agent skill reference list the new atom.
Builds on #89551 (@calvinnwq, cherry-picked): his showCloseButton flag
hid the hover X; this completes the model so standing chrome can never
be closed at all, only shown/hidden (#89546).
- hideOnly pane chrome (sessions + Bots): no hover X, no middle/meta
click close, no Close verbs in the tab menu, excluded from
close-others/right/all sweeps
- zone right-click menu gains Show/Hide rows for the strip's chrome
tabs (Hide bots / Show sessions, localized in 6 locales)
- Cmd-K palette: auto-registered "Toggle <tab> tab" rows for every
hideOnly pane, on-screen truth semantics, plugin panes included via
registry subscription
- hides persist across launches (survive the enforced dock re-adopt);
reveal intent and Layout reset clear them
- last-visible-tab guard: hiding the zone's last shown tab is refused
with a toast, so the strip can never become an empty dead zone
Add a pane-level opt-out for the hover close button and apply it to the persistent Sessions and Bots navigation panes. Keep their existing close handlers and other tab behavior intact.\n\nFixes #89546
Completes group/1:1 attachment parity (#88983). PR #89486 covered images;
this adds the remaining half:
- The composer picker accepts any file type; kind (image/pdf/file) decides
the staging RPC. Paste handlers accept non-image files too.
- Drag & drop anywhere on the room drops into the active composer (open
reply box, else main), with a drop overlay naming the target.
- Member turns stage PDFs via pdf.attach (rendered per-page into vision
tiles by the gateway) and other files via file.attach; each returned
@file: ref is appended to that member's turn prompt so file tools can
read the artifact. Failed attaches still degrade to text-only.
- Transcript markers distinguish [attached PDF: x] / [attached file: x] /
[attached image: x]; room log renders non-image attachments as named
chips, pending chips show type icons.
Tests: 3 new vm-harness tests (per-kind RPC routing across members,
@file: ref injection into turn prompts, transcript labels); 279 total pass.
New agents now get a blobatar — a deterministic soft-body face generated
from the bot's name (same name, same face, forever) — as the default
shapes mode, with full manual control:
- Face follows the name live while typing in New Agent
- Randomize re-rolls the seed; Lock face pins the current one so a later
rename can't change it (Unlock returns to name-following)
- Any of the six silhouettes (round/organic/boxy/nub/cloud/sun) can be
pinned via frozen-per-major trait positions while the rest stays
name-derived
- Classic geometric shapes remain one click away, and existing bots keep
their stored looks untouched
Wiring: blobatar@0.2.0 (zero deps, ~3.7KB) exported through the plugin
SDK (blobatarSvg / Blobatar), feature-detected in plugin.js with a
legacy-shape fallback for older desktops. Blob shape strings are
'blobatar[:seed[:kind]]' inside the existing meta.shape field, so
persistence, cross-machine ui_meta sync, and the roster's PNG backfill
(data-bot-face tag preserved) all work unchanged.
Group rooms were text-only on the ingest side: the composer and
runGroupChatMemberTurn submitted prompt.submit {text} with no attach step,
so a user screenshot could never reach the members' models (community
report from Osiris). The gateway already ships the staging pipeline
(image.attach_bytes -> attached_images -> next prompt.submit) and the 1:1
canonical chat uses it — this wires the group surface to the same path.
- Composer + thread reply boxes: attach button, Ctrl/Cmd-V paste, pending
chips with preview/remove, image-only sends allowed.
- Attachments are downscaled (long edge 1568px) and stored on the room-log
entry, so reloads keep showing what members were shown.
- Turn drive stages the delta's images into EVERY responding member's own
per-group session via image.attach_bytes before its prompt.submit —
works cross-connection through requestForBot; a failed attach degrades
that member to text-only rather than failing the turn. Watermarks
guarantee an image is staged at most once per member.
- formatGroupChatLine names attachments ([attached image: name]) so the
transcript delta and the staged pixels line up for every viewer.
- Room log renders attached images on user entries.
Tests: 6 new vm-harness tests (fan-out staging order, mention-scoped
attachment routing, image-only sends, no re-attach across turns,
transcript naming, invalid-attachment degradation); 276 total pass.
The Bots pane docks into the sessions zone but did not declare
collapsible. Below the sidebar-collapse breakpoint, the sessions pane
left the grid and the zone kept a stranded BOTS tab on screen.
The Bots pane now declares collapsible, so it leaves the grid with
its zone. The narrow edge overlay now mirrors the zone's tab strip
when the revealed pane has collapsed zone-mates. Without the strip,
only the first pane of the zone was reachable while collapsed. A
lone pane keeps the stripless overlay form.
Follow-up to the root-cause fix: comment documenting why the opt-in
exists, plus a source-contract test proven to fail without the
attribute (sabotage run).
Group-chat message bodies in the hermes-bots plugin render without the
data-selectable-text attribute, so they inherit the app-wide
body { user-select: none } and cannot be drag-selected or copied.
1:1 chat messages already carry the equivalent marker
(aui_assistant-message-content), so this aligns group chat with that
behavior by adding data-selectable-text="true" to the message body
wrapper.
The salvaged gate only requested an explicit session.resume when the target
stored session was ALREADY selected — but the field failure (#89206) is the
cold open, where the persisted route points at the bot's session while
selection/runtime/transcript are all unsettled. The precondition skipped the
resume exactly when it was needed, and the hydration wait timed out into a
blank pane.
- sdk/index.ts: judge the main surface AFTER openSession() navigates, and
request a sequenced resume whenever the surface is not healthy (selected +
runtime bound + expected transcript present). Redundant requests are
consumed as no-ops by the route-resume effect.
- hermes-bots plugin: widen the fix to the sibling open path — the profile
session browser (openProfileSession) now opens with the same
awaitHydration/expectHistory contract as canonical Bot Chats, so a stale
main surface gets the same explicit resume instead of a silent blank pane.
- Regression test for the cold-open shape, proven failing against the
pre-fix gate (sabotage run) and passing with it.
Bot Mode group chats were named once at creation and could never be
renamed, and rooms had no picture — only the fanned member faces.
- New Group Chat dialog: optional room picture (upload from device or
image.generate, same 256px normalize pipeline as bot avatars).
- Room header: gear button opens Group settings — rename the group or
set/replace/remove the picture after creation.
- renameGroupChat re-keys the room record (log, watermarks, sessions,
members, picture), swaps the name in every local member's ui_meta
groups list, follows open views to the new name, and rejects
collisions instead of silently suffixing. Stored member sessions keep
resuming by sid, so no history is lost.
- Room picture persists in the durable room record, hydrates on window
load, and renders in the roster row (over the face pile), and the
room header.
A profile belongs to one gateway, but the Capabilities surface (Skills /
Tools / MCP) always read and wrote through the window's active backend —
scoping to a remote-owned profile silently edited the wrong machine.
- hermes.ts: capability REST helpers accept a ProfileScope
(string | {connectionId, profile}); ambient path now also carries the
active registry connection tag (same contract as the cron helpers,
#87882); profileScopeKey namespaces cache keys per connection.
- SkillsView: scope selector lists (profile, device) rows from the union
agent roster on multi-connection desktops; new fixedConnection prop
pins the whole view to a registered connection (plugin door), with a
probe-able SkillsView.supportsFixedConnection flag.
- MCP tab: live reload.mcp RPC withheld for cross-backend scopes (it
rides the active gateway socket and would reload the wrong machine).
- Bot Mode: remote-target drafts now get the live Capabilities tab
pinned to the target machine via fixedConnection, feature-detected so
older desktops keep the staged checklists.
- Config-record/hub-action stores accept scopes; cache keys fold in the
connection id so two gateways' same-named profiles never share rows.
The group room's composer (new-thread box and reply-in-thread box) rendered
a plain SDK Input. The plugin's mention provider is registered against
COMPOSER_AREAS.atCompletions, which only mounts in the main chat composer —
workspace tiles never see it, so typing @ or / in a group chat did nothing
despite the placeholder advertising "@name to direct, @everyone for all".
Fix: a member-scoped GroupMentionInput wrapper in the plugin itself —
caret-aware @-token detection, popover offering @everyone/@all plus each
seated member's handle (same botHandle the parser uses), keyboard nav
(Up/Down/Enter/Tab/Escape), and insertion that produces exactly the
"@handle " strings parseGroupChatMentions resolves. Both group composers
(GroupChatWorkspace main box + thread reply box) mount it; the per-bot
composer path is untouched.
Root cause traced by the reporter of #89049 — confirmed against source.
`hermes profile rename default <name>` (and the Desktop/dashboard rename
flows) now set a presentation-only `display_name` in profile.yaml instead
of erroring. The canonical id stays "default"; resolution, comparison,
and spawn paths are untouched. Named profiles keep real renames and their
display_name survives the move.
Surfaces: profile list/show/status, /profile (text only — data.profile
stays canonical), dashboard ProfilesPage, TUI-gateway profiles.list, and
Desktop (rail, switcher, Manage page, and the Bot Mode roster via a
displayName fallback so a renamed default shows its name, not "default").
Slimmer redo of the direction in PR #87760 by @yxssxn — thanks; see PR
body for what changed vs that approach.
Supersedes the display-only conversation folding from #89030 with actual
threads, per Teknium's direction: guessing topic boundaries from user-
message timing was wrong — tasks take many user turns.
- Every room entry carries a thread id. The main composer STARTS a new
thread with the whole group ('New Thread'); each open thread has its own
'Reply in thread' box that CONTINUES that work. Explicit intent, no
heuristics.
- Member turns are thread-scoped end to end: the round-robin drive filters
the room log to the triggering thread, watermarks key on
thread::member, and responder resolution reads only that thread — so
parallel topics never leak into each other's deltas or eat each other's
watermarks.
- Stranded (timed-out) replies remember their thread and harvest back into
it; pre-thread bare-number markers still parse.
- Pre-thread logs hydrate through assignLegacyThreads(): a user message
after a 15-min lull starts a synthetic thread, follow-ups inside the
window stay together — one-time conversion only, live sends always mint
real ids.
- UI: threads ordered by last activity, newest open by default, older ones
fold to summary rows (head text, reply count, last activity) with
expand/collapse.
Tests: thread minting, explicit-thread continuation + delta scoping (other
thread's text never reaches the member prompt), legacy hydration split
behavior, thread UI source contracts. 258/258 plugin tests pass.
Every USER message starts a conversation (the same boundary the turn
engine's delta scoping keys on). The latest conversation renders in full;
older ones collapse to a one-line summary row — head text, reply count,
last-activity time — that expands/collapses on click. db's 'threads per
conversation' ask, delivered as timeline folding: no log-model change, no
new persistence, cross-machine sync untouched.
groupChatConversations() splits the log (leading member run from a trimmed
log forms a headless block); rendering extracts renderEntry() unchanged.
Tests: conversation splitting (headless block, heads, startIndex) + fold
source contracts. 256/256 plugin tests pass.
With multi-group membership, a bot's meta carries groups[]; the create
dialog's taken-name scan must union all of them (botGroups) or a name only
present as a secondary membership could be reused and resurrect that room.
The empty cronjobs list rendered both a calendar-icon placeholder blurb
("Cronjobs are recurring tasks this agent runs on a schedule.") and the
Create Cronjob button — two elements saying the same thing (empty).
Per Teknium's review, drop the generic placeholder and keep only the
create button. The filter hint ("jobs exist but are hidden by the bot
filter") still renders, since it carries real information rather than
just marking emptiness.
From db's bot-mode feedback thread (Discord support, Aug 17):
- A 7-minute member run timed out at the fixed 3-minute turn deadline, read
as a (pass), and its finished result never reached the room. The turn
deadline now extends while the session visibly reports inflight/running
work (bounded by a 20-minute hard cap), and a turn that still times out
records a runtime 'stranded' baseline so the finished reply is harvested
into the room at the next turn boundary — late, never lost. Stranded
markers persist with the room so a reply that finishes after a window
reload is still delivered.
- Re-creating a group under a taken name (easy: the default name is just
the member names) silently reopened the old room with its full log.
Create now uniquifies against live rooms and every bot's current
grouping, so a new group is always a fresh room.
- The generic 'The room is working…' line now names the member on turn
('Radar is thinking…') so slow models read as thinking, not stuck.
- Room prompt no longer caps every reply at 1-3 sentences: chatter stays
short, but results/answers/substantive work are explicitly full quality
and length (outputs felt 'not as strong as usual').
- Room markdown gets list/pre styling (padded bullets instead of browser
defaults flush against the pane edge).
Tests: group-chat.test.mjs +7 (harvest posts late replies and consumes
markers, late (pass) consumes without posting, stranded persistence,
deadline-extension + turn-label + fresh-name source contracts, prompt
quality rule). 245/245 plugin tests pass.
The one-time dock heal from #88788 ('sessions-tab-v1') burned its token even
when its guards skipped the move, and exempted $userPlacedPanes — so exactly
the users who had fought the old stacked layout by dragging panes stayed
stacked forever (empirically reproduced on a live desktop: burned-token and
user-placed installs both keep the Bots pane split below Sessions on current
main). Replace the heal with an enforced dock invariant (dock.enforce: true):
the Bots pane re-homes into the sessions zone's tab strip at every boot's
first adoption pass, idempotent per boot so an intra-session drag sticks
until the next launch. The retired paneDockHeals.v1 ledger is dropped on
store load.
Right-click a bot row -> Hide Bot persists hidden:true through the
existing bot-meta pipeline (local ctx.storage + server profile.yaml
ui_meta via profiles.configure), so the hidden state follows the
profile across machines through mergeServerMeta.
- Hidden bots drop out of the roster list and the Active-now strip.
- A header eye toggle renders only while >=1 bot is hidden; while on,
hidden rows render dimmed (opacity-60, eye-closed glyph) and their
context menu offers Unhide Bot.
- Unhide writes hidden:false (never null): the server deletes None'd
ui_meta keys while the local {...prev, ...patch} merge keeps null
keys, and that asymmetry would let a stale truthy copy resurrect.
- Hiding the selected bot re-homes selection: first visible bot, then
'default'; if default itself is hidden with nothing else visible the
selection stays put so the Routines pane never chases a ghost.
- Hidden bots accumulate unread silently but never toast; the eye
button badges when a hidden bot has unread.
- Display-only: @mentions, group chats, name-collision checks, and the
meta/avatar/activity sweeps all keep the full roster. Known v1
simplification: meta is keyed by bot NAME, so hiding a name hides
every local row of that name (thin remote-source rows never read
local meta and stay visible).