Bring the per-profile scoping affordance the Gateway page already has to
every config-backed settings surface. A new shared nanostore
($settingsScopeOverride in store/settings-scope.ts) holds one "Applies to"
selection that persists across pages; a reusable SettingsProfileScope chip
row (app/settings/profile-scope.tsx) renders it. Hidden with fewer than two
profiles, and null (follow the active profile) keeps every request on the
exact pre-existing unscoped path.
Scoped pages:
- Model / Workspace / Safety / Memory & Context / Voice / Chat / Advanced
(ConfigSettings): config record + schema queries and the debounced
autosave PUT now carry the selected profile; the inner page remounts per
scope so drafts can't leak across profiles. ModelSettings threads the
scope through model info/options/aux/MoA fetches and assignments; memory
provider config/OAuth panels follow.
- Tools & Keys (KeysSettings via useEnvCredentials): env list + save/
reveal/delete target the selected profile's store.
- Messaging overlay: platforms, enable/save/clear, pairing approve/revoke
are scoped; the list blanks on scope switch so stale rows can't be
toggled against the wrong backend.
Plumbing: hermes.ts REST helpers gain optional trailing `profile`
parameters (config, schema, env, memory provider, messaging, pairing,
model endpoints) resolved through the existing profileScoped() ladder —
omitting them is byte-identical to before. The cron model-impact warning
is skipped for scoped applies (it belongs to the other profile's backend).
An app-wide profile switch drops the override so edits can't silently
stay pointed at the previous target.
i18n: new settings.profileScope.{appliesTo,editsProfile} keys in
en/zh/zh-hant/ja/ar.
Capabilities (app/skills) and Scheduled Jobs (app/cron) verified: both
already thread their profile scope through every fetch and mutation.
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.
Layout (overlap bug): the EmbeddedHubPicker section had no flex
containment — its fixed-height iframe viewport made the section's
min-content height rigid, so at a tall persisted drag height or a short
window the flex column starved the installed-skills area to 0px and the
hub header row painted straight over the list's sort strip and "changes
apply to new sessions" footer. The picker section now clips its own
content (overflow-hidden, shrinkable, min-h for the header row), the hub
viewport uses flex-basis instead of a hard height so it gives pixels
back first, the list region keeps a min-h-40 floor, and the sash drag is
clamped against the actual column height (list reserve) rather than just
the window.
Perf (Capabilities lag):
- The hub iframe (a full Docusaurus site) mounted eagerly with the view
and re-mounted on every scope change (key={`picker-${scopeKey}`}).
It now lazy-mounts the first time the Skills tab is shown, then stays
mounted but display:none across Tools/MCP so a tab bounce never
reloads the site. The scope key is gone — the picker fetches nothing;
the profile rides into each install call as a prop.
- EmbeddedHubPicker is memo()ed and subscribes to the single
UPDATE_ALL_KEY running flag via useStoreSelector instead of the whole
$hubActions map, which churns on every tailed log line during installs.
- Collapse state now persists through the pane store (0 = collapsed),
matching DetailPane, instead of resetting open on every mount.
- CapRow gets content-visibility:auto so 80+ row lists skip offscreen
layout/paint (fixed row heights keep scroll geometry stable).
Validation: npm run check:lint (tsc x3 + eslint) clean; vitest
src/app/skills 9/9 passing, including a new test asserting the iframe
is absent on non-Skills tabs, mounts with the Skills tab, and survives
a tab switch hidden.
- 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).
Merge Settings → Connections into Settings → Gateway as a single
"Gateways" page:
- One nav entry ("Gateways"); the Connections nav entry is removed. The
legacy `?tab=connections` deep link stays in the route enum and
redirects to the unified page, and renders it in the interim frame so
saved routes/bookmarks don't break or flash.
- The connections registry UI (list, add, edit, delete, test, make
primary, update-all) moves into its own component
(connections-registry.tsx) composed at the bottom of the Gateways
page; connections-settings.tsx is deleted (nothing else imported it).
- The Add flow now offers ALL kinds: Local, Hermes Cloud, Remote
gateway, and SSH (previously only remote/ssh). The Local kind button
is disabled while the managed local entry exists; a hint points cloud
adds at the sign-in/discovery flow above.
- Duplicate prevention, enforced in the save path on both sides of the
IPC boundary (renderer inline error + main-process
normalizeConnectionInput throw): only one 'local' entry ever;
remote/cloud dupes keyed on the normalized URL (trim, strip trailing
slashes, lowercase) across both kinds; ssh dupes keyed on normalized
user@host:port + remote profile.
- The page-level "Applies to" ScopeChip row is gone. Per-profile
gateway overrides are now an explicit "Per-profile overrides"
subsection listing the default connection and each named profile,
with an Edit affordance that drives the same scope state machinery.
- Command palette and profile-rail deep links retarget the unified
page; i18n nav rename + new strings across types, en, ja, zh,
zh-hant, ar.
Tests: new connections-registry.test.tsx (registry UI + dedupe helper
units, including the inline duplicate rejection), electron
connection-registry.test.ts dedupe cases, and updated
profile-rail-connect test.
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.
Follow-ups on the salvaged bounded-teardown fix:
- Replace the source-grep regression test (backend-lifecycle.test.ts asserted
main.ts source text) with behavior-contract tests against the extracted
createPoolStopper(): handle retention until bounded exit, stop dedup,
stopAll completeness, and the respawn-awaits-dying-backend gate.
- Gate the registry-local spawn path on the in-flight stop too (sibling site
the original PR predates).
- Route teardownPoolBackendAndWait through the shared stopper so profile
delete/rename teardown covers both local pool keys with the same bounded
semantics.
Follow-ups on top of the salvaged rename lifecycle:
- Retire both local renderer socket scopes for the OLD profile name before
the rename PATCH (rename-profile-dialog), mirroring the delete-path fix
from #88638 — a retained socket otherwise treats the rename's backend
teardown as a transient drop and its reconnect respawns the old-name
backend, whose ensure_hermes_home() recreates the directory the rename
just moved (#45474).
- Hold the profile deletion gate for rename requests too, so a concurrent
renderer reconnect entering ensureBackend() mid-rename cannot respawn the
old-name backend.
- Regression tests for retire-before-rename ordering and the
validation-rejected path.
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.
A resume racing a profile/connection swap can 404 on a backend that
does not own the session; the terminal-failure branch then dropped the
window to the blank new-chat route while the target session was alive.
goneSessionVerdict() now gates the draft fallback: a session created
this run, still listed on some profile, or looked up while a gateway
swap is in flight arms the bounded auto-retry latch instead of
discarding the route. Draft remains the calm-conditions path for
verifiably dead ids.
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.
A leftover sshConnections key made roster polling call
ensureRegistryBackend for every SSH source every ~5s. That spawned
remote dashboards, the mux died, and the renderer hit hermes:api
ECONNRESET / liveness-probe drops.
SSH inventory stays on the cached ls path only. Clicking a bot still
dials that one source.
A first inventory miss used to stick forever as a seeded default until
the user hit Test. Retry after 60s; a successful cache still never
re-probes, and Test still forces an immediate refresh.
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.
The skew banner told users to run the in-app update, but on machines where
the local desktop pack is the broken step (e.g. the get-windows win32
binding staging failure in #88251), rebuilding in place cannot succeed.
Reinstalling from the packaged installer sidesteps the local build
entirely, so offer it as the escape hatch:
- Settings > About skew banner gains a "Get the installer" button opening
https://hermes-agent.nousresearch.com/ via openExternal
- banner copy now mentions reinstalling when the update doesn't clear the
warning
- all 5 locales updated (new bundleOutOfSyncAction key)
An inline ::preview widget could render and be clicked, but the click went
nowhere: the sandbox has no channel to the agent, so an interactive chart
was a dead end. Now the frame injects a second script beside the measurer
that gives the page one voice:
window.hermes.send('get-price eth')
<button data-hermes-send="get-price eth">ETH</button> (zero-script form)
The prompt rides postMessage up tagged with the mount token, then goes
through the composer's own send path (requestComposerSubmit -> prompt.submit)
flagged display_kind=hidden — the same row-typing auto-continue and internal
notifications already use. The agent wakes and takes a real turn; the
durable row persists (context, resume, DB audit); but NO bubble renders,
live or on reload. The user clicks ETH and the chart just changes — the
off-screen loop is click -> hidden turn -> agent rewrites the widget file ->
frame hot-swaps.
Trust boundary matches size reports and is tighter where it matters: mount
token required (frames can't forge each other's intents), string-only,
trimmed, capped at 500 chars, throttled to one intent per second per frame.
The gateway whitelists display_kind to "hidden" — the RPC can't mint
arbitrary row types — and the flag threads through both turn paths (inline
and compute-host isolation) so isolated sessions don't resurrect bubbles on
resume.
The desktop platform hint teaches the model to wire interactive widgets
with data-hermes-send and to answer clicks by updating the widget's file
rather than with prose; the SDK doc documents the contract.
hermes update moves the source tree, but the desktop UI (including bundled
plugins like Bot Mode) is compiled into the app binary at build time. A
terminal-side update — or an in-app update whose bundle-swap leg failed —
leaves a new runtime under an old renderer: About reports the new Hermes
version while the sidebar is missing that version's desktop features
(the 'no Bots tab after the Bot Mode update' reports).
Detect the skew by comparing the packaged install-stamp commit against the
tree (git rev-list --count <stamp>..HEAD -- apps/desktop) and surface it:
- Settings > About: amber warning banner pointing at the updater
- macOS native About panel: suffix on the version line
- hermes:version IPC gains bundleOutOfSync / bundleCommitsBehind
Fail-quiet by design (no stamp / fallback stamp / git failure = no warning)
so dev runs and non-git builds never see a false 'install is torn' alarm.
All 5 locales covered.
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
CI's check:lint caught three real issues in the directive surface:
- TranscriptDirectiveLeaf called the contribution's render() inline in JSX
— the exact pattern no-restricted-syntax bans because the callback's hooks
land in the host and a plugin reload changes the host hook count
(React #310). The callback is now memoized and mounted via ContribRender.
- The inline frame mirrored its height state into a ref from the message
handler (the stale-read pattern no-restricted-syntax flags). Functional
setState reads current state directly; the shadow refs are gone.
- perfectionist import/export ordering in the frame and the SDK index.
The first inline frame was a full-width bordered box at a fixed height:
webpage-in-a-rectangle, not a widget. Now the frame disappears into the
message flow:
- Content-driven size. The injected measurer reports height (live) and
intrinsic width (adopted once, so %-width children can't feedback-loop the
frame toward zero). A sparkline shrink-wraps and sits flush left like an
inline image; a full-bleed page measures the whole viewport and stays
column-wide. The height attribute is now only a starting value.
- Theme bridge. A style prelude injects first with the app's resolved theme
tokens under stable names (--foreground, --muted-foreground, --accent,
--border, --card), the app font, zero body margin/padding, and a
transparent background — reference HTML written against those vars renders
native in any theme. Page styles override the prelude, so a page that
brings its own design keeps it.
- No chrome. Border, rounded box, and the rail-opener card under the frame
are gone; the fallback paths (non-HTML, remote gateway, unreadable file)
keep the classic card. The wheel gate went with the border — frames size
to content, so there is nothing to scroll inside, and widgets are fully
interactive.
- The desktop platform hint now teaches the default: an inline widget is
transparent, token-colored, flush left, no page chrome — only a standalone
page brings its own background. "Make me an inline sparkline" gets native
styling without the user spelling it out.
Some providers re-send the previous assistant text verbatim when a turn
continues past a tool call (a tool_calls row, then a stop row with identical
prose — both persisted). The turn merge folds both rows into one bubble, so
every paragraph in the reply rendered twice; inline ::preview frames made it
obvious. Repeated text parts now dedupe in the same pass as generated-image
echoes — the last occurrence wins.
The frame was a hardcoded 280px unless the model guessed a height attribute
— tall pages clipped (the flip-clock demo cut its last digit), short ones
floated in dead space. The opaque-origin sandbox means the parent can't
measure the document, but we own the srcdoc string: a tiny injected script
observes the document with ResizeObserver and posts its scrollHeight up via
postMessage, and the frame tracks it live within the 120-1200 clamp.
Reports are validated before they can move layout — per-mount random token
(two previews in one transcript, or a hostile page inventing messages,
can't move each other's frames), finite-number check, clamp. A 4px
tolerance stops vh-sized pages (which measure exactly what they're given)
from oscillating; an explicit height attribute still opts out of
auto-sizing entirely.
The first cut of the core ::preview consumer rendered the classic
preview-attachment card — a button into the right rail we already had, which
made the directive indistinguishable from an ordinary preview link. Now the
directive shows the thing itself: the workspace HTML file renders in a
sandboxed srcdoc iframe inline in the assistant message (opaque origin,
allow-scripts only — no reach into the app, its storage, or the bridge),
with an optional height attribute clamped to 120-1200px and the classic
card kept below as the rail escape hatch.
The frame waits for turn settle before reading the file (mid-stream it is
often mid-write), resolves relative paths against the session's own cwd,
and falls back to the plain card for non-HTML targets and remote gateways
(no local file door there).
The transcript becomes a contribution area (transcript.directives). A plugin
registers a named directive and the model addresses it by emitting
::name{key="value"} as its own paragraph; that leaf renders as the plugin's
component, wrapped in the contribution error boundary. Unclaimed or malformed
directives stay plain prose, so nothing changes for text that merely looks
like a directive (std::vector) or for users with the plugin disabled.
Core ships ::preview{file="..."} as the reference consumer (the existing
preview-attachment card), the desktop platform hint teaches the model the
syntax, and the SDK exports the area + types so runtime plugin.js files get
the surface through the normal plugins API.
Desktop shipped the same bug class four times in one week: a decorative
animation loop that never sleeps (bots face clock #88543, pixel egg #88406,
diffusion placeholder #88564, plus the #77651 hidden-renderer wave). Each fix
hand-rolled the same four behaviors. This extracts them into one helper in
src/lib/budgeted-loop.ts:
- fps budget (default 15) on top of rAF
- observability pause via the existing createRendererLoopPauseController
- idle dormancy: idleWhen() true after a draw parks the loop with zero
pending work until wake() — the piece every hand-rolled loop forgot
- teardown: dispose() cancels the frame, disposes the controller, and makes
wake() a no-op
DiffusionCanvas migrates onto it as the first consumer (net -40 lines at the
call site); its existing scheduling/budget/instance-cap tests pass unchanged
against the migrated implementation. Helper suite covers budget, pause,
park/wake, dormancy-survives-focus-churn, and dispose idempotency; sabotage
run (budget+dormancy stripped) fails 4/5.
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).
Follow-up to the cherry-picked fix for #88391: the bare `streamdown` import
broke the plugin's side-load contract (vm-harness tests and the legacy-sdk
tmpdir loader can't resolve bare specifiers, and the runtime plugin door only
maps @hermes/plugin-sdk and react). Export Streamdown from the SDK instead,
feature-detect it in plugin.js like SkillsView/McpTab (plain-text fallback on
older desktops), and fix the indentation at the render site.