Commit Graph

71 Commits

Author SHA1 Message Date
Brooklyn Nicholson
929de15c73 docs(desktop): correct WorkspacePageHeaderControl older-host note
Some checks failed
auto-fix lint issues & formatting / Generate eslint --fix patch (push) Has been cancelled
auto-fix lint issues & formatting / Apply patch (push) Has been cancelled
Deploy Site / deploy-vercel (push) Has been cancelled
Deploy Site / deploy-docs (push) Has been cancelled
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).
2026-09-27 13:18:46 -05:00
Brooklyn Nicholson
aa25f9e85f fix(desktop): Kanban board switcher outside the full-page layout
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).
2026-09-27 13:18:46 -05:00
Adolanium
d1167fdff7 fix(desktop): match custom:<key> providers in the settings and bot model pickers
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.
2026-09-27 12:49:56 -05:00
teknium1
be59f0411e fix(plugins): broadcast_plugin_event from the turn-isolation child reaches the Desktop
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).
2026-09-24 02:03:03 -07:00
teknium1
3593b63563 docs(plugins): broadcast_plugin_event contract, delivery scope and rss-reader migration
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.
2026-09-24 02:03:03 -07:00
tobenwarrior
8034d493dc feat(plugins): public event bridge for plugin backends
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).
2026-09-24 02:03:03 -07:00
teknium1
c2645ef14c fix(desktop): SandboxedFrame takes an explicit prop allowlist, not iframe attrs
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.
2026-09-24 01:53:34 -07:00
teknium1
a48557baff feat(desktop): trim SandboxedFrame tests to invariants; document the allowlist contract
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.
2026-09-24 01:53:34 -07:00
tobenwarrior
d81db86d61 feat(desktop): sandboxed embed primitive for plugins
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).
2026-09-24 01:53:34 -07:00
brooklyn!
efafed5baf fix(desktop): Kanban attachment download through the one gated resolver
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>
2026-09-24 03:42:42 -05:00
Johan Roest
d9a44a0ed4 fix(desktop): download Kanban attachments through authenticated gateway
(cherry picked from commit 1f9e64b0ed27f68885ad0a211914e7c10736c0ab)
2026-09-24 03:42:42 -05:00
teknium1
55c52f2902 fix(desktop): appearance extras use the pane boundary and render on the top-level page only
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.
2026-09-24 01:34:59 -07:00
teknium1
84ac69e884 feat(desktop): keep ColorSwatches export as on main; document APPEARANCE_AREAS.extra 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).
2026-09-24 01:34:59 -07:00
tobenwarrior
8d71403ffa feat(desktop): appearance-settings slot and the app's swatch grid for plugins
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).
2026-09-24 01:34:59 -07:00
teknium1
4cec3f85e7 fix(desktop): host.pluginDecisions hands out frozen copies, not the live store object
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.
2026-09-24 01:25:11 -07:00
teknium1
7daf8180f7 docs(desktop): capabilities bridge section; pluginDecisions documented read-only
Signatures, the profile-scoping rule, the WHY of the declined set() writer,
teardown note, and the exact better-capabilities migration table.
2026-09-24 01:25:11 -07:00
tobenwarrior
bd55633248 feat(desktop): typed skills/toolsets/profiles bridge for plugins
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).
2026-09-24 01:25:11 -07:00
teknium1
6b2c23ae42 fix(desktop): model-pill provider must return a string; non-strings fall through
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).
2026-09-24 01:12:58 -07:00
teknium1
1b77007a36 docs(desktop-sdk): model-pill label providers — signatures, arbitration, migration
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.
2026-09-24 01:12:58 -07:00
tobenwarrior
2b4a5a0924 feat(desktop): model-pill label providers for plugins
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).
2026-09-24 01:12:58 -07:00
teknium1
2553b8660b fix(desktop): sidebarNav.prefs cannot hide the Plugins row; document the real order rule and namespaced ids
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`.
2026-09-24 00:59:20 -07:00
teknium1
7fba0e6e73 feat(desktop): sidebar nav prefs become a sidebarNav.prefs contribution
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).
2026-09-24 00:59:20 -07:00
tobenwarrior
632a5cd1c8 feat(desktop): sidebar nav visibility and order for plugins
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).
2026-09-24 00:59:20 -07:00
teknium1
4f3426e7ca fix(desktop): host.sessions.reorder writes live ids; add reorderPinned; move verbs to sdk/sessions.ts
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.
2026-09-24 00:31:25 -07:00
teknium1
9d8ae38850 feat(desktop): host.sessions.pin takes a drop index; reorder([]) resets the manual order
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
2026-09-24 00:31:25 -07:00
tobenwarrior
585e296db5 feat(desktop): session-list API and row-decoration slots for plugins
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).
2026-09-24 00:31:25 -07:00
teknium1
21f6e35643 docs(desktop): host.settings — teardown rule, declined keys, consumer migration
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
2026-09-24 00:19:42 -07:00
Jony
748f0398e3 feat(desktop): expose typed plugin settings gateway 2026-09-24 00:19:42 -07:00
teknium1
ef70b3661c fix(desktop): host.composer 'new' fails closed, single-owner draft replies, verbs in sdk/composer.ts
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).
2026-09-24 00:01:41 -07:00
teknium1
931619742b docs(desktop-sdk): host.composer signatures, arbitration, consumer migrations; document COMPOSER_AREAS.underside
`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.
2026-09-24 00:01:41 -07:00
Binshao
6b3c0de462 fix(composer): gate active draft requests on isActive and acknowledge SDK inserts
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.
2026-09-24 00:01:41 -07:00
Binshao
a874cee625 feat(desktop): expose composer draft API to plugins (host.composer)
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).
2026-09-24 00:01:41 -07:00
teknium1
81279c5279 fix(desktop): bot profile writes take the pool's foreground slot
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.
2026-09-23 10:41:43 -07:00
teknium1
e1a6679399 fix(desktop): runtime plugin loader survives hung imports, leaked timers, duplicate ids and broken reloads
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).
2026-09-22 00:46:24 -07:00
teknium1
eba8199ce3 docs(desktop): say plainly that desktop plugins are not sandboxed
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.
2026-09-21 22:39:35 -07:00
teknium1
e089be80a1 docs(desktop-sdk): note DecodeText loop is opt-in
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.
2026-09-20 20:14:20 -07:00
teknium1
2fbcd8b0ea docs(website): link pages by relative Markdown path so they open on GitHub (#114428)
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.
2026-09-18 14:27:04 -07:00
teknium1
aee7de4db5 fix(desktop): point the remote-backend desktop-half tooltip at the install path
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.
2026-09-18 10:53:41 -07:00
teknium1
14d862384b fix(desktop): page-owned header controls get their own area; titleBar.center stays permanent
`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.
2026-09-18 10:45:44 -07:00
teknium1
ca396e0f7f fix(desktop): track plugin gateway listeners in the bundled loader and ctx.onEvent
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>
2026-09-16 17:46:28 -07:00
teknium1
8170c16cca fix(desktop): route every desktop-plugin tree copy through staged publication
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.
2026-09-16 17:17:22 -07:00
teknium1
c764d6d354 fix(shared): ensureContrast keeps the desktop's 0.2-step ladder; TUI chain opts into 0.05
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.
2026-09-13 06:50:57 -07:00
Teknium
248ff2d3e8 feat(desktop): one row per plugin; desktop halves are app-level copies, never profile-scoped
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.
2026-09-10 08:19:55 -07:00
Teknium
ff93f0ee9b fix(desktop): desktop plugins are app-level, not per profile; catalog gets a drag sash; Accent Picker moves to its own repo
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.
2026-09-10 08:19:55 -07:00
Teknium
61afcde8f9 refactor(desktop): one Plugins surface — Capabilities → Plugins owns agent + desktop plugins, install, and the catalog
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.
2026-09-10 02:30:50 -07:00
brooklyn!
a6102b8d80 docs(desktop): document Radio controls and privacy 2026-09-09 22:57:27 -05:00
Brooklyn Nicholson
89ecaf03d5 docs: document the desktop plugin SDK theme surface
`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.
2026-08-20 13:30:35 -05:00
Teknium
23c1c9815f fix(desktop): Bots sidebar highlight and Cronjobs tile now follow the chat on screen
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.
2026-08-18 19:51:13 -07:00
Teknium
0c5f195ee2 docs: document one-click plugin install links (hermes://plugin/install)
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.
2026-08-18 16:42:04 -07:00
seref
73ddf6665c feat(desktop): rich plugin OS notifications with deeplink activation
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).
2026-08-18 14:26:28 -07:00