The shared ensureContrast shipped the TUI's fine 0.05×20 ladder, which
changed --dt-primary-solid for 7 of 15 desktop presets (nous #3b6acb →
#3f70d8, cyberpunk #00661a → #008021, slate #505457 → #6f7377) while the PR
body said no preset VALUE changed. The ladder is now the desktop's original
algorithm exactly — pole by luminance < 0.5, accumulating 0.2 steps up to
1.0001, re-mixed from the source colour — with `step` as a parameter. The
only pre-refactor TUI caller (ColorChain.ensureContrast) passes 0.05, so
the terminal palette is byte-identical too.
Test: apps/desktop context.test.tsx iterates every builtin preset × mode,
paints it through ThemeProvider and asserts --dt-primary-solid equals the
value a reference copy of the old desktop algorithm computes. Sabotage
(default step 0.05): 11/30 rows fail. Docs: the SDK table now lists
contrastRatio as `number | null` under sRGB measures, not OKLCH.
Capabilities → Plugins is now a single table: one row per PACKAGE, with a
Desktop column (this app) and an Agent column (the selected profile). A
package with both halves is one row, never two; the kind badge is inferred
from what it ships (plugin.yaml → agent, plugin.js → desktop).
The desktop half of a unified agent+desktop package no longer loads from the
profile-shaped `plugins/<name>/desktop/` folder. Electron copies that half
into `~/.hermes/desktop-plugins/<name>/` beside a `.hermes-package.json`
marker (package name, source, origin repo/sha) and keeps it in sync: newer
source → re-copy, package uninstalled → copy removed, hand-installed
standalone folder of the same name → never overwritten. The renderer scans
exactly one root, so a pane can never appear, disappear, or re-scope when the
user switches profiles — the same switch reads the same value everywhere.
Why a copy rather than scanning every profile: two profiles can carry the
same package at different SHAs; a scan has to pick one silently. One copy,
one source of truth, stamped with where it came from.
- plugin-packages.ts: pure merge of desktop records + agent rows → rows
- plugins.manage list reports `has_desktop_half` so the pairing is explicit
- Install dialog (local backend): the desktop half is materialised from the
installed package instead of cloning a second standalone copy; remote
backends keep the separate clone. Target path now names the real profile
folder for non-default profiles.
- "Install here": a desktop half whose agent half is missing in the selected
profile pre-fills the dialog from the marker's origin; disabled with an
explanation for hand-copied folders with no origin.
- Profile selector moves into the Agent column header; hidden with 1 profile
- Rescan/Update reconcile the copies BEFORE rescanning (ordering bug)
- Drop the dead `agentPluginsRoot` IPC; docs updated (desktop.md, SDK,
bot-mode.md, hermes-desktop-plugins reference)
Live-dogfooded on a headless Electron with two profiles and a real
file:// git package: install both halves, profile switch ×3, Install here
into the second profile, v2 update via `hermes plugins update` → chip text
changes on Rescan, uninstall from both profiles → copy and row gone, broken
plugin row, cold restart, sash drag/reset, legacy Settings → Plugins
redirect.
Three things Teknium hit on the merged Plugins page:
1. Desktop plugins vanished on profile switch. `fs-ipc.ts::localPluginsRoot`
resolved `<HERMES_HOME>/profiles/<active>/desktop-plugins` for a named
Desktop profile, so a disk-installed plugin only existed under the profile
it was installed from. Desktop plugins extend the app, not an agent; the
root is now `<HERMES_HOME>/desktop-plugins` regardless of profile, gateway
or remote machine (`electron/desktop-plugins-root.ts`), with a one-time
migration that lifts any per-profile folders into the app root (root copy
wins on collision). Agent-plugin and logs roots stay profile-scoped.
On the page, the profile selector now renders INSIDE the framed Agent
plugins block as its header, and Desktop plugins sit outside that frame,
so the scope boundary is visible without reading the blurb.
2. The catalog viewport could not be resized. It gets the same top-edge drag
sash as the Skills hub picker (persisted height, double-click resets,
clamped so the lists above keep real height; iframe pointer-events off
while dragging).
3. Accent Picker, a theme-authoring toy shipped off by default, is removed
from the bundled set and published as a standalone desktop plugin at
https://github.com/NousResearch/hermes-desktop-accent-picker (same source,
esbuild-bundled plugin.js importing only the three loader-resolvable
specifiers). Docs point there.
Plugins were split across two pages that each showed half the picture:
Settings → Plugins listed desktop plugins plus "Install from Git" and a
pointer saying agent plugins live elsewhere; Capabilities → Plugins listed
agent plugins plus the catalog picker but knew nothing about desktop
plugins. A user asking "what extends my Hermes and where do I add more?"
had to visit both and still could not see the whole set in one place.
Capabilities → Plugins is now THE plugins page:
- Agent plugins section (scoped to the profile selector) with the
"Install from Git" button in its header — installs target the scoped
profile, not whichever one is active.
- Desktop plugins section beneath it (same for every profile), with the
folder/rescan controls and the "agent half missing here" drift chip,
whose repair also lands in the SCOPED profile.
- The catalog picker underneath, unchanged.
Settings → Plugins is removed. `/settings?tab=plugins[&plugin=…]` and the
existing `?tab=mcp` redirect share one table (`settings/moved-tabs.ts`) so
old bookmarks and palette links land on the same row on the new page.
Command palette: plugins moved from the Settings group to the Capabilities
group; installed-plugin rows deep-link to `/skills?tab=plugins&plugin=…`.
Dead `settings.plugins.agent.*` and `settings.nav.plugins` i18n keys dropped;
docs and in-code pointers say Capabilities → Plugins.
`useTheme`, the accent override, the retint helper and the OKLCH math reached
the SDK without reaching this page, which still documented `THEMES_AREA` alone.
Registering a theme only lists it in the picker, so the natural reading was that
plugins cannot switch themes at all — and the one person who tried concluded
exactly that and patched the app instead.
Documents the selection half: the hook for components, `requestTheme` for
callbacks with no component around them, and a Theming row in the export table.
The agent-facing reference gets the same note, since it never covered themes.
The Bots roster highlight and the Routines (Cronjobs) tile were keyed off
host.state.profile — the gateway socket's home. Tab/tile focus moves without
swapping the socket, so opening one bot's chat while the socket was homed on
another highlighted the wrong bot and showed the wrong bot's cronjobs
(community report: Newsanalyst chat open, Hermes highlighted).
- sdk: new host.state.focusedSessionProfile — owner profile of the focused
chat, resolved from the focused stored session's row stamp via
rememberedSessionProfile() (same ladder as remembered navigation and the
HUD), with the gateway profile as the draft/uncached fallback.
- hermes-bots: $focusedBotProfile = focusedSessionProfile || profile
(feature-detected; older desktops keep prior behavior). BotRow highlight,
RoutinesPane scope, and the $selectedBot tracker use it. Turn-busy 'work'
mood stays keyed to the socket-home profile (only it can be mid-turn).
- tests: SDK atom behavior (vitest) + plugin source-shape suite; prewarm
harness stubs gain the new atom.
- docs: SDK page + hermes-agent skill reference list the new atom.
The deeplink-driven plugin install flow shipped in #89464 (salvage of
#82735 by @serefyarar) had no docs. Adds:
- user-guide/features/plugins.md: "One-click install links (Desktop)"
section under Managing plugins — link forms (repo/enable/force), the
confirm-first dialog contract (never auto-installs, same install-time
security scanning as the CLI), hybrid-repo behavior, legacy
plugin-agent/plugin-desktop routing, hermes-dev:// in dev builds, and
the no-SDK anchor example. Cross-links the MCP "Add to Hermes link"
equivalent.
- developer-guide/desktop-plugin-sdk.md: "Distributing with an install
link" section so plugin authors find the link form next to the
packaging docs.
Extends ctx.os.notify (the curated plugin OS door from #78685) with icon,
action buttons, and a serializable `activate` target. Body/action clicks
focus the window and navigate to the plugin's screen; activation paths
share one resolver (hermes-open-target.ts) with hermes:// OS deep links,
so `hermes://index-network/intent/1`, `/index-network/intent/1`, and
{ path, params } all land on the same hash-router route. Approval
notifications keep their existing session-scoped channel.
Salvaged from PR #84192 by @serefyarar (net diff of the PR branch applied
onto current main; branch carried merge commits so a single authored
commit preserves attribution).
Update the desktop docs for five just-merged desktop changes:
- Settings → Gateway + Settings → Connections are now one "Gateways" page:
retitle every reference, describe the Add-connection flow's four kinds
(Local / Hermes Cloud / Remote gateway / SSH) and the save-time duplicate
rules (one local; URL-normalized dedupe across remote/cloud; user@host:port
+ remote profile for SSH), and describe the "Per-profile overrides"
subsection that replaced the page-level Applies to chip row.
- Document the shared "Applies to" profile scope on the config-backed
settings pages (Model, Workspace, Safety, Memory & Context, Voice, Chat,
Advanced, Tools & Keys) and the Messaging overlay.
- Agent plugins section: bundled built-ins are hidden (user/git/project/
pip/portable installs only), Example Plugin is gone, and the section has
its own Applies to selector backed by plugins.manage's optional profile
param.
- Bot Mode: group chats are standalone Discord-style roster rows and open
in the main chat window (older builds fall back to the in-panel view).
- Desktop Plugin SDK: document the new host.openWorkspace(id, { render,
title, minWidth, onClose }) door, its refresh/re-front semantics, and
the feature-detection fallback pattern.
Also retitles the Settings → Gateway references in the web-dashboard guide.
No new pages; sidebars.ts unchanged. `npx docusaurus build` passes.
Covers the precise pinned-session resolver added in PR #88690: request
param shape, the preferred_session response field, hidden-row and
compression-lineage resolution, and older-gateway behavior.
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.
The SDK doc still described the v1 frame (fixed height attribute, rail card
under the frame) — chrome that no longer exists. And plugin_storage's usage
example used `with plugin_db(...)`, which reads as auto-close but sqlite3's
context manager only scopes transactions; the example now closes explicitly.
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.
Independent second-pass review found three gaps:
- focusedUsage is null | UsageStats, not Partial — ClientSessionState.usage
is the full type (app/types.ts) and its only write site seeds the four
required fields before merging (gateway-event.ts). The earlier Partial
annotation traced the wrong type (SessionRuntimeInfo, an RPC payload).
Comment now names the genuinely optional fields instead.
- The tile-focus contract test never seeded $sessionTiles/$sessionStates,
so it proved focusedStoredSessionId follows a tile but could not
distinguish focusedSessionId/focusedUsage working from broken. Seed a
bound runtime with distinct usage and assert both readout atoms move.
- The two public host.state references (website docs + bundled skill
reference) enumerated the old six atoms; plugin authors would never
discover the new ones. Both lists updated.
tsc/eslint/vitest green (4/4).
Gate composer submit and plugin host busy on the target session slice, not a leftover foreground busyRef. Staff can keep typing while a worker session is running.
Includes the follow-up test that submit uses the target session busy flag.
Follow-up to the salvaged #87558 commit: the PR's docs promised the flags
follow "the focused chat", but PRIMARY_SESSION_VIEW is the primary
workspace tab only — a focused session TILE would read the wrong chat.
Wire host.state.busy / host.state.awaitingResponse through the focused
slice ($focusedStoredSessionId / $focusedSessionState), same semantics
as the statusbar busy pulse, with the primary view (and its draft
fallback) while the workspace holds focus.
Adds a tile-focus vitest case and corrects the docs wording.
Plugins can now read host.state.busy and host.state.awaitingResponse
for the focused chat. These follow the same session slice the chat pane
uses, so a draft falls back to the global flags and a background turn
does not leak.
Closing a pane contributed by a plugin used to disable the entire plugin,
unloading every one of its contributions. For a plugin that owns several
independent panes (e.g. Bot Mode's Cronjobs pane alongside its Bots roster
and composer middleware), closing one pane silently killed the rest.
Now: closing one pane of a multi-pane plugin dismisses only that pane; the
plugin stays enabled and its other panes/commands/middleware keep working.
Reset layout restores dismissed contributed panes. A single-pane plugin
keeps the existing symmetric behavior (Close disables the plugin, with
Settings -> Plugins as the recovery path).
Adds regression coverage for both cases.
Desktop plugins reach the backend exclusively through the generic ws
JSON-RPC door (host.request), but profile enumeration/creation only
existed on the dashboard REST router, which plugins cannot reach — so
anything 'one chat per agent profile'-shaped (bot rosters, profile
pickers, team panes) was impossible to build as a plugin.
- tui_gateway/methods_profiles.py: new @method handlers
* profiles.list — profiles + optional last_session preview per profile
(mirrors session.list's kanban/tool deny-list; best-effort per-profile
state.db probe degrades to null instead of failing the call)
* profiles.create — ws twin of POST /api/profiles (clone_from/clone_all/
no_skills/description), plus optional SOUL.md content and a best-effort
model+provider pin; mirrors the CLI flow (seed skills, safe alias)
Both run on the RPC pool, not the WS reader thread (list_profiles walks
skill trees; create copies bundles).
- SDK: host.openSession(id, { profile, intent }) — open a stored session
the way core surfaces do, soft-swapping to the owning profile's backend
first (ensureGatewayProfile), and host.newChat(profile) — fresh draft in
a named profile (same door as the sidebar's per-profile '+').
- Docs: desktop-plugin-sdk.md gains both surfaces.
First consumer: a Grok Bot-style 'Bots' roster plugin (one persistent
chat per agent profile with a New Agent dialog) built on exactly these
four doors.
Fold ctx.notifyNative into a ctx.os namespace so every way a plugin
reaches outside the app window lives behind one attributed door instead
of accreting one top-level ctx method per capability:
- ctx.os.notify — the native-notification door from the previous commit,
unchanged semantics (plugin kind pref, away-gating, per-plugin throttle).
- ctx.os.openExternal / ctx.os.revealPath / ctx.os.writeClipboard — the
existing window.hermesDesktop bridge capabilities, now sanctioned and
result-shaped: each resolves false (never throws) when the bridge or
member is missing, so a plugin branches on the result instead of
sniffing the preload surface or crashing on an older shell.
No new Electron surface: everything routes through bridge members the
app already ships; the notification path keeps every existing gate.
Desktop plugins can toast in-app (host.notify) but have no sanctioned way to
reach the OS notification pipeline the app's own approval/turn alerts use, so
a plugin surfacing a genuinely notable background event (e.g. a discovery
plugin finding a match) stays invisible once the user steps away from Hermes.
Add a curated per-plugin door instead of exporting the raw dispatcher:
- ctx.notifyNative({ title, body?, silent? }) on PluginContext — attributed
to the plugin id, routed through dispatchNativeNotification so every
existing gate applies (master + per-kind prefs, post-connect baseline,
away-from-app gating, throttle).
- New 'plugin' native-notification kind with its own Settings ▸ Notifications
toggle (default on), so users silence plugins without losing app alerts.
- New optional `tag` discriminator on the notify payload keys the renderer
throttle and main-process cross-window dedupe per plugin, so two plugins
can't collapse each other's session-less notifications.
Consumer: the Index Network desktop plugin wants background opportunity
alerts; anything in ~/.hermes/desktop-plugins gets the same door.
Add an end-to-end developer guide for extending the native Hermes Desktop
app introduced in #60638: the HermesPlugin contract, PluginContext, every
contribution area (panes, routes, sidebar nav, status/title bar, palette,
keybinds, themes, composer, mount-scoped Contribute), the host API, the
React Query + nanostores data layer, the UI kit + theme variables, the
scoped ctx.rest/ctx.socket backend (plugin_api.py under /api/plugins/<id>)
and its separate enable gate, Settings/defaultEnabled/storage, bundled
plugins, the security model, pitfalls, and a full reference.
- Register the page in the sidebar under Extending -> Plugins.
- Disambiguate from the unrelated web-dashboard plugin SDK from both
directions, and add a map-table row + a desktop user-guide pointer.
- Fill the gaps in the agent-facing hermes-desktop-plugins skill
(ctx.rest/socket + backend, React Query, defaultEnabled, Contribute)
and point it at the new reference so agents know the SDK when writing
addons.