Salvage of saralilyb's remote-bot routing work onto current main:
- kept: immutable (connectionId, profile) owner capture, requestForBot
routing, backendTargetProfile aliasing, group session owners,
connection-qualified deletion, focused-owner atoms, remote roster
merge, Electron profile-delete routing, sdk/store/transcript changes
- reconciled: canonical Bot Chat resolution stays NAME-identity (the
'Bot Chat' registry row) and FAIL-CLOSED on lookup errors — now
consulted on the bot's own source via the captured owner route, so
remote bots get the same no-fork guarantees
- dropped: pointer-pin plumbing (preferredSessionIds, saveBotMeta chat
writes, pin verification) — superseded by name-identity on main;
renderer-side remote DM delivery (deliverRemoteRosterMentions /
pollRemoteDmReply / ensureRemoteCanonicalChat) — superseded by the
message_agent tool architecture (#91802/#91915: middleware identifies,
never delivers); pointer-era test files deleted on main
- openStoredBotChat/createCanonicalChat: remote opens keep Desktop's
chrome home (keepAllProfilesScope: true on routed opens); local bots
keep the measured workspace re-home
- prepareBotSource: capability gate only — routed RPCs never require
activation authority
findExistingCanonicalChat() swallowed every lookup error and returned
null — indistinguishable from 'this bot has no Bot Chat yet' — so a
transient RPC failure against a just-restarted backend (the exact
post-desktop-update window) sent createCanonicalChat() straight to
session.create, minting a fresh 'Bot Chat' while the real one (data
intact, hidden) still held the canonical title. Users experienced this
as bots losing all context after every desktop update.
The lookup now fails CLOSED: a failed registry consultation throws,
both open paths surface their existing 'try again' toast, and
session.create can never fire off an unknown ownership state.
Tests: two new VM-executed regression tests (sabotage-verified — both
fail with the old fail-open catch); hide-bot-chats source-shape regex
updated for the new layout. 364/364 plugin tests green.
A bot's forever-chat now has exactly one identity: the session titled
"Bot Chat" on that bot's profile. Core UNIQUE(title) makes (profile,
'Bot Chat') an exact registry, and every open consults it directly via
session.list {title, include_hidden}. The stored-id pin
(ui_meta['hermes-bots'].chat) and its entire verification apparatus —
preferred_session_ids resolution, drifted-pin keep branches, last_session
grandfathering, dead-pin recovery re-anchoring, newerVisibleBotChat — are
removed, not deprecated. Legacy ui_meta.chat keys are ignored and dropped
from merges on sight.
Every lost-canonical-chat incident (#88146, #88200, #90524, #90705, and
five hardening waves) traced to that pointer dangling or being stolen,
then later guards welding the wrong session in. A name cannot dangle:
corrupt pins self-heal on first click because the pointer is simply never
read.
Gateway: profiles.list now reports canonical_session per profile row
(registry row resolved server-side by title — hidden rows resolve,
deny-listed sources and archived rows do not, compression lineages
resolve to the live tip), replacing the preferred_session_ids request
contract. The roster preview, activity signals, and the /new→/compact
guard all read canonical_session, so preview identity and click identity
are the same row by construction.
No migration shims: this IS the system.
Partially reverts the newer-visible-session preference from #91791
(salvage of #91258), which made the pinned canonical Bot Chat
unreachable. Fixes#92040.
Canonical Bot Chats are ALWAYS hidden from the Sessions sidebar:
session.create passes hidden:true unconditionally and
hideOwnedBotSessions() sweeps any that were born visible (asserted in
tests/hide-bot-chats.test.mjs). The bot row is therefore the ONLY
entry point to a bot's forever-chat, so preferring the profile's
freshest visible session did not re-order two equivalent doors — it
removed the only one. Reported symptom: a 106-message bot-building
conversation with no reachable entry point anywhere in the UI, while
the row previewed one session and opened another (a regression of the
preview/click identity #88200 established).
The report behind #91791 was real but has a non-destructive answer:
scratch sessions started via "New chat with this agent" are not
plumbing-titled, so neither hideOwnedBotSessions() nor
sweepBotProfileSessions() hides them (the sweep matches the exact
titles 'Bot Chat' / 'Agent Inbox' / 'Group: …'). They stay listed in
the Sessions sidebar and are reachable there; they simply are not what
the bot row targets, which is by design.
Changes:
- openBotCanonicalChat: when the pin is alive and verified, open it
directly. The newerVisibleBotChat preference is removed from that
branch only; the helper stays for the dead-pin recovery path.
- Drop the now-unused latestVisible parameter and its argument at the
BotRow call site. The second call site already passed three args.
- tests/bot-row-opens-latest.test.mjs ->
tests/bot-row-opens-canonical-chat.test.mjs: the two tests that
asserted the newer-session behaviour are rewritten rather than
deleted, so the reasoning survives in the suite. Adds a source-level
guard ("the healthy-pin branch never prefers a newer visible
session") so this cannot silently regress. The deleted-newer-session
fallback test covered a path that no longer exists; replaced with one
asserting a failed open of a verified pin propagates instead of
forking the forever-chat.
The keepAllProfilesScope: false half of #91791 is untouched.
Plugin suite: 392 pass, 0 fail.
The composer middleware is now identification-only: it resolves the
user's @tags against the live roster and annotates the draft with who
they refer to (profile, friendly title, device for cross-connection
rows). The agent decides whether to contact them and does it through
its message_agent tool — one send path, composed messages only.
Deleted the renderer's entire parallel delivery transport:
deliverRemoteRosterMentions / pollRemoteDmReply /
ensureRemoteCanonicalChat and the injected shellout instructions
('[@mention handoff — run hermes -p …]' and 'Desktop is delivering …
over Connections'). This retires the whole invocation bug class at the
source instead of sanitizing it: no verbatim user text is ever
forwarded by the renderer (#91397), and no shell command is ever
composed from prompt text (#91304, #91339 shape).
Tests: mention-identification.test.mjs replaces the two delivery-era
files — identification note shape, no-shellout/no-delivery containment
(sabotage-verified: re-adding a renderer delivery call fails 2 tests),
poisoned-title inertness, pass-through for unknown @s, and a source
contract pinning the deleted machinery. hide-bots + roster-cache-key
harnesses re-pinned to the new contract. 390/390 green.
Clicking a bot in the roster always reopened its pinned canonical Bot Chat.
Start a new conversation with bot A, click bot B, click back to A — the new
conversation was gone, replaced by the pinned transcript. A bot row is a
workspace entry point, so it has to land on the live conversation.
Two independent causes, both fixed here:
1. The pin overrode newer work.
`openBotCanonicalChat` opened the pin unconditionally. It now prefers the
bot's freshest VISIBLE session — but only AFTER `profiles.list` has
verified through `preferred_session` that the pin is alive and is a real
canonical Bot Chat. That ordering matters: with a dead or unverified pin,
adopting the profile's latest row would claim an unrelated user
conversation as the bot's chat, and the hide sweep would then hide it.
The existing "no pin" / "dead pin" safety tests cover exactly that and
still pass. The pin keeps owning plumbing (creation, hide sweep, DM
delivery); it just stops shadowing newer conversations.
Guards on the candidate (`newerVisibleBotChat`): the canonical chat can
never shadow itself, an empty draft never displaces a real conversation,
and a gateway that omits `message_count` is treated as real history
rather than discarded.
2. The workspace did not follow the bot.
The three `host.openSession` calls on the bot path relied on the SDK
default `keepAllProfilesScope: true`, so `$activeGatewayProfile` stayed on
whatever profile was active before the click. Sessions created afterwards
were then filed under the previous bot's profile — measured: four new
chats started from three different bots all persisted into one profile's
state.db. Clicking a bot IS a profile switch, so these pass `false`.
Note on the call shape: `previewSession` is `bot.preferred_session || last`,
so on a pinned bot it resolves to the PIN (preview identity must match click
identity). Feeding that as the "newer" candidate makes the whole preference
dead code — it always sees the pin and short-circuits on "same id". The
freshest visible session therefore arrives as its own argument. The first
attempt at this fix had that bug and passed its tests, which is why
`bot-row-opens-latest.test.mjs` mirrors the production call site argument for
argument rather than constructing a convenient one.
Tests: 362 pass (was 348). Each new guard was verified by sabotage — reverting
any one of the three behaviours above makes the suite fail (1, 3, and 1 tests
respectively), so none of them is a test that passes either way.
PaneTab gated its hover close button on two independent inputs: the
onClose verb, and a showCloseButton prop that TreeGroup fed from a
showCloseButton flag on the pane contribution. The middle-click and
Meta-click gestures read only onClose. A tab could therefore close on a
pointer gesture and advertise no control for it.
The flag had no user that hideOnly did not already cover. Both setters
also set hideOnly: true, which removes every close gesture:
- the sessions pane (app/contrib/controller.tsx),
- the Bots pane (plugins/hermes-bots/plugin.js).
The flag was an opt-out marker with no reachable effect, so this change
deletes it instead of teaching it to track the gestures. onClose alone
now decides both shapes. A tab that closes shows the button. A tab
without the verb shows nothing. To make a tab uncloseable, give it no
close verb.
hideOnly and uncloseable keep their meaning. They gate the verb, and
both shapes follow the verb together.
The DialogContent and SheetContent prop of the same name is a different
prop and stays. It has no close verb to derive from, and one caller
changes it while the dialog is open.
Tests: the new tab-close-affordance test renders the real TreeGroup and
asserts that button presence equals middle-click closure. It covers
hideOnly chrome, a plain side pane, the uncloseable workspace, and a
session tile. It reads closure from the layout tree, not from a spy, so
a wired-up mock cannot pass it. A regression that hides the button on a
closeable tab fails two of the four cases. The compiler rejects the
deleted prop, so the test carries no fixture for it. The pane-tab unit
test moves off the deleted prop.
Verified with the full apps/desktop vitest suite, npm run typecheck, and
npm run lint. Two electron process-spawn tests fail on this machine.
They also fail on a clean tree, and they do not touch the pane shell.
deliver='bot-chat[:<profile>]' is a machine-local pseudo-platform: the
scheduler delivers job output as a real inbound turn in the target
profile's canonical Bot Chat via the chat CLI lane (--in ~ -c "Bot Chat"
--create-if-missing -Q --query-file), the same lane Bot Mode
agent-to-agent messages use. The bot reads the output, acts on it, and
responds in its chat — instead of the output only landing in Run history.
- cron/scheduler.py: token parsing, target resolution (own profile /
named local profile / unknown -> skipped with warning), subprocess
delivery lane with cron.bot_chat_delivery_timeout_seconds (default
600s), preflight exemption, and bot-chat entries in
cron_delivery_targets() for UI pickers. Excluded from 'all' by design.
- tools/cronjob_tools.py: create/update-time validation — named profiles
must exist on this machine (fail at create, not at 3am); deliver schema
documents the new token.
- tui_gateway/methods_tools.py: cron.manage add forwards deliver.
- hermes_cli/profiles.py: list_profile_names() cheap name-only scan.
- hermes-bots plugin: Create Cronjob dialog gains a 'Send results to'
picker (Run history only / <bot>'s chat); bot-chat jobs send the BARE
token on the profile-scoped create so Desktop-side aliases can never
name a profile the backend doesn't have.
- Docs: user cron guide, automate-with-cron, cron-internals.
Machine-local by construction: names resolve only against the executing
machine's ~/.hermes/profiles/, so overlapping profile names across
multiple connected gateways are unambiguous.
updateGroupChat's inline durable-map builder (the local-mutation persist
path) skips tombstoned rooms and carries roomId — but durableGroupChatRooms,
the SEPARATE builder persistGroupChatRooms uses for the remote-merge path
(every pullGroupChatServerState / gateway-swap sync), has neither.
Two independent gaps in the same function:
1. Tombstone resurrection. Disband sets a runtime-only tombstone
({tombstone: true, log: [], ...}) while a drive may still be mid-turn,
with no roomId. mergeRemoteGroupChatSnapshotIntoRooms spreads
...existing before its explicit field overrides (none of which touch
tombstone), so if a remote gateway hasn't received the delete yet
(plausible now that sync fans out to every reachable default-profile
gateway with independent per-connection backoff) and still has a live
copy of the room, the tombstone flag survives into the merged room.
That merged map is handed straight to persistGroupChatRooms, which
wrote it to storage because durableGroupChatRooms had no tombstone
check. On the next cold hydrate the persisted tombstone reads back as
an empty, non-tombstoned room, resurrecting the original bug
(recreating a room under the same name silently becomes "<name> 2")
through a path the earlier tombstone fix didn't cover.
2. roomId loss. mergeRemoteGroupChatSnapshotIntoRooms correctly carries
roomId into the merged in-memory room, but durableGroupChatRooms never
included it in the persisted snapshot. Every room merged in via the
remote-sync path therefore loses its immutable room identity on the
next cold hydrate (comes back with roomId: null) and falls back to
legacy name-keyed identity — breaking id-based rename/merge resolution
and member-session titling ("Group: <roomId>").
Fix: durableGroupChatRooms now mirrors updateGroupChat's inline map
exactly — skip tombstones, carry roomId.
Tests: durableGroupChatRooms unit tests for both gaps, plus an
end-to-end reachability test (tombstone -> mergeRemoteGroupChatSnapshot-
IntoRooms -> persistGroupChatRooms -> storage) proving the merge really
does forward the tombstone and the fix really does keep it out of
storage. Mutation-verified against pre-fix code (all 3 new tests fail).
Full hermes-bots plugin test suite (60+ files) green.
Resolves the room-lifecycle class on top of the salvaged #89369 projection:
- v3 projection keys rooms by immutable roomId (id:<roomId>) with
name:<name> fallback for legacy rooms; v1/v2 envelopes are normalized
on read so mixed-version fleets share one merge path
- rename is now a same-key field update — no distributed delete+create,
no old-name resurrection from lagging gateways
- id tombstones are FINAL (ids are never reused), so a gateway that was
offline during a disband can never resurrect the room, regardless of
the revision its stale copy carries; same-name recreation is unaffected
because it mints a fresh roomId
- the projection fans out to EVERY reachable default-profile gateway
(per-gateway job queues, CAS revision streams, backoff and retry caps),
so rooms survive any single gateway dying and surface on gateway-only
clients without waiting for a Desktop to foreground that gateway
- cold hydrate follows a remote rename via roomId instead of duplicating
the room under both names
New tests: id-keyed rename continuity, final id-tombstones vs lagging
high-revision copies, rename-job shape (changed+deleted same key),
cold-hydrate re-keying, multi-gateway fan-out. Sabotage-verified: each
new test fails against the pre-class behavior.
Two follow-ups to the salvaged #89369 base against current main:
- compact projection entries no longer overwrite the local rich copy
(attachments survive; watermark accounting stays stable)
- synthetic legacy-N thread ids collapse to one bucket in the entry key,
so id-less entries don't duplicate after a pull and manufacture
phantom member turns into busy sessions
useRoster() keys its query on [...ROSTER_KEY, connectionId] so every
connection the window has been on gets its own cache entry, but the two
imperative roster readers — the @mention completions and the mention
middleware — still called getQueryData(ROSTER_KEY). TanStack Query's
getQueryData is an exact-key match, so the bare key matched nothing and
both readers saw an empty roster: the composer offered no agent handles,
and remote @name-device mentions (e.g. @default-vera for a same-named
profile on an SSH connection) fell through to the local profiles.list
fallback, which only knows bare local names — the mention passed through
untouched to the local agent instead of being routed over Connections.
Add a cachedRoster() helper: honor the legacy bare-key write first, then
scan the key family with getQueriesData, preferring the active
connection's entry and falling back to any other entry (a stale roster
beats none). Never throws, matching the readers' existing posture.
Regression tests model the real cache shape — entries under the suffixed
key, exact-key reads missing them — so the key-shape mismatch is pinned;
the previous tests stubbed getQueryData to ignore keys, which is exactly
why this slipped through.
useRoster caches under [...ROSTER_KEY, activeConnectionId] — a 3-element
key — but the composer @ autocomplete provider and the mention-routing
middleware both read getQueryData(ROSTER_KEY) with the bare 2-element
key. An exact-match lookup against a 3-element cache key returns
undefined forever, so:
- @ autocomplete offered ZERO bot rows (local and remote alike)
- remote-bot mentions never matched the union roster and fell back to a
local-only profiles.list, so @name-device mentions did not route
That is issue #89303: the remote-source Bot row tells the user to
`@name-device` the agent, and the completion surface never offers it.
Fix: cachedUnionRoster() — read the live connection's cache entry first
(getQueryData with the full 3-element key), fall back to a prefix scan
(getQueriesData) keeping the freshest snapshot. Never throws; callers
keep their existing cold-cache fallbacks.
The plugin test mocked getQueryData as key-agnostic, which is why this
never failed in CI: the mock now reproduces the real QueryClient v5
exact-key semantics (a bare ROSTER_KEY lookup misses the 3-element
cache), and the tests fail against the old code (verified: 2 failures
before the plugin fix, 9/9 pass after).
Fixes#89303
Keep-alive workspace panes (#89788) stay mounted while hidden, so returning
to an already-open room never remounted GroupChatWorkspace and the
mount-time bottom anchor didn't rerun — reopening group A after visiting
group B left A at the old scroll position. GroupChatMainView now subscribes
to the pane's visibility via feature-detected host.paneVisibility (always-
visible atom fallback for older SDKs) and the workspace scrolls the bottom
sentinel into view on the hidden→visible edge. Repro and fix direction from
the live-audit review comment on #90526.
Also updates the composer shape test to slice only GroupMentionInput
(GroupClarifyCard, merged since the branch was cut, legitimately uses
Input) and teaches the legacy-SDK react proxy about useMemo.
Composer was a single-line Input (Enter always submitted, newlines
impossible) — now the SDK Textarea with Enter=send / Shift+Enter=newline
and popover-first key handling. Room log had no scroll anchoring (opened
at position 0) — bottom sentinel + near-bottom-guarded anchor effect.
Stranded replies were only harvested inside an active turn loop (stuck
until the user's next send) — the settle path now runs a bounded
background harvest that yields to a live loop.
Found in live GUI E2E of the roomId salvage: disbanding a room while a
drive is mid-turn leaves an epoch-bump tombstone under the room's key.
Two defects surfaced: (1) updateGroupChat persists the WHOLE atom map, so
the next unrelated room write persisted the tombstone as an empty room;
(2) create/rename collision sets counted the tombstone's key, so
recreating the group under the same display name silently became
'<name> 2'. Tombstones are now flagged, excluded from the durable map,
from create/rename collision sets (liveGroupChatNames), and from roster
rows. Regression coverage in the existing tombstone test.
New rooms title member sessions 'Group: <roomId>'; the dialog copy now
describes the kept sessions generically instead of quoting a title that is
wrong for post-roomId rooms.
Adopt upstream's one-hidden-forever-chat model and remove the per-bot hidden
session browser while preserving connection-bound remote Bot opening, the full
remote action menu, same-name isolation, and global Sessions navigation.
Disband removed the room log and each member's group membership, but
BotsPane's mergeServerMeta then overlaid the STALE cached roster snapshot
(fetched before the disband) whose ui_meta['hermes-bots'] still carried the
old groups array — spreading it back over local meta and re-listing the
group as an empty row. Any later meta write for the bot (pin, title,
canonical-chat pointer) re-uploaded the resurrected membership server-side,
making the ghost permanent. Same stale-overlay class could revert renames,
re-group left members, and undo pins/hides.
Fix (class-level, not per-field):
- saveBotMeta stamps a per-bot last-local-write time (re-stamped when the
profiles.configure write settles).
- useRoster stamps each snapshot with its fetch ISSUE time (fetchedAt).
- mergeServerMeta skips overlaying any bot whose local write post-dates the
snapshot; the next (fresh) fetch overlays normally, so server truth still
gets the last word. No-fetchedAt callers behave exactly as before.
- disbandGroupChat / renameGroupChat invalidate the roster query so all
surfaces converge on a fresh snapshot immediately.
Tests: new regression (stale snapshot cannot resurrect a disbanded
membership; fresh snapshot still wins) proven to fail without the fix via
sabotage run; fence-off compatibility test; hide-bots shape regex updated.
341/341 plugin tests pass.
Approvals (pending_approval) had the identical blind spot as clarify:
blocked server-side in the hidden member session, invisible until timeout.
- syncGroupClarify mirrors pending_approval alongside pending_clarify
(clarify outranks when both appear); entries carry kind, command, the
server's choice set (once/session/always/deny, fallback once/deny), and
the runtime session id approval.respond keys on.
- The room card renders approvals as command-in-code + choice buttons
(closed set, no free text; deny tinted destructive) and routes
approval.respond via the member's own source.
- 6 new tests incl. an end-to-end blocked-on-approval drive; sabotage
run (approval mirroring disabled) fails all 4 behavioral tests.
Group members run in hidden plumbing sessions, so a member's clarify tool
blocked server-side with no surface to answer it — the room showed
'@lead is thinking…' until the 300s clarify timeout (salihsungur's report).
- The turn poll and the stranded-harvest pass mirror each member's
`pending_clarify` resume field into $groupClarify and hold the turn
deadline open while a question waits (bounded by the existing hard cap).
- The room renders a question card per blocked member — choice buttons,
free-text, batch sub-questions — and answers route via clarify.respond
through the member's OWN source (requestForBot), so cross-connection
members work. The answered exchange echoes into the room log.
- needs-you badges the roster row while a question waits; disband/rename
clear mirrored cards; older backends without pending_clarify no-op.
- 7 new tests incl. an end-to-end blocked-turn drive, sabotage-verified
(disabling the poll gate fails the drive test).
PR #90732 removed the per-bot Sessions browser (right-click -> Sessions),
but the Disband-group confirm dialog still told users they could open the
kept 'Group: X' sessions 'from each bot's session browser' - an affordance
that no longer exists. Drop the stale clause; also update the one test
comment that still described the removed workspace.
Review follow-up from #90732 (found by the 3-angle review pass).
The #90732 adoption scan used session.list's 200-row recency window. A busy
bot profile (group-chat traffic, routines, or accumulated fork spam) pushes
an older forever-chat past row 200, the scan misses it, and the mint path
re-enters the unique-title-conflict fork loop — same pathology, higher
trigger threshold.
Profile → Named Session is an exact registry (UNIQUE title index), so
consult it exactly:
- session.list gains a `title` param: indexed WHERE title = ? lookup,
window-free, hidden rows resolve, archived/deny-listed do not,
compression lineages resolve to the live tip (resolved_id), mirroring
profiles.list's preferred_session resolver.
- findExistingCanonicalChat sends title: 'Bot Chat'. Older gateways ignore
the unknown param and return the windowed listing — the local scan stays
as the compatibility rung.
- Adoption opens the lineage tip (resolved_id) while pinning the durable id,
same split as the preferred_session path.
Symptom: switching between bots forked a brand-new "Bot Chat" for the
returned-to bot on EVERY switch, burying the user's real forever-chat
(one report: a 930-message chat displaced by 7 forks in one morning).
Root cause is a self-perpetuating loop between three parties:
- state.db enforces UNIQUE(title): the first fork permanently squats
the "Bot Chat" title; every later mint's title request is silently
dropped by set_session_title (returns 0, no error to the caller).
- The post-turn LLM auto-titler then names the untitled fork from its
kickoff content ("Assistant introduction request #2", ...).
- openBotCanonicalChat's identity check is title-string matching, so
the renamed fork reads as "not plumbing" -> corrupted metadata ->
clear pin -> mint again. Grandfathered pre-convention chats (real
history, derived titles) hit the same branch and are forked away
from immediately.
Fix, two invariants:
1. Adopt-before-mint: createCanonicalChat first scans the profile via
session.list include_hidden:true for an existing "Bot Chat" row and
re-pins it instead of creating. The UNIQUE index makes this an exact
registry lookup (at most one match), not a heuristic. Older gateways
without include_hidden find nothing and fall through to mint.
2. A pin that resolves to a NON-plumbing session carrying real history
is the user's conversation - keep it and open it (title drift is
metadata damage, not ownership loss). Only a pin resolving to an
EMPTY stray draft is treated as corrupted and replaced (which now
goes through adoption first).
Also removes the right-click -> Sessions per-bot stored-session browser
(ProfileSessionsWorkspace and its atoms/query/rows). Bot Mode's product
contract is ONE forever-chat per bot; a browser listing every hidden
plumbing session contradicts that and confused users into opening dead
forks. The Sessions workspace test goes with it; the include_hidden
source-shape test now pins the adoption scan instead, and a new suite
(canonical-chat-adopt-before-mint.test.mjs) covers both invariants plus
the older-gateway fallback.
The #89788 gate read main-tab ownership from a plain module Map — invisible
to React — and openGroupChat set the selection atom before recording the
tab. Every open therefore rendered BotsPane in a selected-but-unowned
window, painting the in-pane room beside the main tab, and the duplicate
stuck because the later Map write repaints nothing.
- $groupMainTabsRev atom shadows tab-map membership; all mutations go
through recordGroupMainTab/dropGroupMainTab; BotsPane subscribes, so the
in-pane gate re-evaluates on tab open/close.
- openGroupChat records the tab BEFORE setting the selection atom; older
desktops without the main-window door (and a throwing door) still get
the in-pane fallback.
- Regression tests: gate is false at the instant the selection atom flips
(fails on the old ordering — sabotage-verified), and rev bumps on tab
open/close.
Two things a genuinely fresh instance surfaced that no existing profile could.
The renderer's mode fell back to `light` when nothing was stored, so a
dark-mode desktop opened a white window on first launch. Main already
defaulted its own themeSource to `system`, so the two disagreed at boot — and
once translucency became per-appearance it also handed those users light's
much heavier tint, tuned for a bright desktop they don't have. Both the
normalizer and the SSR fallback now say `system`; an explicit choice still
wins.
The accent plugin reached straight into `@/components` and `@/themes`, which
the plugin lint rule exists to prevent: plugins import `@hermes/plugin-sdk`
and nothing else, so the app can move its internals without breaking them.
The fix is to widen the SDK rather than exempt the plugin — it now exports the
OKLCH colour maths, `useTheme`, `retintTheme`, and the accent override, so any
plugin can derive a palette instead of hardcoding one.
Finding a colour by hex is guesswork; finding one by eye needs a picker that
does not lie about where you will land. HSV crushes the whole blue family
into a narrow band of its hue rail, so dragging "to blue" puts you on pure
sRGB blue, which reads violet — every blue that actually looks blue lives in
a few degrees you cannot reliably hit there.
This one is OKLCH. The hue rail is perceptually even and previews the
current colour at every hue rather than showing a generic rainbow, and the
field is a canvas drawn per-pixel through the real conversion, so its curved
edge is the true sRGB gamut boundary — every pixel is a colour the display
can show. Dragging repaints the whole app against the real derivation.
It ships off (`defaultEnabled: false`) and holds no persisted state: the
override clears on dispose, so turning the plugin off returns every surface
to the authored theme rather than stranding a colour with no control to
clear it. The retint itself stays in core, where Appearance settings and the
command palette can reach it.
Renaming a bot (Bot Mode title or 'hermes profile rename' display_name)
changed the roster row but not what the user could @-tag it with — mentions
still only resolved the original profile handle, and the composer
autocomplete never offered the new name.
- mentionNameForms()/botFriendlyNames()/botMentionTag(): one resolver for
the taggable forms a friendly name yields (slugged + collapsed), with
reserved tokens (hermes/default/everyone/all/user) excluded so a rename
can never hijack them.
- resolveRosterMentions() and parseGroupChatMentions() accept the friendly
forms alongside the profile name/handle (both keep working).
- Composer @ autocomplete (global provider + group-room popover) inserts
the renamed tag and prefix-matches on tag, handle, and display name.
- Mention middleware's cold-cache fallback now runs the same resolver
instead of a bare-names-only parse, so renamed tags resolve there too.
- durableGroupChatMembers persists title/display_name so renamed-tag
mentions survive connection switches in cross-machine rooms.
- Docs: bot-mode.md documents renamed tags.
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.
Preserve current upstream Desktop and Bot Mode behavior while carrying the
connection-bound remote Bot routing implementation forward without rewriting
the signed feature commit.
Carry immutable connection and profile ownership through Bot Mode actions,
session hydration, transcript loading, new chats, and profile deletion.
Keep same-named local and remote profiles isolated, preserve the full context
menu, and migrate Bot metadata to connection-qualified storage.
Add adversarial routing, alias, migration, cache, and compatibility coverage.