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).
- Bot Mode's pane dock changes to { pane: 'sessions', pos: 'center' }: the
lone-pane header auto-hide trap the old 'bottom' split worked around is
fixed (center gains pin the header shown), so the sidebar grows a
SESSIONS | BOTS tab strip instead of two cramped stacked panes.
- One-time persisted-layout heal (dock heal tokens in the tree store):
installs that adopted under the old split re-home into the sessions strip
exactly once; user-placed panes are never touched and the burned token
never re-fights a user who re-stacks afterward.
- The profile-scoped Cronjobs (routines) pane now registers only while the
Bots pane is on screen, via the contribution disposer driven by the new
host.paneVisibility SDK export (reactive $paneVisible). Feature-detected
in plugin.js with the always-registered fallback for older desktops;
the visibility listener tears down through ctx.onDispose.
The id-based hide sweep (66221397a) only reconciled session ids the plugin
KNOWS — canonical Bot Chats from $botMeta and group rooms' member sids. But
Bot Mode sessions are also minted OUTSIDE the plugin: bot-to-bot handoffs run
`hermes -p <bot> chat -c "Agent Inbox" / -c "Bot Chat" --create-if-missing`
via CLI, and the mention-handoff path can mint a fresh "Bot Chat" beyond the
canonical one. Those ids never enter plugin state, so the sweep never touched
them and they sat visible in the global Sessions sidebar forever (user
report: five "Bot Chat" + two "Agent Inbox" leaked rows).
Add sweepBotProfileSessions(), chained from hideOwnedBotSessions() at the
same two call sites (plugin load + gateway reconnect): enumerate each roster
bot's OWN profile via session.list (visible rows only — naturally idempotent)
and session.set_hidden any row titled exactly 'Bot Chat' or 'Agent Inbox',
or prefixed 'Group: ' (the member-session title ensureGroupChatSession has
always used). Exact-title matching means a user's real conversations inside
a bot profile are never hidden, and non-bot profiles are never listed at all.
Remote-source bots route via requestForBot to their own connection. All
feature-detected and fire-and-forget per row.
The kickoff instruction text is left unchanged: the hermes CLI has no
hidden flag on `chat -c` session creation, so the sweep is the cover for
CLI-born rows.
Builds on @nductien's completion-notify module (PR #87705):
- Notify on the gateway watcher's full terminal set — blocked, gave_up,
crashed, timed_out, block_loop_detected — not just completed. A worker
hitting a blocker while the user is away was the original community ask.
- Route all notification copy through the kanban plugin i18n bundles
(en/ja/zh/zh-hant), with an English-bundle fallback when the translator
isn't bound yet.
- Wire the ctx.os.notify door so events also fire a NATIVE OS notification
while the user is away from the Hermes window (host.notify toast covers
the foreground). OS-door failures are isolated from the toast path.
- Docs: Desktop notifications section in kanban.md, including the
app-running coverage window.
When a background agent completes a Kanban task, the Desktop app now surfaces a native OS notification with the task summary and a one-click "Open Kanban" action.
Rides the existing /events WebSocket (onEventsFrame in api.ts). A
new module completion-notify.ts implements cursor-based per-board
deduplication with baseline from GET /board, fail-closed on unknown
baseline, and reconnection safety. The notification uses the existing
host.notify() and host.navigate() SDK APIs.
Files changed:
- apps/desktop/src/plugins/kanban/api.ts (+8, -1) — wire completion-notify
- apps/desktop/src/plugins/kanban/completion-notify.ts (+89, new)
- apps/desktop/src/plugins/kanban/completion-notify.test.ts (+378, new)
19 targeted tests pass (0 fail).
Group chats in the Hermes Bots roster were rendered as a section header
("HERMES, PAPERCLIP MANAGER" + divider + tiny "Open chat" link) with each
member bot listed underneath — reading as separate DMs, not a group — and
opening the room rendered it INSIDE the narrow bots side panel.
Roster (plugin.js):
- Each group chat is now ONE standalone roster row (GroupRow), Discord-style:
stacked member avatars (BotFace composite, org glyph when empty), group
name, member count, latest room line as the preview with markdown syntax
flattened, relative last-activity time, and the "needs you" badge on the
row itself.
- The roster is a flat list: bot rows and group rows interleaved in the SAME
pin+recency ordering (a group's recency = its newest room-log entry).
Bots keep their individual DM rows; the group is an additional
independent row. The groupRoster() sectioning is removed.
- New stripPreviewMarkdown() flattens **bold**, `code`, > quotes, links,
and headings out of BotRow and GroupRow previews (raw ** and > were
leaking into row previews).
Main-window takeover (sdk/index.ts + plugin.js):
- New generic SDK door host.openWorkspace(id, { render, title, minWidth,
onClose }): registers a placement:'main' pane docked center into the
workspace zone (the same shape session tiles and previews use), wires
registerPaneCloser so tab Close / ⌘W tears the registration down, and
reveals it. Re-opening the same id refreshes in place and re-fronts.
Returns a disposer.
- Clicking a group row calls openGroupChat(): on desktops with
host.openWorkspace the room opens as a tab taking over the MAIN chat
area; older desktops keep the exact in-panel GroupChatWorkspace fallback
(feature-detected, established Bot Mode pattern). Disband closes the
main-window tab too; the composer/round-robin logic is untouched and
renders identically in both hosts.
Tests: roster-groups rewritten for the flat model + new helpers;
group-chat/create-group-chat source contracts updated to the
openGroupChat door; profile-prewarm harness stubs stripPreviewMarkdown.
- Remove the bundled Example Plugin from the desktop renderer plugins
(apps/desktop/src/plugins/example/). Reference/demo plugins live in the
companion hermes-example-plugins repo (already pointed to by
src/plugins/README.md); shipping the counter demo in everyone's Settings
doubled UI noise for no user value.
- Agent plugins section now hides ALL repo-bundled built-ins
(source === 'bundled': browser/browserbase, cron_providers/chronos,
model-providers/deepinfra, platform adapters, image/video backends, …).
The section is the control panel for plugins the user installed
(user/git/project/pip/portable); built-ins ship enabled-by-default and
are configured from their own surfaces. The HIDDEN_KEY_PREFIXES list
stays as a fallback for older backends. Count pill reflects the
filtered list.
- Add an "Applies to" profile scope selector to the Agent plugins section
(same pattern as the Capabilities scope selector): list/toggle any
profile's plugins without switching the whole app. Backend:
plugins.manage now accepts an optional `profile` param via the same
set_hermes_home_override contract as cron.manage / mcp.servers.*;
unscoped calls are unchanged, so older backends keep working.
- i18n: new settings.plugins.agent.appliesTo key (types + en + zh; other
locales fall back through defineLocale).
- Tests: plugins-settings.test.tsx covers bundled hiding, prefix fallback,
count pill, selector visibility, scoped list/toggle payloads; new
tests/test_plugins_manage_profile_scope.py mirrors the cron.manage
profile-scope tests (scoped read, unknown-profile 4064, no override
leak, unscoped contract unchanged).
The BotFace catchlight dots were hardcoded white. On dark bodies
(maroon/ink/oxblood) isDarkColor() flips the pupils to light cream
(eyeFill), so a white catchlight on a cream pupil is invisible — those
avatars looked like they had no dots in their eyes (image14 report).
Catchlight contrast now follows the pupil, not the body: dark pupils
keep the white sparkle, light pupils get a dark one (rgba(0,0,0,0.6)).
The animation clock only moves the hb-hl elements (cx/cy) and never
touches fill, so the initial-render fill carries through every frame.
Verified with a rendered before/after/control strip: BEFORE maroon has
no visible dots, AFTER shows clear dark dots in the cream eyes, light
control body unchanged with white catchlights. Plugin suite green
incl. new face-catchlight.test.mjs.
Partial salvage of PR #88561 (routines-filter-hint half only; the pet
gallery pagination half is out of scope for #88263 and was skipped).
When the cron store has jobs but none surface for the active bot (older
gateway without profile scoping, no [bot:<name>] tags matching), the
Routines pane now explains that cronjobs exist but are hidden, instead
of showing the generic empty state — so users don't believe their
scheduled jobs disappeared.
Port of NousResearch/Hermes-Bot-Mode#95 (repo archived, ported upstream).
Three bugs in apps/desktop/src/plugins/hermes-bots/plugin.js:
1. register() subscribed host.state.profile / host.state.gateway listeners
without capturing the unbind functions, so a plugin disable -> re-enable
cycle stacked a duplicate listener per cycle that kept firing until app
quit (same survives-disable class as the face clock before its
onDispose hook). The unbinds are now captured and released via
ctx.onDispose.
2. CreateRoutineDialog passed `key: createTarget` INSIDE the jsx() props
object. The react/jsx-runtime silently ignores a `key` prop there (key
is the third jsx() argument), so switching the routine owner never
remounted the dialog and it kept stale per-bot form state. Moved the
key to the third argument; the source-shape test now pins the correct
shape and rejects the prop form.
3. beginOAuth() overwrote pollRef.current without clearing an existing
interval, so a retry / double-click while a poll was live orphaned a
2s poller that ran until unmount and could flip the phase from a stale
OAuth session. It now clears any live poll before starting a new one.
Community report: group chats showed neither profile pictures nor proper
names for members (names fixed in #88721; this adds the avatars).
- Every bot message in a group room now carries the speaker's avatar,
resolved through the same botAppearance pipeline as the roster: custom
uploaded/generated/pet images honored, backfilled PNGs dropped so the
animated math face renders, deterministic shape+color for bots with no
customization (including remote speakers with no local meta).
- The room header shows the member roster as overlapping faces (capped
at 6) with a display-name tooltip, alongside the existing count.
Community report: in a Bot Mode group chat with the primary agent, Hermes
lost its name and rendered as @default in the room.
- Workspace speaker labels now render the roster displayName (default →
Hermes, titles respected) instead of the raw @profile id. Clicking a
speaker reveals the full disambiguated form — Display-gatewayname
(@handle) — so same-named agents on two connections stay tellable
apart on demand; naturally they just show their display name.
- Room transcripts fed to members render the default profile as Hermes
(new groupSpeakerLabel helper).
- The per-turn prompt addresses members by @handle (@hermes for the
primary profile) instead of @default, and parseGroupChatMentions now
resolves @hermes back to the default member via botHandle.
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.
Follow-up to #88664. The remote @mention delivery path had three gaps that
made cross-machine DMs half-work:
1. No reply relay: deliverRemoteRosterMentions submitted the prompt and
toasted, but never polled — the handoff note promised a relay that never
came. Now a bounded poll (same shape as a group member turn: new
assistant message after the baseline, 180s cap) relays the recipient's
reply as a notification, or says it's still pending.
2. No sender attribution: the raw user text was submitted, so the
recipient's messaging protocol never recognized an agent-to-agent
message. Deliveries now carry the standard
"Message from 🤖 <sender> (@handle):" prefix.
3. Duplicate Bot Chats: every mention minted a fresh "Bot Chat" session.
ensureRemoteCanonicalChat now resolves the recipient's pinned canonical
chat from its profile ui_meta, falls back to resume-by-title, and only
creates when neither exists — mirroring ensureGroupChatSession.
Tests: remote-dm-delivery.test.mjs (pin-resume without create, attribution
prefix + reply relay via vm-run behavior tests, source contract for the
bounded poll). Plugin suite 187/187.
The BOTS sidebar previewed each profile's most recently active session
(last_session) but clicking the row opened the pinned canonical chat —
two different session identities, so the preview described one
conversation and the click landed in another.
- profiles.list gains an optional preferred_session_ids param
({profile: session_id}): an exact, existence-checked per-profile
lookup that resolves hidden rows and compression lineages to the
live tip (the same resolver session.resume uses) and returns a
preferred_session summary alongside the unchanged last_session.
- The hermes-bots plugin sends its canonical-chat pins with each
roster poll and previews preferred_session ?? last_session.
- openBotCanonicalChat verifies pins through the precise resolver
instead of a paginated, hidden-excluding session.list window that
misjudged real hidden pins as gone; transient lookup failures no
longer clear the pin or mint a replacement chat.
- Grandfathering: a bot with history but no pin adopts the previewed
session on first open instead of minting a new empty chat — the
behavior the design comment already promised.
Closes#88200
A failed intro used to clear the pin even though the chat was already made, so the next click opened a second one. A missing pin grabbed the newest session, even when a real Bot Chat was in the list.
Failed intros now keep the pin. A missing pin looks for a session titled Bot Chat. If that is not there, we try the stored pin instead of the newest row.
Ported from NousResearch/Hermes-Bot-Mode#59 after Bot Mode moved in-tree.
Builds on the salvaged #88598 multi-source roster work:
- New Agent "Create on" picker (multi-connection registries only): the
profiles.create/configure/describe/mcp.catalog calls route to the picked
connection's backend via host.requestProfile route descriptors — the
window's active gateway never switches. Remote-target drafts discard via
the remote CLI; appearance/title write into the remote profile's ui_meta
and asset store; the taken-name check is scoped to the target machine's
roster; the live SkillsView Capabilities tab (active-gateway-bound) falls
back to the staged checklists for remote targets.
- Group chats can seat bots from other registered connections: member turns
(session.create/resume, prompt.submit, reply polling) route to each
member's own source through the new requestForBot helper. Remote member
descriptors persist on the room record (bot-meta is active-gateway-scoped
by design); watermarks/sessions key by source-qualified member keys so
same-named agents on two machines never share state; @name-device handles
resolve in room mentions; room lines and turn prompts badge cross-machine
speakers with their device.
- pidIsOurDashboard: a dead remote PID (ps exits non-zero) now reads as
FOREIGN instead of throwing "Could not verify SSH backend process
ownership" — the misleading error from #88625's secondary report.
Tests: cross-connection-bots.test.mjs (route descriptors, requestForBot
routing, member keys, disambiguated mentions, device badges, source
contracts); plugin suite 180/180; electron vitest 220/220.
Stay on the default gateway. Bot Mode still lists every Connections
agent. Clicking a remote row no longer hops the window onto SSH —
@dixie / @bob-spark in this chat resolve against the roster and
Desktop delivers in the background via requestProfile.
Clicking Mac Mini / Spark (the device default row) passed the desktop
pool key as the remote Hermes profile. That profile does not exist, so
the chat never opened. Named profiles (bob, dixie) already sent a real
name and worked.
Also refuse to fall back to this-device's default chat pin when the
remote source did not actually become active.
Remembered remotes were restored whenever the union omitted their
connection id, so a deleted registry source could keep resurrecting
until remount. Only restore rows that still belong to a registered
source.
Clicking the local agent left connectionId null, so the roster treated
the registry primary (often an SSH box) as active and dropped its
profiles while inventing a "This device" shadow of default.
Inventory undialed SSH sources with a cached ls of ~/.hermes/profiles
instead of requiring the window to switch onto that machine. Hostile
HERMES_HOME values are rejected before the listing command runs.
Group chats could be created but never deleted — the only way out was
manually ungrouping every member, which still left the shared room log
in plugin storage forever.
The group-chat workspace header now has a trash button behind a
ConfirmDialog. Disband is soft: it clears every member's group
assignment (syncs cross-machine via ui_meta), drops the room log from
the atom and the persisted group-chats map, clears the needs-you badge,
and closes the room view. The members' per-group gateway sessions
("Group: <name>") are intentionally kept and remain reachable from each
bot's session browser. A room with a drive still in flight leaves a
runtime-only epoch-bumped tombstone so the round-robin loop bails at its
next member boundary; the tombstone is never persisted.
Exports createBudgetedLoop (+types) through the plugin SDK and migrates the
Bots plugin's face-animation clock onto it. The hand-rolled clock only
checked document.hidden; via the shared loop it now also pauses while the
window is minimized or unfocused, matching every other desktop render loop.
- sdk/index.ts: export createBudgetedLoop/BudgetedLoop/BudgetedLoopOptions
from @/lib/budgeted-loop with plugin-facing guidance
- hermes-bots/plugin.js: startFaceClock delegates scheduling to the SDK loop
when present (fps 15, idleWhen = no visible faces); paint body, IO-based
visibility tracking, and the 1Hz rescan are shared; the hand-rolled rAF
path remains as a feature-detected fallback for older desktops, per the
plugin's established SkillsView/McpTab pattern
- tests: new SDK-path case (fps/idleWhen wiring, visibility wake, re-entry
wake, dispose-on-stop) driven through an injected fake loop; verified to
fail with the SDK branch removed; existing fallback-path tests unchanged
Follow-up to #88523/#88542 from a community report (main agent listed twice,
both rows unnamed @default handles). Two distinct bugs, both specific to
remote-gateway-primary desktops:
1. Phantom "This device" default (electron): the roster enumeration dialed
ensureRegistryBackend for the registry's local entry unconditionally. On a
remote-primary desktop that forces resolveRegistryLocalRoute into the
forced-local branch — SPAWNING a local backend the user never asked for.
That backend enumerates a `default` profile, so a second default agent
appears AND the duplicate-handle rule forces -device suffixes onto the
real one. New shouldDeferLocalEnumeration() treats the forced-local route
as connect-on-demand (same courtesy as undialed SSH sources): the local
entry only enumerates when it is the delegate route (local-primary
desktops, byte-identical behavior) or a forced-local child is already
pooled (the user opened one).
2. Main agent renamed to a connection label (plugin): displayName keyed the
"show the connection label for a default row" rule off sourceScoped —
which annotation also sets on ACTIVE-source rows. A remote-gateway user's
main agent rendered as an IP-derived label (or bare handle) instead of
"Hermes"/their title. Key it off remoteSource: only THIN rows from
another source trade the friendly name for their source label.
Tests: shouldDeferLocalEnumeration route matrix (delegate always enumerates;
forced-local defers until a conn:local:: child exists; bare-key remote
descriptor doesn't count), displayName regression (active default stays
Hermes/title; thin remote default still shows its source label).