A named import of a missing SDK export fails to link through the runtime
shim, so the plugin never loads; it is not undefined. Point authors at a
namespace-import feature-detect or the raw Contribute form.
Refs #123597
Originally authored by Justin Haynes (@jhaynes).
A Kanban board opened in a split route tile rendered no board switcher:
the board contributed it to WORKSPACE_PAGE_HEADER_AREA unconditionally,
and only the workspace pane paints that area. The tile's contribution
also leaked into another page's header and shared its id with the full
page's, so closing the tile removed the page's switcher.
Add WorkspacePageHeaderControl (exported via the plugin SDK). The
workspace pane's render provides a private host context; inside it the
control projects into the page header, anywhere else it renders inline.
The board mounts BoardSwitcher once, through it, in its own header row.
Fixes#123597
Originally authored by Justin Haynes (@jhaynes).
model.info and saved profiles report a user-defined provider as custom:<key>, but the catalog row uses the bare key as its slug. Settings > Model and the Bot Mode picker compared the two with ===, so a saved custom provider never found its row. Settings showed a duplicate custom:<key> entry and a Set up provider button, and the bot editor fell back to the manual form.
Both now match rows with catalogProviderMatches, like the composer picker already does. Settings uses a small findCatalogProvider helper for every row lookup, including the aux and MoA slots and the endpoint passed on Set to main. catalogProviderMatches is now exported through the plugin SDK so the bot picker can use it.
Review finding (MAJOR-2): with dashboard.turn_isolation the plugin's agent-side
code (tools, hooks) runs in the compute-host child, where _live_transports is
empty and _broadcast_global_event dropped the frame at logger.debug. The child
now registers its _HostTransport as a live transport for the life of run_host,
so a session-less broadcast rides the existing host pipe; the parent bridge
(_relay_compute_host_rpc) recognises an event frame with no session_id and
fans it out via _broadcast_global_event instead of write_json, which would
have dropped it on hermes serve's stdio.
Minor: the late `from tui_gateway.server import …` could raise ImportError in
a plugin-only process despite the "safe from any handler" docstring — caught
and logged as a warning.
Docs: the SDK page now tables exactly which process each call site runs in
and what it reaches (hermes serve / compute-host child / stdio TUI / gateway
run + chat + cron = nobody).
The listener receives the GatewayEvent (destructure payload), names are
dotted, delivery is per process (hermes serve = every Desktop window), and
the exact rss-reader switch away from the jsonl queue + 3 s poll. One line
in the catalog trust model on the lint's regex/RegExp allowance.
A plugin backend pushing an update to its own desktop half imports
tui_gateway.server._broadcast_global_event — a core-private whose signature
is not a plugin contract. Give plugin authors a public, documented door.
hermes_cli.plugin_events.broadcast_plugin_event(plugin_id, event, payload)
emits plugin.<plugin_id>.<event> on the app's global event stream (the one
host.onEvent subscribes to), with the plugin id forced into the namespace and
the bare event name validated — a mangled name would strand the desktop half
waiting on a name nobody emits. Fire-and-forget, safe from any request
handler.
Wishlist item 8 of #116305; unblocks rss-reader (#115972).
Review finding (MAJOR) on #120927: the props type extended
`ComponentProps<'iframe'>` and the component spread them onto the element,
so a plugin could pass `allow="camera; microphone"` — the electron
`setPermissionRequestHandler`/`setPermissionCheckHandler` grant media
capture without looking at the requesting frame's origin, so that hands a
third-party site the app's mic/camera — plus `srcDoc` (replaces the `src`
contract with caller markup), `name`, `allowFullScreen`, `csp`,
`credentialless`. Props are now an explicit interface (className, style,
onLoad, onError, ref, sandbox, src, title) and nothing is spread, so none
of those reach the DOM even through a cast.
minor: `src` is scheme-checked — only http(s)/data render, anything else
(file:, blob:, javascript:, relative) renders nothing with a console.warn,
matching what the docs already promised.
minor: the SDK surface exports only `SandboxedFrame` + `SandboxedFrameProps`;
`sanitizeFrameSandbox`/`SANDBOXED_FRAME_DEFAULT_SANDBOX` stay module-internal.
Tests: the existing posture test now also asserts allow/srcdoc/name/
allowfullscreen never land on the element; one new test covers the scheme
refusal. Both were red on 17d1b046.
Nine change-detector tests become four invariants (one per behaviour, two
behaviours per module): every realm-escaping / unknown / mixed-case token is
dropped and an emptied set falls back to the default posture; allowlisted
tokens survive deduped; the rendered frame carries the posture; props cannot
re-open it.
Docs: TS signature, the exact allowlist and the strip list the ruling names
(allow-same-origin, allow-top-navigation*, allow-popups*, allow-modals,
allow-storage-access-by-user-activation), teardown, and the rss-reader
(#115972) migration off its stubbed /preview → openExternal chain.
A reader-style plugin embeds external content by mounting a raw Electron
<webview> on the app's persist:hermes-preview partition — sharing the app's
cookies and storage. Give plugin authors the app's own guest-content posture
instead.
SandboxedFrame renders a sandboxed iframe: opaque origin, allow-scripts by
default, no-referrer, lazy. Realm-escaping tokens (allow-same-origin,
top-navigation, popups, modals, storage-access) are stripped even when a
caller asks for them — a frame with no sandbox attribute is fully
privileged, so an empty result falls back to the default posture.
Wishlist item 9 of #116305; unblocks rss-reader (#115972).
Fold the Kanban attachment download onto the existing gateway file-save
resolver (lib/media.ts downloadGatewayMediaFile / captureGatewayFileDownload)
instead of the source PR's second resolver (api/file-download.ts): one auth
shape, one owner-scope contract, for every Desktop gateway-file save.
- lib/media.ts: downloadGatewayMediaFile now accepts an explicit owner scope
({ connectionId, profile }) so a capture snapshot (Kanban) and the ambient
$connection path (artifacts, chat previews) share one function. Adds
downloadGatewayFileWithFeedback (the toast wrapper) and
captureGatewayFileDownload (snapshots capabilityScoped() at read time).
- store/file-actions.ts: downloadRemoteFile is now a thin call into
downloadGatewayFileWithFeedback -- the same fileMenu.downloadSaved /
downloadFailed toasts the Files panel already used; cancel stays silent.
- plugins/kanban/drawer.tsx: rebased AttachmentDownload/AttachmentsSection
onto main's Dialog-based drawer (aside "Attachments" section), using the
app's boxless text-button treatment (size="inline" variant="text") to
match the drawer's other inline actions, with a Tip for a long filename.
- sdk/index.ts: keeps captureGatewayFileDownload as the plugin SDK export
(now sourced from lib/media, not a second module); the plugin docs keep
it as a void-returning, non-throwing capture (toasts already fire inside).
- types.ts: keeps KanbanAttachment.stored_path.
- Deletes api/file-download.ts and its resolver; drawer/file-actions tests
adapted for the new call shape plus a local-mode ownership case.
Root cause: the Kanban drawer only ever rendered the attachment filename
with no action, so on macOS (and everywhere else) there was no way to fetch
the stored file at all -- the backend already returns stored_path
(plugins/kanban/dashboard/plugin_api.py) but nothing in the renderer used it.
Tests: apps/desktop `npx vitest run --project ui src/plugins/kanban
src/lib/media src/api src/store/file-actions` (112 passed); the new drawer /
media-file-download / file-actions cases fail on main first (confirmed
against a scratch main worktree) and pass here. `npm run typecheck` clean.
Limits: attachments with no stored_path (older backend rows) keep the
control disabled rather than guessing a workspace path, matching the source
PR's compatibility stance.
Fixes: https://github.com/NousResearch/hermes-agent/issues/85672
Supersedes: https://github.com/NousResearch/hermes-agent/pull/107370
Supersedes: https://github.com/NousResearch/hermes-agent/pull/110161
Supersedes: https://github.com/NousResearch/hermes-agent/pull/87727
Co-authored-by: Johan Roest <229638764+jroest@users.noreply.github.com>
Review findings (minor) on #120912:
* `AppearanceExtraSlot` wrapped each contribution in `ContribBoundary
variant="chip"`, so a page-level plugin card that threw collapsed into a
bar-item chip meant for toolbar slots. Use the default `pane` variant —
the canonical ErrorState with Retry, matching every other zone body.
* The slot was mounted unconditionally, so every deep-link subpage
(`settings/appearance/<section>`, which shows exactly one built-in
section) also grew the plugin cards. Gate it on `subpage === undefined`
like the rest of the page's top-level-only chrome.
Tests: the boundary test now asserts the pane fallback (red on c322f692);
one new page-level test renders `AppearanceSettings` with and without a
subpage and asserts the extra mounts only on the top-level page (red on
c322f692). Docs updated to the new contract.
Drop the salvaged ColorSwatches re-export hunk: main already exports the
component from the SDK, and the plain-JS plugins this slot serves do not need
the props type, so the shared sdk/index.ts hunk stays a single added line.
Docs: TS signature, arbitration (every registration mounts in its own
boundary — no first-wins, plugins cannot suppress each other), teardown
(loader-owned disposer, nothing persisted) and the exact migration for
better-session-appearance (#115961) and hermes-appearance-hub (#116049).
A plugin adding appearance controls injects nodes into Settings → Appearance
and drives the app's widgets through React internals. Give it a seam.
APPEARANCE_AREAS.extra renders contributions at the end of the Appearance
page (own error boundary each, chip fallback), and ColorSwatches — the grid
the profile rail and project dialog already use — joins the public SDK
surface with its props type, so a plugin picks colours with the app's own
control and its own onChange (pair it with host.sessions.setColor for
session colours).
Wishlist item 7 of #116305; unblocks better-session-appearance (#115961).
Review finding (MAJOR) on the typed bridge: `pluginDecisions.get()`,
`.value` and the `subscribe`/`listen` callback argument returned the
store's own object. `pluginActive()` reads that same reference and
`saveDecisions({...$pluginDecisions.get(), [id]: enabled})` spreads it,
so `host.pluginDecisions.get()['other'] = false` silently disabled
another plugin and the next toggle persisted it — the declined `set()`
by another door.
Every value the read-only view hands out is now `Object.freeze({...v})`;
assignment throws under strict mode and the store is untouched. The
existing read-only test gains the mutation assertion (red on c1b740ac).
Docs: frozen-copy contract spelled out; cheat-sheet signature for
`host.profiles.list(scope?: ProfileScope)` made explicit.
Plugins configuring capabilities called window.hermesDesktop.api raw and
read/wrote the persisted pluginDecisions map directly. Give them the same
doors the Capabilities page uses.
host.skills.list/setEnabled, host.toolsets.list/setEnabled, and
host.profiles.list wrap the app's own api modules — same endpoints, same
profile scoping (a ProfileScope configures any profile without swapping the
app-wide active one) — and host.pluginDecisions exposes the decisions map
with set() going through the live toggle (deactivate/activate included),
never a raw storage write.
Wishlist item 6 of #116305; unblocks better-capabilities (#115960).
Review finding (MAJOR) on #120919: useComposerModelPillLabel accepted any
truthy return with `if (label)`. ModelPill drops the value straight into JSX
with no error boundary, so a plugin returning an object/array/number threw
"Objects are not valid as a React child" and blanked the whole composer —
exactly the failure mode the hook's throw-swallowing was meant to prevent.
Only a non-empty, non-whitespace string is now a label; anything else
declines like null.
Review finding (minor): the two existing tests are tightened instead of
adding new ones. The compact-mode "providers skipped" assertion was
`queryByText(...)` on a chevron-only render and passed regardless; it is now
a spy that must not be called. The throw test now also covers the
non-string return (red on b559ac9c: React child error) and two non-null
providers: the first registered string wins and the later provider's spy is
never invoked.
Docs: arbitration section states the string-only contract and that
`reasoningEffort` is always a string ('' when none).
Spell out the contract plugins will lean on: the context fields, the
"registry order, first non-null wins, throw = decline" rule, that compact
mode never consults providers, and that teardown is the contribution's
own disposer (nothing for ctx.onDispose). Include the exact migration for
compact-reasoning-label, noting that the core label no longer carries the
effort word, so the plugin's regex strip is already a no-op and the hook
is the place to compute a label rather than rewrite rendered text.
The compact-reasoning-label plugin rewrites the model pill's textContent
through a MutationObserver to show a compact reasoning label. Give it the
sanctioned seam instead.
COMPOSER_AREAS.modelPill takes data contributions with a label(ctx) resolver
({ model, reasoningEffort, compact }); the first non-null label wins and a
declining (or throwing) provider leaves the core label. The pill keeps its
chrome, pin dot, and menu — only the text changes.
Wishlist item 5 of #116305; unblocks compact-reasoning-label (#115962).
Review findings on #120922.
- Hiding `capabilities` removed the row that hosts the Plugins tab, the
user's only path to a plugin's own off-switch — the design law forbids a
plugin locking the user out of disabling it. `NEVER_HIDDEN` keeps that
row; it can still be re-ordered.
- Docs said "first-registered order wins", but `registry.getArea` sorts by
`Contribution.order` then insertion, so a later plugin with `order: -1`
pre-empts. The registry exposes no insertion order, so the docs (and the
SDK/store comments) now state the real rule: lowest `order`, then
registration. A registry-backed test pins it.
- Docs said a contributed row's id equals its `SIDEBAR_NAV_AREA` id, but
`createPluginContext.scope` namespaces it to `${pluginId}:${id}`; the
docs now give the namespaced form to use in `hide`/`order`.
Replace the persisted `host.sidebar.hide()/setOrder()` store from #116409
with a registry contribution area (`SIDEBAR_NAV_PREFS_AREA`), keeping the
contributor's pure merge function (now `applySidebarNavPrefs`) fed from
`useContributions()` instead of two persisted atoms.
Why: `host` is a module singleton that cannot attribute a write to the
plugin that made it, so a persisted preference outlives the plugin (a
hidden row with nothing left to restore it) and two plugins overwrite each
other's order. A contribution is attributed, merged with a stated rule and
dropped by the loader's per-plugin disposer on disable/reload, so the rows
come back on their own.
Arbitration: hidden = union of every contribution's `hide` (hide beats
order); order = first-registered contribution wins, later ones place only
ids not yet placed; unknown ids inert; unnamed rows keep default relative
order after the named ones. The user's choices persist in the plugin's own
`ctx.storage`; it re-contributes them (same id replaces).
No `host.sidebar` key, no `hermes.desktop.sidebarNav*` localStorage keys.
Tests: one pure arbitration invariant + the sidebar restoring the row on
dispose (red on origin/main).
A sidebar-manager plugin had no SDK door for moving or hiding nav rows —
today it hides core rows with CSS display:none and re-parents React-owned
children. Give it a preference store core reads at render.
host.sidebar.hide(navId, hidden?) and host.sidebar.setOrder(ids) write the
persisted nav-preference store; the sidebar applies them through one pure
function (orderSidebarNav) so the semantics are testable: hidden ids drop,
named ids come first in that order, unnamed rows keep their default relative
order after them, unknown ids are inert. The preference never mutates the
default list — removing the plugin leaves every row where it was.
Wishlist item 4 of #116305; unblocks sidebar-manager (#115973).
Review findings on #120916:
- MAJOR: `$sidebarSessionOrderIds` is keyed by the LIVE session id — the drag
path persists `reorderableRowIds` (`session.id`) and the sidebar reconcile
effect keeps only ids present in `unpinnedAgentSessions.map(s => s.id)`.
The row slots hand plugins the DURABLE (lineage-root) id, so feeding those
back into `reorder` dropped every compressed session from the order and,
when none survived, flipped `manual` off on the next render. `reorder` now
maps each id to its loaded row's live id through `sessionMatchesStoredId`
(the same matcher pin uses). Test: reorder with `_lineage_root_id != id`
was red on head (`['c','a','b']` written verbatim), green now.
- minor: the Pinned drag path (`setPinnedSessionOrder`) had no verb, so
drag-to-pin-session could not port its pinned reorder. `reorderPinned(ids)`
delegates to it after durable-id resolution; unmentioned pins keep their
slot (test red on head: property did not exist).
- minor: `durableSessionPinId` already resolves a loaded id through lineage;
the unloaded pass-through is now stated in the docs (pass the slot's durable
id for rows that may have scrolled out).
- shape: the ~55 verb lines appended to `sdk/index.ts` move to
`sdk/sessions.ts` (`sessionsHost`); index.ts is import + `sessions:` +
the SESSION_ROW_AREAS export.
- docs: `reorderPinned` in both API blocks; `SESSION_ROW_AREAS` /
`SessionRowSlotContribution` in the exports-at-a-glance table.
drag-to-pin-session drops a row between two pins, so `pin(id)` appending to
the end of the Pinned list is not enough — it read `pinSession(id, index)`
off the row's fiber for exactly this. The store already accepts the index;
expose it as the verb's third argument so a drop lands where it was aimed.
The same plugin's "reset" path calls `reorder([])`. The app's own drag
handler reaches the default sort for an empty list only through the
sidebar's reconcile effect one render later; the verb states it directly
(manual = ids.length > 0) so a plugin reset never depends on a mounted
effect, and the semantics are documented rather than incidental.
Tests trimmed to invariants: the two slot tests cover both areas + the
durable id and late-arrival + teardown; the pin/reorder tests gain the index
and reset assertions. All six were red against origin/main's session-row.tsx
and sdk/index.ts and green on head.
Docs: TS signatures, arbitration (all slots mounted, own boundary; verbs =
last write wins on user data), teardown, and the exact migration for
drag-to-pin-session (#115963) and better-session-appearance (#115961).
Part of #116305
Plugins that manage or decorate sessions had no SDK door for it: they walked
the row component's fiber for onTogglePin/onReorderSessions props and wrote
the persisted sessionColors key directly. Give them the same doors core uses.
host.sessions.pin / reorder / setColor write the stores the app's own
controls write — the row's shift-click, the drag persist, the colour picker —
so a plugin action and a hand click can never disagree. Ids resolve through
the app's lineage matcher to the durable (lineage-root) id, so pins and
colours survive compression's session-id rotation.
SESSION_ROW_AREAS.leading / .trailing render slots let a plugin decorate a
sidebar row, with the row's stored session id handed to its render. Mounted
like chat.empty: own error boundary, can subscribe to its own stores, every
registration mounted so a declining plugin cannot suppress the owner.
Wishlist item 3 of #116305; unblocks drag-to-pin-session (#115963) and
better-session-appearance (#115961).
Move the section out of the middle of the host.request explanation to the end
of "Host API" and complete it per the #116305 design: TS signatures; the
arbitration rule (closed allowlist -> owning store setter, sync throws, never
localStorage); the teardown rule (host is a module singleton and cannot
attribute a subscriber, so plugins must ctx.onDispose the subscribe disposer);
the declined keys with the reason each stays out (keybind map -> KEYBINDS_AREA,
theme/mode -> THEMES_AREA, pluginDecisions -> Plugins tab, appearance-hub's
extra keys -> per-store follow-ups); and the exact hermes-appearance-hub /
prompt-snippets migration off localStorage + synthetic StorageEvent.
Part of #116305
Review findings on #120907:
- MAJOR-1: resolveComposerAddress mapped 'new' to target 'active', so
insertText/submit/focus('new') landed in whatever composer the bus
routed to (a tile showing session X) while the docs promised the
session-less draft. 'new' now resolves to the primary composer only
while it shows no session ($activeSessionId and $selectedStoredSessionId
both empty — the same condition under which its getIds() is
['__new__']), otherwise to no target and the verbs return false / drop.
- minor-1: an id-addressed draft request was answered by every owning
surface (primary pane + keep-alive tile of one session both painted).
The first owner stamps `claimed` on the shared detail; later owners skip.
- minor-2: getDraft's stash fallback keyed on ids[0] (the requested,
possibly runtime, id); the stash is keyed by the stored id, so a
runtime-addressed read of an unmounted session came back null. Key on
the resolved stored id.
- minor-3: JSDoc claimed an absent surface "rejects"; nothing rejects —
wording now matches the null/false contract.
- shape: the ~130 lines of verb bodies + resolver move out of the
sdk/index.ts facade into sdk/composer.ts (`composer: composerHost`),
like the settings/bridge lanes.
- tests: collapse the six "no surface -> null/false" assertions and the two
self-responding plumbing tests; each behaviour keeps <= 2 tests, the
three findings above are folded into existing tests (all red on 8fe7b682).
`underside` has existed in contrib.ts since the composer dock landed but the
SDK page never listed it, so next-prompt registers the string literal. Spell
out the fail-closed arbitration rule and the no-teardown property of the
draft verbs, and give each held catalog plugin its exact reach-in -> SDK
replacement so the migration is copy-paste.
Two contract edges from the #116389 review pass:
1. active draft requests now require address.isActive() — every mounted
surface used to claim them, so listener registration order, not the
focus bus, decided who answered; with keep-alive tabs in the stack a
buried composer could read the active draft and a set painted onto
every mounted draft.
2. host.composer.insertText returns Promise<boolean> via an acked insert
on the bus (same trim + deferred dispatch, plus a token the claiming
surface echoes back). Blank text, no claimant, or a refused write
settle false — the fail-closed semantics setDraft/submit already
use — instead of a silent no-op.
Both insert subscribers acknowledge; internal fire-and-forget inserts
(no token) are unchanged.
Implements item 1 of #116305 as a thin facade over the app's own composer
bus — no new state, no behavior change for non-plugin paths:
- host.composer.getDraft/setDraft/insertText/submit(sessionId | null, ...)
addressing: null = active, stored/runtime id = that session's primary or
tile surface, 'new' = the session-less fresh draft.
- focus.ts: hermes:composer-get-draft / -set-draft request/reply pair
(50ms timeout settles null/false; a non-answering subscriber can never
strand the caller).
- use-composer-draft.ts: mounted composers answer for their live DOM text
(paintDraft owns writes, so @-ref / / tokens hydrate as chips like
official paste); hidden keep-alive surfaces answer too — paintDraft
never steals a visible caret.
- store/composer.ts: export NEW_SESSION_DRAFT_KEY for address mapping.
- tests: bus-level (focus.test.ts, 5) + facade-level (sdk/index.test.ts,
4: addressing, live read, stash fallback, timeout-null, refused write).
- docs: 'Composer draft API' section in desktop-plugin-sdk.md.
Unblocks the DOM-reach-in in prompt-snippets (#116030), prompt-enhancer
(#116031), and the insert path of appearance-hub (#116049).
With the pooled local backend (cap 3, one slot reserved for foreground
dials), filing a bot into a section, saving its Configure editor, changing
its avatar or duplicating it ran profiles.configure/set_asset/create at the
background default. Once two warm bots held both background slots, the
cold bot's write queued for 30 s, timed out, entered the background retry
backoff, and was lost: the roster showed the change but the bot's
profile.yaml never got it.
requestForBot now dials those profile writes at foreground priority unless
the caller says otherwise, and the Configure editor's load (describe +
mcp.catalog) is marked foreground because the user just opened it. Polling
and roster warming keep the background default.
Four error-isolation holes in the disk plugin door
(apps/desktop/src/contrib/runtime-loader.ts), found by a static+live audit of
the loader; none had an issue filed.
- A plugin whose module evaluation never settles (top-level `await` on a dead
host) hung `import()` forever and, through the scan's sequential loop and
its re-entrancy guard, froze every later plugin and all future scans until
restart. `import()` now races a 10 s deadline; the plugin errors on its own
row ("import timed out") and the scan continues.
- Timers and DOM listeners a plugin took out with bare globals survived
disable and every hot-reload. `ctx.setTimeout` / `ctx.setInterval` /
`ctx.addEventListener` are tracked with the plugin and torn down on
unload; the SDK doc says bare globals are not.
- Two folders exporting one plugin id silently last-wins: the second
disposed the first's registrations and each hot-reload flipped ownership.
The first (folder-name sorted, so deterministic) owns the id; the later
file errors on its own row ("duplicate id, already loaded from <path>").
- A save that no longer loads (syntax error, timeout, duplicate) left the old
incarnation's contributions and activate handle live beside the error
row, so the Plugins tab showed a broken file as "loaded" and could
re-enable stale code. The previous incarnation is unloaded and dropped.
Tests: one invariant per fix in runtime-loader.test.ts, all red on base
(the hang case red by timing out).
The catalog page claimed admission's lint means a marketplace install 'cannot quietly rewire the app'; the lint is a handful of regexes and plugin.js runs in the app realm with the full window.hermesDesktop bridge. The user guide, catalog trust model and SDK security section now describe the real model: human review of a pinned SHA plus two tripwires (lint + loader import allowlist), no isolation.
DecodeText is a published plugin-SDK export; flipping its `loop` default
to false silently changes third-party plugin visuals, so the SDK doc says
so where the component is listed.
Mechanical `check_doc_links.py --fix` pass over website/docs (hand-authored
and generated pages) and the zh-Hans mirror: 1,868 route-style links
(`](/section/page#anchor)`, `](/docs/...)`) become `](../section/page.md#anchor)`.
Every target was asserted to exist on disk; anchors and query strings are
preserved; fenced code blocks and inline-code examples are untouched.
Two dead targets found by the converter were fixed by hand first:
memory-providers.md linked `/user-guide/plugins` (page is
`user-guide/features/plugins`), and the zh-Hans learning-path still linked the
removed `rl-training` page — ported the EN treatment (external Atropos link).
Docusaurus build after: EN locale 0 unresolved Markdown links, 0 broken links,
0 broken anchors.
The honest "unavailable (remote backend)" state (previous commit) tells the
user the copy will never happen; the tooltip now also says what does work
against a remote backend — Install from Git with the Desktop target checked
clones the desktop half onto this machine (the install modal already takes
that branch for connection.mode === 'remote'). Docs note the new state next to
the existing remote-backend paragraph.
`444c75c10a` rendered `titleBar.center` in the workspace panel header on
full pages so the kanban board switcher would sit beside the page title
instead of colliding with the sidebar tab strip. That made the area migrate
between two React subtrees on chat <-> page navigation: the old instance's
effect cleanup ran after the new instance's setup and wiped every global
side effect a third-party plugin had just re-created (#114290).
Keep both intents: `titleBar.center` is a permanent titlebar slot again (the
salvaged commit), and page-owned controls move to a dedicated
`WORKSPACE_PAGE_HEADER_AREA` (`workspace.pageHeader`) that the workspace
pane projects into its vetoed tab row while `$workspaceIsPage` holds —
exactly the placement `444c75c10a` introduced, just not through the
plugin-facing titlebar area. Kanban's board switcher contributes there.
- SDK exports `WORKSPACE_PAGE_HEADER_AREA`; `TITLEBAR_AREAS` documents the
permanent-mount contract.
- Test: a `titleBar.center` component's effect runs setup once and cleanup
never across a chat -> /skills -> chat round trip (red on base).
- Docs: plugin SDK guide covers the lifecycle contract and the page-header
area.
The cherry-picked fix scoped host.onEvent disposers into the runtime
loader's per-plugin disposer list. This finishes the class:
- apps/desktop/src/contrib/plugins.ts: the bundled loader's activate()
wraps register() in the same trackGatewayEventDisposers scope, so a
Capabilities > Plugins disable/re-enable cycle no longer strands a
bundled plugin's gateway listeners either (same accumulate-per-toggle
mechanism as the disk hot reload).
- apps/desktop/src/contrib/plugin.ts: PluginContext.onEvent — a tracked
door for subscriptions made AFTER register() returns (timers, socket
callbacks), where the register-time scope cannot see them and a bare
host.onEvent still needs hand-wiring to ctx.onDispose.
- apps/desktop/src/sdk/index.ts + website desktop-plugin-sdk doc: state
the contract plugin authors can now rely on.
- Tests: one ctx.onEvent invariant (red on base: TypeError) in
plugin.test.ts; the contributor's events.test.ts control case dropped
(green on base, no invariant) to keep the fix at two tests.
Why: unloadRuntimePlugin / deactivate only run ctx-tracked disposers;
anything a plugin subscribes outside track() survives every reload, and
one relay marker then opens N sessions (#112366).
Fixes#112366
Co-authored-by: Kevin Rajan <7121943+kvnloo@users.noreply.github.com>
Extract the contributor's staging + rename sequence into
`publishDesktopTree` and make `installDesktopPluginFromGit` use it too:
its `copyDesktopTree` had the same mkdir + cp shape, so a copy that failed
half-way left an empty `<root>/<name>/` and every later install attempt
was refused with "already exists. Enable force reinstall to replace it".
One helper, both copy sites, same guarantee: a published folder is always
complete (marker included) or absent.
Docs: describe the staged copy and the marker-less/plugin.js rule in the
desktop plugin SDK guide.
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).