Cmd+R over a focused page reloads that page instead of the whole shell,
a trackpad swipe walks its history, and a mouse's back/forward buttons
do the same. Shift+Cmd+R still force-reloads the window.
All three arrive as native input, so main decides. A webview guest is
out-of-process: when the pointer and focus are inside the page, no
renderer signal sees it — not activeElement, not the layout tree's
hover/focus ladder. Main asks Electron for the focused webContents and
acts on it directly, and only falls through to the renderer when the
gesture landed on Hermes' own chrome.
Also adds a Reload window command to the palette, since Cmd+R no longer
always means that.
The Browser tab could only show whatever opened it: no back, no forward,
no way to type an address. Adds the row every browser has — back /
forward / reload / address — above the page.
The console and DevTools toggles move here from the zone strip. They act
on one page, so they belong beside its address rather than on a strip
shared with every other tab; that also frees the space the address bar
needed. Nothing else contributed strip tools, so the extension point
goes with them.
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.
nextSessionTileForWorkspace() only walked tabs stacked WITH the workspace
tab. In a side-by-side layout (session tiles in their own zones — db's
three-pane report), the walk found nothing, closeWorkspaceTab() skipped
promotion, and requestFreshSession() dropped main to a new draft: closing
a pane read as 'it gave me a new session'. A tile in any zone now promotes
(its zone collapses via the tile close), so Close is also the path from N
panes back to 1. Ghost panes whose tile is gone still never promote.
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.
Window Translucency shipped defaulting to Clear, which means the mode worth
finding is the one nobody sees — Glass is the better-looking half and the
reason the feature exists. A fresh macOS profile now starts with Glass
selected.
Nothing turns on. The intensity still defaults to 0, so the window is
byte-for-byte what it is today until the user moves the lever; the default
only decides which mode that lever will drive. windowOpacityFor stays 1 and
the window is still born with its opaque backing.
The one profile that must NOT flip is one already carrying a non-zero
intensity with no mode recorded: it predates the setting, has been rendering
as clear the whole time, and defaulting it to glass would change a window
someone deliberately tuned. normalizeMode takes the saved intensity and keeps
those on clear.
The renderer store was hand-rolling its own copy of this rule, so it now
routes through the shared normalizer and the two can't disagree.
The store's default test only passed because a beforeEach reset the atom
before it looked — it asserted the post-reset value, not the default, so it
would have stayed green through this change. It now snapshots the atom at
import time. All three mutations (default back to clear, escape hatch
removed, glass leaking onto non-mac) fail the suite.
Pre-handoff polish, no behavior change. The three translucency pickers
shared a verbatim five-line onChange (haptic, set, conditional pulse) —
one pickTranslucency helper now serves mode, frost and area. Dead
re-exports cut: the electron adapter no longer forwards renderer-only
symbols (TRANSLUCENCY_STEP, TranslucencyMode, GlassScope), and the store's
re-export block shrinks to what its call sites read; the store test takes
the shared constants from @hermes/shared/translucency directly.
Audit pass over the whole feature's lifecycle, closing four holes:
- Stuck peek. Escape mid-drag unmounts the slider before its pointerup ever
fires, stranding the counter above zero — every LATER settings overlay
then renders ghosted at 8% opacity. The appearance surface now drops all
outstanding holds on unmount (resetTranslucencyPeek); expiring pulse
timers become no-ops on the zero floor.
- Frozen sibling windows. Under glass an intensity change touches nothing
native (by design, that's the perf fix), so a second chat window never
heard about it and its tint froze until reload. The store now adopts a
sibling's persisted state off the storage event — the same cross-window
pattern themes/context and store/session already use.
- Layout thrash on the sidebar-scope drag. startRailTracking re-measured the
rail on every store sync: a getBoundingClientRect (forced layout) right
after the tint's style write, once per slider tick. While tracking is
live, the ResizeObserver and the resize listener own geometry; the settled
hot path is now one boolean check. Re-acquisition still covers the two
real cases — a rail that hasn't mounted yet, and a rail that REMOUNTED
(layout reset swaps the element, leaving the observer on a detached node
that never fires again).
- The rail observer/listener pair was already torn down whenever glass or
the sidebar scope ends (stopRailTracking) — audited, covered by the scope
attribute tests, unchanged.
The perf pass over-corrected: it debounced the renderer's IPC send along
with the localStorage write. Under glass that was harmless (the renderer
paints the effect itself), but in clear mode the effect IS the native window
opacity, and main can only move it when told — so dragging the slider did
nothing for 120ms and then snapped to the released value. Exactly the jank
the debounce was meant to remove, reintroduced on the other mode.
The send is per-tick again; only the localStorage write stays debounced.
Per-tick sends are cheap now because main diffs the state: one setOpacity in
clear mode, nothing at all under glass.
The store test asserting the send was coalesced encoded the bug — flipped to
assert every tick reaches the bridge, with a comment naming the rule.
Dragging the intensity slider was janky, and the four frost levels looked
nearly identical. Same cause.
At step=1 a drag emits ~100 updates, and every one of them did four
expensive things: a synchronous localStorage.setItem, an IPC wake, a
synchronous fs.writeFileSync in main, and setVibrancy + setBackgroundColor
on every open window. The vibrancy call is the one that also broke the
frost picker — it animates over 150ms, so re-issuing it per tick restarted
the animation before macOS could ever settle the material. The levels
weren't indistinguishable, they were never finishing.
The fix follows from a property the mode already has: under glass the
intensity is a pure renderer concern. windowOpacityFor returns 1 for the
whole range, so main has nothing to do on an intensity change at all.
- main diffs the incoming state against the current one and passes a
`changed` set to applyWindowTranslucency. An intensity-only change under
glass now touches zero native properties. Crossing zero still moves the
backing, since that flips glass on and off.
- main's disk write is coalesced onto a 250ms trailing timer, flushed on
before-quit. Only a cold launch reads that file.
- the renderer paints every tick (the field has to track the hand) but
coalesces the localStorage write and the IPC send onto a 120ms trailing
timer, flushed on pagehide.
Covered as contracts rather than timings: a table asserting exactly what
each kind of update changes natively (nothing, across the whole intensity
range under glass), and store tests that a six-tick drag produces one write
and one IPC call while every intermediate value still paints. Both
directions mutation-checked — restoring per-tick writes fails, and
debouncing the paint fails too.
jsdom reports an EMPTY navigator.platform and a userAgent of "darwin", so
GLASS_SUPPORTED resolved false and every `if (GLASS_SUPPORTED)` assertion in
the store suite took its else-branch — on a Mac too. The suite was green
because it was checking that glass does nothing.
Found by mutation: removing the `data-hermes-glass-scope` cleanup, hardcoding
the keep percentage, and dropping the isChatWindow guard all survived a full
run. Pinning navigator.platform before the store module evaluates makes the
glass path the one under test, and all three now fail as they should.
Also adds the coverage those mutants exposed as missing:
- Each of the three ways glass can end (intensity to zero, mode to clear,
window kind) clears the scope attribute independently — a stale scope keeps
the split-paint gradient selector live over a non-glass field.
- The store must CONSULT isChatWindow, not merely export it. Driving
window.location.search proves the HUD / pet overlay / quick entry don't get
their page surfaces rewritten while still honouring the user's saved mode.
- A guard asserting GLASS_SUPPORTED is true in this environment, so if the
env ever stops reporting mac the suite fails instead of quietly hollowing
itself out again.
Restores the four features SHL0MS built on #84329 that an earlier pass on
this branch had carved out, reconciled onto the shared translucency state
rather than the four-atom store they were written against.
- Frost picker. macOS exposes no blur-radius knob, so the vibrancy material
IS the frost control. The four in the ladder come from a pixel census on
macOS 26: the 14 Electron materials collapse to 9 distinct looks, and
these four are the widest separations that stay distinct in BOTH
appearances. sidebar/hud collapse into under-window when unfocused, which
is why they're deliberately absent -- normalizeMaterial rejects them, with
a test saying why.
- Sidebar-only glass, the Finder shape. <body> stays the single painter and
splits at the rail's live-measured edge with a hard gradient stop, so
there's no smear across the seam and no per-layer tint stacking. RTL
mirrors.
- Full-range tint. glassSurfaceKeep runs linear to zero, so the top of the
lever is bare untinted blur instead of stopping at a 30% wash. Text, cards
and the composer keep their own opaque tokens, which is what makes 100%
usable rather than unreadable.
- Peek. The settings overlay covers the very effect its slider controls, so
holding the slider ghosts the whole overlay layer and the live window
becomes the preview. A counter, not a boolean: a held drag and a timed
pulse from a picker click overlap, and the drag must not be cancelled by a
pulse expiring underneath it.
One change from the original: the peek's transition is scoped with :has() to
the overlay that arms it. The version on #84329 shipped a bare
`[data-overlay-surface] { transition: opacity 420ms }`, which gave every
overlay in the app -- command center, cron, agents, model picker -- a 420ms
opacity transition for the life of the process to serve one slider. Verified
by running the candidate rules through lightningcss: the scoped selectors
survive minification and no un-gated overlay transition remains.
visualEffectState is pinned to 'active' at each chat window, because several
materials collapse to a shared inactive look on blur -- without it the frost
choice silently erases itself whenever the user clicks another app.
Co-authored-by: SHL0MS <SHL0MS@users.noreply.github.com>
Every chat window constructor repeated the same three translucency-related
options -- the vibrancy material, the native opacity, the webContents
backing -- and the glass work was about to make that four things to keep in
step across three sites, with the cold-launch backing rule (omit
backgroundColor, never pass alpha) restated at each. chatWindowSurfaceOptions()
states it once, and the comment naming the HUD, pet overlay, quick entry and
wake indicator as deliberately-not-chat-windows lives with it.
The settings row reads the store's single object rather than two atoms.
Glass thins the field tokens, so anything that needs a fill for a reason of
its own had to be exempted. Those exemptions were written as styles.css
reaching into other components by class name and slot -- .cursor-grabbing,
.composer-human-message-container, [data-slot='file-diff-panel'] -- which
puts the knowledge in the wrong file: rename the class, lose the fill, and
nothing fails until someone turns glass on over a diff.
There are only two roles. A surface that MASKS its siblings (the diff
gutter, a dragged row) must stay opaque or it reads as text through text. A
surface RAISED above the field (overlay cards, the inline edit box) stays
near-opaque and never thinner than the field behind it. Each one now says
which it is at its own call site, and styles.css styles the roles.
This also narrows the diff-panel rule to the sticky gutter that actually
masks, rather than the whole panel.
Three small dedupes in the surfaces this feature touches:
- GLASS_SUPPORTED was a fourth inline copy of "am I on a Mac" in the
renderer. src/lib/platform.ts owns it now and the terminal's
isMacPlatform re-exports it, so the two call sites can't drift.
- The store held intensity and mode as two atoms behind two localStorage
keys with two subscriptions calling one sync. They are one setting: one
atom, one key, one subscription. A pre-mode value under that key is a bare
intensity, which has only ever meant clear -- read() keeps it there.
- global.d.ts re-declared the IPC payload as an inline union. It takes
TranslucencyState, so widening the state can't leave the bridge behind.
isChatWindow is exported and takes its search string as a parameter, because
"which windows may thin their surfaces" is the contract worth pinning.
The mapping had grown three copies: the clear-mode ramp in
electron/window-opacity.ts, a second clamp + mode normalizer in
electron/translucency.ts, and a third clamp in the renderer store, with a
"keep in sync" comment standing in for a shared type. Anything the two
processes must agree on -- what a mode is, where the lever clamps, what
intensity means as an opacity -- now lives in apps/shared/src/translucency.ts
and both ends import it.
electron/translucency.ts keeps only the piece that needs a BrowserWindow to
mean anything (the constructor backing), and re-exports the rest so main.ts
has a single import. Its relative specifier is deliberate: the electron
bundle is built by esbuild with no tsconfig path resolution, so a bare
@hermes/shared/translucency would typecheck and then fail to bundle -- the
same constraint connection-registry.test.ts documents for backendScopeKey.
The renderer's tsconfig drops its reference to the electron project. With
both projects claiming the shared file, that edge made the renderer resolve
it through the electron project's build output and demand a prior
`tsc --build`. Nothing in src/ consumes electron's emitted types, so the
reference bought nothing; `npm run typecheck` still checks both projects.
Constructor backgroundColor with alpha is silently treated as opaque on a
non-transparent window (Electron only documents constructor alpha with
`transparent: true`), so windows created while glass was persisted were
born with an opaque backing and the vibrancy material never showed —
exactly the state a user lands in after toggling glass on and relaunching,
or when the renderer re-reports the persisted state at boot (the IPC
handler correctly dedupes it, so no runtime swap ever fired).
Measured on macOS 26 / Electron 40 (side-by-side spike windows, pixel
luminance): ctor '#00000000' = flat opaque (lum 38, same as no glass);
omitting backgroundColor entirely = vibrancy visible (lum 57). Runtime
setBackgroundColor swaps are also LOST while a fresh process's compositor
is settling — swaps at 1s/3s/6s after creation never landed, including
from 'ready-to-show' and 'did-finish-load'; a 10s swap stuck. So cold
launches must be right at creation: windowBackingOptions() spreads either
{} (glass) or the themed anti-flash backing (everything else) into the
three chat-window constructors. The runtime swap path stays for live
Settings toggles, where the window is long settled.
Two opaque layers sat between the transparent page and the vibrancy material, so glass read as a slight lightening instead of a blur. The contrib shell root and the SidebarProvider wrapper paint full-window opaque fills above body; both are cleared under glass so body's tint is the window's only field paint. And Chromium composites the page against the window backgroundColor before macOS composites the window, so chat windows now get an alpha-0 backing when glass is active, at creation for cold launches and via setBackgroundColor on runtime toggles, scoped to registered chat windows so the transparent special-purpose windows (HUD, pet, quick entry) are untouched.
Session panes stayed nearly opaque under glass while the landing page and overlay cards showed the effect: the field surfaces nest (body, pane container, chat section, transcript wrapper all wear the surface tokens), so a per-token tint stacked once per layer and compounded toward opaque exactly where the pane tree is deepest. Body now paints the glass tint once and the field tokens go fully transparent, so the field alpha is one number on every route.
Overlay cards on OverlayView are marked data-glass-raised and pinned near-opaque (never thinner than the field), inverting the hierarchy the first cut had backwards: glass field behind, solid card in front. Mask surfaces (diff gutter, dragged sidebar row, inline edit box) get opaque fills back, and in clear mode the overlay scrim darkens and widens its blur so two uniformly faded layers of text stop fighting.
The translucency slider maps to native window opacity, which fades the whole window including text; over a busy wallpaper even low settings get hard to read. This adds a second mode to the same lever: Glass keeps the window opaque at the native level and instead thins the renderer's field surfaces (chat surface + sidebar) over the macOS vibrancy material every chat window already carries, so the desktop shows through as a smooth matte blur while text keeps full contrast.
One lever, two modes: Clear stays the default and is byte-identical in behavior; Glass is macOS-only (other platforms normalize to clear on both sides of the IPC). Mode persists next to intensity in translucency.json and localStorage, applies live to all open windows, and survives cold launch. Raised surfaces (cards, popovers, composer, terminal) keep opaque fills; the terminal surface is pinned because xterm resolves its background to a concrete color for its canvas.
setOpacity fades the entire window, text included, so the band a user can
still read in sits just under opacity 1. The linear ramp spent that band
in its first ~7% and the remaining travel on settings nobody can work in:
combined with the old 5% step, the lever had about two usable stops.
Curve the ramp instead. Both endpoints are bit-identical to the linear
mapping -- 0 is byte-for-byte the opaque window and 100 is still the 0.3
floor -- while the readable band now covers roughly the first third of the
travel.
Extract the mapping and its clamp into electron/window-opacity so it is
reachable from tests, following the electron/zoom split. main.ts keeps
ownership of the persisted intensity and passes it in.
The Window Translucency slider moved in fixed 5% jumps, so a 0-100 lever
had only 21 stops packed into a 160px control -- and arrow-key nudges
skipped 5 at a time. The settings that are actually readable live at the
low end, so the coarse step made most of them unreachable.
Step in single percent, and hoist the bounds into named constants beside
the atom that owns them, mirroring PET_SCALE_MIN/PET_SCALE_MAX so the
control and the clamp read from one source.
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.
Community reports (Aug 2026): sidebars where sessions and the Bots pane were
already co-located in one group, but the group's tab strip was hidden
(headerHidden) with Bots holding the active tab — Sessions existed yet was
invisible with no strip to switch back ("my ui only shows bots now on the
side, cant find the sessions").
The enforce pass only re-homed panes in a DIFFERENT group than their anchor;
the co-located-but-hidden shape hit the early continue and was never
repaired (insertAtGroup's headerHidden:false pin only runs when an insert
happens). enforceDockedPanes now forces the anchor group's strip visible
when the enforced pane is already co-located — the active tab is not
stolen; the strip is simply reachable again.
New regression test reproduces the community shape (co-located +
headerHidden + bots active) and asserts the strip is forced visible;
sabotage-verified (reverting the repair fails exactly this test, 1F/5P).
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.
The unified Gateways settings page (from the recent settings merge) still
carried the legacy per-profile gateway-override machinery: an "Applies to"
profile-chip scope switcher, a scope state machine threaded through load/
save/test/sign-in paths, inherit-mode ModeCard variants, and an SSH
remote-profile mapping row.
The page is machine-level gateway management: it decides which gateway
backends this desktop can connect to, and profiles are discovered FROM the
connected gateways. It must not be profile-scoped.
- Delete the scope chips section, ScopeChip component, and the scope/setScope
state; every scope-conditional collapses to its global (scope === null)
branch. getConnectionConfig/save/apply/test/sign-in are all unscoped now.
- ModeCard local card always renders the local title/desc (inherit variants
gone); SSH remote-profile mapping row removed.
- i18n: drop now-unused gateway keys (appliesTo, allProfiles,
defaultConnection, profileConnection, inheritTitle, inheritDesc,
sshRemoteProfileTitle, sshRemoteProfileDesc) from types.ts and en/zh/
zh-hant/ja/ar in sync; rewrite the gateway intro in each locale to say
connections are machine-level and profiles come from gateways.
- Tests: replace the scope-switching component tests with a machine-level
assertion (loads getConnectionConfig(null), never a profile scope, no
scope UI rendered).
- Docs: update desktop.md and multi-connection-desktop.md wording — gateway
connections are machine-level; per-profile backend routing continues via
the profile rail / session source surfaces, not the settings page.
The electron main-process per-profile override mechanism
(getConnectionConfig(profileName), route map) and the profile-rail connect
flows are intentionally untouched; only the settings page loses the
affordance.
Backend: /api/status now carries a stable random install_id persisted once
under the root HERMES_HOME, shared by every profile of the install.
Desktop: roster enumeration captures it per connection (TTL-cached probe),
buildAgentRoster collapses same-install rows with a deterministic canonical
pick (active > local > ssh > remote > cloud > earliest), the @name-device
handle rule runs after the collapse, and Settings → Gateways shows a
display-only 'Same backend as' hint. Backends without install_id bypass the
collapse (fully backward compatible).
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.
Closes the renderer half of #88769, found while live-verifying the
profile-lifecycle fixes:
- The secondary-socket reconnect loop now fail-stops when Electron's spawn
guard rejects with "no longer exists" / "is being deleted" — a permanent
condition for that scope, previously retried forever on the 15s-cap
backoff (40+ guard hits observed in 3 minutes after clicking a stale
badge). The entry is disposed, evicted, and the active key restored to
the primary, mirroring the existing missing-connection fail-stop.
- The Bot Mode SDK deleteProfile path now refreshes $profiles after a
successful delete, so the profile rail drops the dead badge instead of
keeping a clickable ghost. (The Desktop dialog path already refreshed
via onDeleted; the SDK path was the gap.)
Tests: two fail-stop regression tests (profile-gone + mid-delete guard
rejections, sabotage-verified to fail without the fix) and a refresh
assertion on the SDK delete ordering test.
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).