Adds a reviewable in-app install path for Hermes plugins:
hermes://plugin/install?repo=owner/repo (and Settings -> Plugins ->
Install from Git) opens a confirmation modal showing the repo identity
and source links, shallow-clones to probe for agent and/or desktop
plugin artifacts, lets the user pick components, then installs — agent
side through the gateway's new plugins.manage `install` action (wrapping
the existing dashboard_install_plugin), desktop side through a new
Electron git-install module with subdir-escape guards, a 60s clone
timeout, non-interactive git env, and insecure-scheme warnings. Never
auto-installs; hybrid repos get one dialog. Legacy plugin-agent /
plugin-desktop deeplinks route into the same modal.
Salvaged from PR #82735 by @serefyarar (net diff applied onto current
main as a single authored commit; the branch carried merge commits).
The preview screenshot PNG from the original branch was intentionally
not carried over — images live in PR bodies, not the repo.
The revert to the committed theme lagged behind Escape. The palette
body stays mounted through the whole exit animation, and the preview
was cleared at unmount. So the repaint waited for the fade.
Subscribe to the palette open store in the body and clear the preview
the moment the store flips to closed. The unmount clear stays as the
backstop for a body that dies without a close.
The highlight preview did not fire. cmdk calls the root onValueChange
only in controlled mode, when the value prop is set. The palette is
uncontrolled, so the preview callback never ran.
Add a HighlightWatcher child that subscribes to the cmdk store with
useCommandState. The store reports the highlight in both modes. The
watcher replaces the dead root prop.
The new test renders a real uncontrolled cmdk root. It proves that
the watcher fires and that the root prop stays silent. If cmdk later
fires the prop in uncontrolled mode, the second assertion fails, and
the watcher becomes removable.
The theme rows in the Cmd-K picker applied a theme only on select.
Now the highlighted row paints its theme immediately.
cmdk reports the highlighted row through onValueChange on the root.
A new optional onHighlight callback on PaletteItem receives it. The
theme rows preview through a new previewTheme function on the theme
context. The preview is not persisted. A highlight on a row without
onHighlight, a page change, a palette close, or a commit clears the
preview. Then the committed appearance returns.
The global SESSIONS sidebar only aggregated local profiles and v1 per-profile
remote overrides. Sessions living on v2 registry connections (remote/cloud/ssh
gateways) never appeared — the remote API returned the rows, but the renderer's
Sessions component received an empty array (#88880).
- electron/profile-session-routing.ts: fetchRegistrySessionRows reads each
CONNECTED registry gateway's session list (ssh backends natively, shared
remote/cloud hosts via one cross-profile aggregate with a legacy flat-list
fallback), tagging rows with connection_id + owning profile.
spliceRegistrySessionRows dedupes them into the unified list and extends
per-profile totals. Reads never pass include_hidden, so Bot Mode's hidden
canonical chats stay OUT of the global list, same as local sessions.
- electron/main.ts: mergeRemoteProfileSessions splices registry rows; the
/api/profiles/sessions[+/sidebar] intercepts also fire when registry
gateways are pooled (previously only v1 remote overrides). Only
already-pooled backends are read — a sidebar refresh never dials or spawns
a backend (the roster-respawn trap), and a dead gateway contributes nothing.
- types/hermes.ts + use-session-actions: SessionInfo carries connection_id;
resuming a registry-owned row activates its connection-scoped gateway
(ensureGatewayAgent) instead of a same-named local profile.
- store/gateway.ts: ensureActiveGatewayOpen rides out an in-flight secondary
activation (bounded 8s) instead of failing instantly — the Sessions "+"
during remote wake no longer errors "Hermes gateway is not connected".
Tests: 5 new registry-source/splice unit tests (tagging, shared-host
aggregate + legacy fallback, dead-gateway isolation, hidden-flag contract,
dedupe/totals) and a sabotage-verified activation-wait regression test.
Two hardening follow-ups on the salvaged #84192 work:
- dispatchNativeNotification now reports whether the notification actually
reached the OS bridge, and dispatchPluginNativeNotification registers its
onActivate/onAction closures only on true. Previously a throttled,
disabled, or baseline-suppressed notification registered handlers that no
click could ever clear, leaking them for the window's lifetime.
- The renderer's onNotificationActivate handler re-resolves the activate
payload through resolveHermesOpenPath instead of trusting the pre-IPC
validation, keeping path validation in one funnel for any future
hermesDesktop.notify caller.
Adds a regression test covering the throttled and suppressed cases.
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).
The inline branch of MATH_SPAN_SPLIT_RE excluded `$` from the body outright,
so a `\$` inside inline math — a literal dollar sign, valid TeX — broke the
span match and the shield silently didn't apply. `$\sqrt[3]{8} + \$5$` still
lost its index.
Step over escape pairs instead, matching the escaped-delimiter rule
findClosingSingleDollar already applies via isEscapedAt. The two body
alternatives are disjoint on their first character, so the added quantifier
can't backtrack ambiguously.
Reported by Copilot in review.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SPGjEQ2yrS4nWiooUEdYti
`$\sqrt[3]{8}$` renders as a plain square root — the index is gone. It is
not a KaTeX layout problem: the index never reaches KaTeX. CITATION_MARKER_RE
strips `[3]` as a citation marker, because its lookbehind accepts any letter
and the `t` of `\sqrt` qualifies. That runs inside normalizeVisibleProse,
which splits out inline code spans but not math, so TeX is fed to rewrites
written for prose.
Numeric-only, which is why `\sqrt[n]{8}` survives and made this look like a
layout edge case rather than a preprocessing one.
Shield math the same way inline code is already shielded: split each prose
part on math spans and rewrite only the segments between them. That also
takes math out of the reach of the other rewrites in that pass
(autoLinkRawUrls, LOCAL_PREVIEW_URL_RE, the ``` stripper, linkifySessionRefs),
any of which can corrupt TeX the same way with different input.
The split is capturing, and math segments are identified by index parity
rather than a leading `$`, so a prose run that merely opens with a stray
dollar cannot be mistaken for math. Escaped `\$` delimiters stay prose, which
is what keeps `$5 and $10` escaping intact.
Verified in the real renderer through the desktop mock-backend E2E harness:
`.katex .root` (the span KaTeX emits for a radical index) goes from 1 to 4 on
the same four-radical reply, and the MathML annotations show KaTeX receiving
`\sqrt[3]{8}` intact rather than `\sqrt{8}`.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SPGjEQ2yrS4nWiooUEdYti
Multi-line display math whose $$ delimiters hug the body
(e.g. $$\begin{aligned}...\end{aligned}$$) renders as raw error text.
remark-math's flow-math construct is fence-shaped: text after the
opening $$ on the same line is read as an info string and discarded,
and the closing $$ is only recognized alone on its own line. So the
block never closes and KaTeX paints the remains via its error fallback.
splitHuggingDisplayMath moves those delimiters onto their own lines. It
runs AFTER normalizeMathDelimiters because that rewrite is itself a
source of the hugging form: a multi-line \[...\] comes out of it as
$$\begin{aligned}...\end{aligned}$$, so the same bug reached users who
never typed a $$ at all.
Single-line $$...$$ is left alone (it routes through the inline
math-text construct and already renders), container prefixes are
replayed onto the delimiter lines, and both patterns anchor $$ to the
start of the line, which keeps them from firing inside an inline code
span.
Verified end to end: the repro emits katex-error through
remark-math + rehype-katex before this change and not after.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Bot Mode wake-ups died on a routing split-brain: session.resume /
session.activate / session.usage were dispatched on whatever socket was
active at request time, while the bot's own backend sat healthy and idle
(zero traffic until the idle reaper killed it). Two divergence sources,
both fixed:
1. Registry-owned route truth. applyActive() now publishes the active
route's bare profile ($activeGatewayRoute + onActiveRouteChanged), and
use-gateway-boot mirrors it into $activeGatewayProfile. Previously,
eviction fallbacks (idle reap, connection removal, profile delete)
moved the SOCKET back to the primary while the profile atom kept
naming the evicted bot — ensureGatewayProfile's "already active" fast
path then trusted the stale atom and skipped the re-swap forever.
2. Request-time routing for session-scoped RPCs. resumeSession's RPCs go
through requestForSessionProfile (store/session-request-router.ts):
when the active route serves the session's owning profile the ambient
dispatcher is kept (reauth-aware reconnect); when it diverges — a
concurrent switch won the mutex, a failed dial left the old socket
active, an eviction re-pointed the route — the RPC is pinned to the
owning profile's own socket via requestGatewayForProfile.
Diagnosed from zero trust's debug bundle (loki/hulk/teknium-kun backends
READY then idle-reaped, renderer stuck on "Waking up… → retries gave
up") and DanBennettUK's #89206 trace (profile socket accepts, closes
with messages=0, no resume RPC observed).
Both layers sabotage-proven: reverting the route publish fails the
lockstep/eviction tests; reverting the request-time routing fails the
wrong-socket dispatch test.
Assistant messages that link a file the agent wrote —
[report](/home/user/report.md), file://…, ~/…, C:\… — rendered as dead
anchors: file:// is blocked in the renderer, Streamdown's URL hardening
turns file:/~/ hrefs into "[blocked]" spans, and on a remote gateway the
path isn't on the viewer's disk at all. Issue #82140 proposed exposing
the Desktop connection mode to skills/MCP/plugins so EXTENSIONS could
emit different output per viewer; this fixes the symptom at the right
layer instead — the viewer surface resolves paths at VIEW time, so
extension output stays surface-agnostic and the same transcript works
from every machine that opens it.
- markdown-preprocess: routeFileLinksToPreview() rewrites filesystem-path
links in prose to the renderer's existing hash-href doors —
#preview/… (PreviewAttachment) for documents, #media:… for
audio/video/image extensions. These pass URL hardening by design and
resolve through normalizeOrLocalPreviewTarget / resolveMedia*Src:
local connections read the file directly, remote connections fetch
over the authenticated /api/fs bridge. Image syntax, fences, inline
code, anchors, relative and http(s) links untouched.
- markdown-text: MarkdownLink routes any filesystem href that still
reaches it (bypassing preprocess) to PreviewAttachment/MediaAttachment
instead of a bare dead <a>.
- media.ts: export isFileMediaPath.
Live E2E (built app, CDP-driven, fixture session with links to a real
gateway-side file):
- BEFORE: [report.md] = dead <a href="/home/…"> (click: nothing),
[notes](file://…) = "notes [blocked]" span, 0 preview affordances.
- AFTER: both render as attachment rows; Open preview shows the file's
content in the preview pane; zero blocked spans; screenshots verified.
Closes#82140. Supersedes PR #82187 (connection-mode API): with view-time
resolution the extension layer no longer needs to know where the viewer
sits.
A `.md` delivered via MEDIA has no entry in MEDIA_BY_EXT, so mediaKind()
classified it as a generic 'file' and MarkdownLink rendered a download-style
anchor. Markdown is renderable content: route markdown document paths to
PreviewAttachment (source='tool-result'), which opens them in the right-rail
preview pane — where .md already renders with a rendered/source toggle and,
since #89381, full KaTeX math, tables, images, and links.
Resolves#84951 (the MEDIA delivery half; the rail-side rendering half landed
in #89381).
The Bots pane docks into the sessions zone but did not declare
collapsible. Below the sidebar-collapse breakpoint, the sessions pane
left the grid and the zone kept a stranded BOTS tab on screen.
The Bots pane now declares collapsible, so it leaves the grid with
its zone. The narrow edge overlay now mirrors the zone's tab strip
when the revealed pane has collapsed zone-mates. Without the strip,
only the first pane of the zone was reachable while collapsed. A
lone pane keeps the stripless overlay form.
Each closeable horizontal tab now shows a close button when the
pointer is on the tab. A small gradient fades the button into the
tab surface, so long labels fade under it instead of a hard clip.
The gradient reads the tab's effective surface color. This color
tracks the hover and selection washes, so the fade is correct on
every theme.
Middle-click and Cmd-click still close the tab. Vertical rail tabs
keep those gestures and do not get the button.
Export MarkdownPreview and cover the regression this PR fixes: KaTeX output present with no raw $$ delimiters left, GFM table structure, image alt/src, and external links opening with noopener noreferrer. Aligns the import order (perfectionist/sort-imports) and documents the mathPlugin module-scope setup to match the chat renderer.
The preview renderer for .md files was missing the math plugin, table/image/link components, and the markdown preprocessing pipeline that the chat transcript renderer has. Add KaTeX math rendering (inline $...$ and block $$...$$), table, image, and link support so file previews match the chat rendering.
DOM-level render test mounting the exact file-preview pipeline
(normalizeFilePreviewMath -> Streamdown + memoized math plugin) and asserting
`.katex` output for inline $.x.$, display $$..$$, and \(..\) delimiter math,
plus that a code-fence $.HOME.) stays code.
The right-rail file preview rendered markdown through Streamdown with no
plugins and no math preprocessing, so $...$ and \(...\) stayed as raw
source text. Wire the memoized KaTeX plugin (same one the chat transcript
uses) into the preview and preprocess prose with a math-only normalizer that
skips chat-only transforms (reasoning-block stripping, session-ref linking,
preview-target stripping, URL autolinking, citation stripping) so a file's
prose, code fences, and inline code spans are never mangled.
- preview-file.tsx: import + module-scope createMemoizedMathPlugin, pass
plugins={{ math }} to Streamdown, preprocess text with
normalizeFilePreviewMath before render
- markdown-preprocess.ts: add exported normalizeFilePreviewMath
- markdown-preprocess.file-preview.test.ts: 8 tests (currency escaping,
delimiter normalization, fence/inline-code preservation, verbatim
citations/URLs/reasoning blocks)
Follow-up to the root-cause fix: comment documenting why the opt-in
exists, plus a source-contract test proven to fail without the
attribute (sabotage run).
Group-chat message bodies in the hermes-bots plugin render without the
data-selectable-text attribute, so they inherit the app-wide
body { user-select: none } and cannot be drag-selected or copied.
1:1 chat messages already carry the equivalent marker
(aui_assistant-message-content), so this aligns group chat with that
behavior by adding data-selectable-text="true" to the message body
wrapper.
The salvaged gate only requested an explicit session.resume when the target
stored session was ALREADY selected — but the field failure (#89206) is the
cold open, where the persisted route points at the bot's session while
selection/runtime/transcript are all unsettled. The precondition skipped the
resume exactly when it was needed, and the hydration wait timed out into a
blank pane.
- sdk/index.ts: judge the main surface AFTER openSession() navigates, and
request a sequenced resume whenever the surface is not healthy (selected +
runtime bound + expected transcript present). Redundant requests are
consumed as no-ops by the route-resume effect.
- hermes-bots plugin: widen the fix to the sibling open path — the profile
session browser (openProfileSession) now opens with the same
awaitHydration/expectHistory contract as canonical Bot Chats, so a stale
main surface gets the same explicit resume instead of a silent blank pane.
- Regression test for the cold-open shape, proven failing against the
pre-fix gate (sabotage run) and passing with it.
Bot Mode group chats were named once at creation and could never be
renamed, and rooms had no picture — only the fanned member faces.
- New Group Chat dialog: optional room picture (upload from device or
image.generate, same 256px normalize pipeline as bot avatars).
- Room header: gear button opens Group settings — rename the group or
set/replace/remove the picture after creation.
- renameGroupChat re-keys the room record (log, watermarks, sessions,
members, picture), swaps the name in every local member's ui_meta
groups list, follows open views to the new name, and rejects
collisions instead of silently suffixing. Stored member sessions keep
resuming by sid, so no history is lost.
- Room picture persists in the durable room record, hydrates on window
load, and renders in the roster row (over the face pile), and the
room header.
A profile belongs to one gateway, but the Capabilities surface (Skills /
Tools / MCP) always read and wrote through the window's active backend —
scoping to a remote-owned profile silently edited the wrong machine.
- hermes.ts: capability REST helpers accept a ProfileScope
(string | {connectionId, profile}); ambient path now also carries the
active registry connection tag (same contract as the cron helpers,
#87882); profileScopeKey namespaces cache keys per connection.
- SkillsView: scope selector lists (profile, device) rows from the union
agent roster on multi-connection desktops; new fixedConnection prop
pins the whole view to a registered connection (plugin door), with a
probe-able SkillsView.supportsFixedConnection flag.
- MCP tab: live reload.mcp RPC withheld for cross-backend scopes (it
rides the active gateway socket and would reload the wrong machine).
- Bot Mode: remote-target drafts now get the live Capabilities tab
pinned to the target machine via fixedConnection, feature-detected so
older desktops keep the staged checklists.
- Config-record/hub-action stores accept scopes; cache keys fold in the
connection id so two gateways' same-named profiles never share rows.
Remote mode only offered Copy Path, which is a Linux server path and useless on the local machine. Reuse the existing gateway save bridge so a selected file can land on this computer.
Both entry points into the browser pane now ask for a reachable URL
first, so the dev server an agent names over a remote gateway actually
loads, and typing that address by hand behaves the same.
Every fallback keeps the original URL, which leaves the pane free to
explain an address it still cannot reach.
Only main can answer whether a loopback address is reachable: it holds
the live SSH connection and the profile precedence that picks it. The
renderer asks over IPC instead of guessing.
Forwards are scoped to the connection that authorized them and dropped
whenever it changes, so a new host never inherits a tunnel into the old
one. A failed forward logs and returns the original URL rather than
breaking the load path.
Opens a local->remote forward on demand so a gateway-side dev server URL
resolves to the gateway, not the laptop rendering the webview.
One lease per remote port, reused across pages and expired after 15
minutes; the port allowlist is deliberately absent, since the transport
we are already authenticated on is the security boundary and a curated
list just means the next framework default silently fails.
Lease and capability shape adapted from #87243.
Co-authored-by: tuancookiez-hub <tuancookiez-hub@users.noreply.github.com>
The scoped cmdk bar on Tools & Keys was a second search UI for the same
catalog ⌘K now serves. Cards stay listed; deep links still expand, scroll,
and flash the matching credential.
* feat(desktop): add scoped credential command search
* feat(desktop): add global settings search
* feat(desktop): settings search lives in the command palette, deep everywhere
Replace the PR's sidebar search input with a settings page inside the ⌘K
palette (the subPages mechanism themes/pets already use). ⌘K on the
Settings overlay opens the palette scoped to settings; Back/Esc/Backspace
step out to the root. The deep catalog — schema-driven config fields,
appearance controls, stored credentials — now also serves the ROOT
palette's typed results, replacing the static section-key list, so ⌘K is
the same settings search everywhere. The input refocuses on page swaps,
and a seed store carries the keystroke that opened the palette so
type-to-search never loses the first character.
* feat(desktop): search pill rides the Settings card's top edge and hands off to ⌘K
The trigger is a fake pill — pure chrome, not an input — centered on the
overlay card's top border, half off the card (a new OverlayView edgeBadge
slot; a sibling of the card since the card clips its own corners). Click
or just start typing to open the scoped palette; while the palette is up
the pill scales up and fades out, then fades back when it closes.
Type-to-search forwards printable keys (outside editable targets) with
the keystroke seeded into the palette filter.
* feat(desktop): plugins are searchable — ⌘K 'kanban' lands on Settings → Plugins with the row flashed
The catalog stopped at config fields and credentials; installed plugins
were invisible to search. Both stores now feed it — desktop plugins from
$pluginRecords, agent plugins via the same plugins.manage RPC the Plugins
page fires (deduped by the store's inflight guard, filtered by the shared
desktop-relevance curation, which moves to the store so both callers use
one predicate). Rows deep-link as ?tab=plugins&plugin=<id|key> and the
page scroll-flashes the row via the shared useDeepLinkHighlight hook.
* style(desktop): satisfy perfectionist import/prop ordering
* test(desktop): add setApiRequestProfile to KeysSettings hermes mock
---------
Co-authored-by: Adolanium <94890352+Adolanium@users.noreply.github.com>
Co-authored-by: Adolanium <adolanium@users.noreply.github.com>
The webview always runs on the user's machine, never on the gateway
host. Against a remote gateway an agent's http://localhost:5173 resolves
here — where that port is usually nothing, or somebody else's service —
and the pane just said 'Server not found'.
The load error now names the mismatch when the address is loopback and
the gateway is remote. It stays quiet on a local gateway and for
non-loopback hosts, which fail for ordinary reasons.
This explains the limitation rather than lifting it; forwarding the port
is a separate, larger job (see #87243 for the SSH case).
The Browser tab could only be summoned by asking the agent or clicking a
link. Adds Cmd+Shift+L and a command-palette row.
L is the location-bar chord every browser shares; plain Cmd+L is
already the terminal's selection shortcut, hence the shift. Pressing it
re-fronts whatever page is loaded rather than resetting it, so it reads
as 'show me the browser' and not 'start over'.
The application menu is only installed on macOS (#77845) — everywhere
else it is set to null, which strips the role accelerators with it. A
menu-only Cmd+R meant Windows and Linux got no page reload at all.
Moves the claim into the same before-input-event hook Cmd+W already uses
for exactly this reason, and drops the accelerator from the menu item so
one keypress can't fire both paths on macOS.
Clicking a link in the transcript, a url chip, or the terminal left
Hermes entirely. Now a web page opens in the browser pane — no context
switch to read a doc, and it's the surface the agent can see — while
Cmd/Ctrl-click and middle-click still escape to the real browser.
Routing lives in one helper so every link surface agrees. Anything that
isn't a web page (mailto:, file:, custom schemes) always hands off to
the OS, as do billing, OAuth, and local media, which need a real
logged-in browser.
The terminal keeps its own chord: it already spends Cmd/Ctrl on
activating the link at all, so Shift is what escapes there.