Desktop
- Settings → Credential Vault gains a "Password managers" section: per-manager
toggle (disabled with a hint when the CLI isn't installed), Locked/Unlocked
pill, Unlock (masked master-password dialog → vault.unlock) and Lock.
Items from a manager show a source badge instead of a delete button.
- Mid-turn vault.unlock.request renders a masked card in the chat (same
contract as the secret/sudo cards: dismiss = keep locked, late answers
tolerated, blocks the composer, badges background sessions).
- i18n parity en/ar/ja/zh/zh-hant.
Ink TUI (hermes --tui): vault.unlock.request/expire overlay via MaskedPrompt;
Esc keeps the manager locked.
CLI: `hermes vault sources [--enable|--disable NAME]`; `hermes vault list`
shows the source column and names enabled-but-locked managers.
Docs: credential-vault.md covers managers, per-session unlock, and the
headless (cron/webhook/API/-q) no-prompt posture.
Consolidated re-apply of #96988 onto current main. Ported from
Merit-Systems/OpenInstinct (MIT) opaque-handle autofill design: the model
sees vault handles + login metadata, the password is resolved and filled
server-side over the supervised CDP socket, and filled values are scrubbed
from every browser tool result by an unconditional redaction registry.
Rebase adaptations to the Sep-2026 facade/sibling layout:
- toolsets: one _HERMES_CORE_TOOLS entry (the browser toolset derives from it)
- hermes_cli/main.py: vault parser registered via the subcommand owner table
- file_safety: vault/ joins the _READ_DENIED_DIRS credential-dir table
- redact: registry scrub runs before the redact_secrets early-return
- browser_vault_tool: _run_browser_command now lives in browser_tool_session
relay-deliver-budget.test.ts reads relay.ts and expects RELAY_DELIVER_TIMEOUT_MS within 400 characters of the bot_relay.deliver call. The comment above the new from_connection parameter pushed it past that window. The parameter names say what the gateway does with them.
delivery_turn_author kept the bare bot:<profile> id when the sender's connection was the Desktop's own "local", so a DM relayed from that machine collided with the recipient's profile of the same name. A relayed DM always crosses gateways, so the connection id is now part of the id whenever the Desktop sends one, and only the direct message_agent path in tools/bot_mode_dm.py stays bare. The relay.ts and session_auto_continue.py comments added earlier are cut to one line each.
A relayed DM stamped bot:<profile> on the recipient turn, so an ops profile on another machine and the local ops profile shared one author id. The Desktop now forwards from_connection with each bot_relay.deliver, and delivery_turn_author builds bot:<connection>/<profile> for it while the Desktop's own gateway ("local") keeps the bare id. An api author object accepts an optional origin string that yields the same shape.
a bot dm arrived as an ordinary user message. the only trace of the sender
was the "Message from" text prefix, which the model reads and nothing else
does. the recipient's memory provider saw its own configured user.
message_agent now passes the sender as {"id": "bot:<profile>", "name":
<handle>, "is_bot": true} to the delivery runner (--author <json>), which sets
HERMES_TURN_AUTHOR on the recipient one-shot only. the -Q turn reads it and
passes turn_author into run_conversation. the desktop relay forwards the
envelope's from_profile/from_handle to bot_relay.deliver, which sets the same
variable on its delivery turn. the runner drops any inherited author first so
a delivery without one stays unattributed. the text prefix is unchanged.
The sender-side waiter gave up at 900s while the Desktop held bot_relay.deliver open for 1500s, so a turn finishing between minute 15 and minute 25 wrote a reply nobody read. REPLY_WAIT_SECONDS now rebuilds the Desktop budget from the same numbers and waits 60s past it. The two turn constants move into tools/bot_relay.py so the gateway handler and the waiter share one definition.
With `voice.auto_tts` on and HUD mode active, a reply could be spoken
twice: the HUD renderer and the hidden app window both claim
`speak:<messageId>` through `hermes:ambient:claim`, and main collapsed
the two claims with the same 1 s deduper it uses for the turn-end beep.
The app window under the HUD is throttled by Chromium, so its transcript
subscription fires well past 1 s, finds the key pruned, and is told it
owns the cue. Fixes#99717.
The unit was wrong, not the window: a spoken reply is minutes of audio
keyed by a durable message id. `createAmbientClaimArbiter` keeps the
tick-sized window for `sound:*` and holds `speak:*` claims for a
10-minute TTL. No HUD special-casing: the same race exists between any
two windows showing the same chat, and the arbiter fixes them all.
Diagnosis credit: #99810 (@liuhao1024) and #100289 (@shivamjg101),
whose TTL framing this follows.
The first map on Hyprland can leave the HUD tiled when `j/clients` does not
list the new client within the 8 × 50 ms the promotion loop allowed; the
window then stays a stretched transparent tile (#103091). Widen the budget to
24 × 100 ms. The loop returns on the first successful float+pin, so a prompt
compositor pays nothing extra.
Salvaged from #103116. Its second half (re-promote from focusWindow's
re-show branch) is dropped: the HUD is destroyed and respawned on every
toggle and is not minimizable, so that branch never runs for it.
Keep the row-shape contract (a TurnRow carrying aui_message-group measures;
clearance/background-resume spacers do not) and drop the two cases that
only re-assert the selector's shape.
TurnRow roots now carry data-slot="aui_message-group", so the band's
*:not([data-slot]) selector measured an empty short session as 0px.
Include message-group rows while keeping the slot-less "show earlier" path.
Fixes#107050
Co-authored-by: Cursor <cursoragent@cursor.com>
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.
Symptom (Discord/X report, macOS): exit HUD mode and the small HUD window
stays on screen while the main window never regains focus; the toggle now
reads the wrong state, so there is no in-app way out.
Three defects in the HUD lifecycle, all in the main process:
- closeHudWindow() detached the window's 'closed' listener before calling
win.close(). close() is a REQUEST that waits on the renderer's unload
handlers, so a HUD renderer that never answered left an always-on-top
window with no teardown owner and no close broadcast. The 'closed'
handler now stays attached and is the single teardown path; a bounded
requestHudClose() (electron/hud-close.ts) destroys the window after a
1.5 s grace if the renderer never lets go.
- ⌘W inside the HUD was routed to the MAIN renderer's close-active-tab
(installPreviewShortcut is wired on every chat window), so it closed a
tab in the hidden app window and left the HUD up. ⌘W in the HUD now
runs closeHudWindow(), the same path as the exit button.
- restoreMainWindowFromHud() was a bare show() and only armed when the
main window was isVisible() at HUD-open time. On macOS isVisible() is
false for an occluded window (Electron folds occlusionState in), so
the app never came back when the HUD was opened over another app, and
show() alone does not make the window key. The restore now arms on a
live main window and routes through focusWindow() (restore → show →
focus). Closes#88513 (credit: PR #90489's diagnosis).
Renderer: the handoff now also requests composer focus on the close
broadcast, so the app window gets the keyboard, not just the session.
Docs: ⌘/Ctrl+W added to the HUD exit list.
config-settings.test.tsx runs vi.useFakeTimers({ shouldAdvanceTime: true })
and then polled with RTL's waitFor, whose interval/timeout also run on the
faked clock. On a loaded CI runner the interplay of real-time advance and
the faked poll interval starved the poll and the file timed out at 15s
(seen on a PR that never touched this file). vi.waitFor advances the fake
timers on every check, which is what the test needs.
Only the CLI could install a plugin at an exact commit (`--ref <sha>`); the
Desktop "Install from Git" dialog, the TUI gateway `plugins.manage install`
method and the dashboard `/agent-plugins/install` endpoint all called
`_install_plugin_core` without a ref, so a team that wanted everyone on the
same private-plugin commit had to leave the app for a terminal.
- `dashboard_install_plugin(ref=)` threads the pin to `_install_plugin_core`;
the 40-hex validation and HEAD verification are unchanged. The gateway
method and dashboard body accept `ref`.
- Desktop dialog gains an optional "Pin to commit" field (custom sources only,
client-side 40-hex check disables Install on anything shorter).
- `plugins.manage list` rows carry `pinned_sha`; the Desktop plugins tab shows
a `pinned @ <sha8>` badge and `hermes plugins list` prints `git pinned@<sha8>`
in Source, so a team can eyeball that everyone runs the same commit.
Treat the Desktop catalog selection as a (provider, model) pair so a
shared model id on OpenRouter cannot rewrite a custom or first-party
pick after refresh or keyboard highlight.
Live pass showed the banner spanning list + detail and the save toast carrying a
second 'Restart gateway' button beside the banner's 'Restart now'. Render the
banner inside DetailColumn (content width, above the platform header) and let the
toast stay a plain confirmation. Also hide the redundant 'Messaging gateway
stopped' setup pill when the state pill already says exactly that.
Port the dashboard Channels flow into the Desktop Messaging page:
- Telegram "Quick setup": the Nous pairing service mints a bot; the page shows
the QR / deep link, polls until Telegram confirms, pre-fills the detected
owner id into the allowlist, and applies. The backend writes the token +
allowlist into the scoped profile and restarts the gateway; the page watches
the restart child's exit so a failed restart surfaces instead of a silent
"gateway stopped".
- Restart-needed banner: every credential save / clear / enable toggle keeps a
banner with "Restart now" up until a restart actually completes. Toasts
vanish; the credential still only loads on the next gateway start.
- runGatewayRestart now resolves a boolean (never rejected before, so the
Webhooks page's catch path was dead and its banner cleared on failure).
- New API helpers carry the page's profile scope on every pairing call so
start/status/apply hit the same backend that holds the pairing in memory.
- i18n keys in all six locales; `qrcode` added to the desktop deps (already
pinned identically in web/).
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.
Hiding the app clusters on contributed full pages must not unmount titleBar.left/center/right; overlays still return null.
Co-authored-by: Cursor <cursoragent@cursor.com>
Plugin ROUTES_AREA pages own their titlebar chrome; the app clusters were sitting on top of it because only overlays hid them.
Co-authored-by: Cursor <cursoragent@cursor.com>
The Timezone field (and the voice/model ComboboxInput) pinned the popover to
--radix-popover-trigger-width. In the settings grid the trigger shrink-wraps to
its current value (~90-120px), so every IANA row was clipped to "Africa/A…" and
the picker was unusable by eye even though selection itself worked. Use min-w so
the list keeps its own width and only grows to cover a wider trigger.
Live-verified on a sandboxed Electron instance over CDP: popover 123px -> 288px,
rows no longer overflow; click, type+Enter and autosave to config.yaml all land.
The Electron composite project imported src/global.d.ts and followed its renderer-only dependencies. Share the notification payload from a dependency-free Electron module instead; all desktop typechecks and lint now pass.
Capture durable focus identity before runtime bindings disappear, retain native handlers, and keep runtime approval actions and plugin callbacks on their source renderer.