The install dialog gated the package path on the agent install succeeding in
the same click (`agentInstalled && desktopHalfFromPackage`). A package already
on disk answers "Plugin '<name>' already exists. Use force reinstall" without
Force, so the retry fell through to installDesktopPlugin and cloned
desktop-plugins/<git-name>/ beside the package copy the app had already
materialised as desktop-plugins/<manifest-name>/. Two folders, one plugin id:
the on-disk duplicate from #100412 (since e1a6679399 an error row instead of
a competing live instance, but still created by the install flow).
The package path now applies whenever the repo is a unified package, the
backend is local and the Agent box is ticked, whatever the agent install
returned. reconcileDesktopPlugins() is idempotent, so a retry touches
nothing; the desktop success toast is raised only when the agent half landed
or a copy was actually materialised, so a refused install shows the agent
error alone. A failed fresh install no longer leaves a standalone desktop
clone behind for the next successful install to duplicate.
Remote backends keep the separate clone (their plugins/ folder is not
readable from this machine); the Desktop-UI-only tick is unchanged.
Fixes#100412 (install-time half; runtime half landed in #118902).
Tests: two invariants in plugin-install-modal.test.tsx, the first red on
main (installDesktopPlugin called on the refused retry), the second guarding
the remote-backend clone.
(cherry picked from commit 9fbd3a9d8328ea452a4149252a35a0e6f43982e6)
runSsh dropped Node's close signal, so a null exit code looked like a
plain failure. Every close consumer now keeps that signal. An empty
stderr signal death is not an unreachable host.
- assert-dist-built now parses every emitted dist/assets/*.js chunk as an
ES module (node --input-type=module --check via spawnSync) before the
build reports success
- a silent bundler output loss (observed twice: a 10-byte identifier token
missing from the main chunk) previously shipped a syntactically invalid
bundle that white-screens Electron with Uncaught SyntaxError
- the loud failure feeds the existing update-path honest-retry and
stage-and-swap, so a corrupted build retries instead of shipping
- vitest coverage: invalid chunk fails with the chunk name in the error,
valid chunks (incl. import.meta / template literals) still pass
Fixes#105184
Chat emits file refs as `[label](#media:<encoded path>)`, but the
Artifacts collector classified the raw href and dropped it. Decode
those hrefs before looksLikeArtifact so Windows and POSIX paths,
image markdown, and explicit tool artifact keys are collected.
Legacy MEDIA: tags and plain URLs are unchanged.
Same decode as pull 88158; that patch did not apply cleanly.
Fixes#88084
Co-authored-by: SayHell0W0rld <18085695+SayHell0W0rld@users.noreply.github.com>
before-input-event fires for keyup as well as keydown. A Ctrl+W that
started in another app delivers its keyup when Windows focuses Hermes,
and the shortcut handlers treated that as Close Tab.
Refs #105498
The bare-word fallback still used \S+, so relative paths and unknown
extensions swallowed a trailing backtick. Stop that alternative before
backtick and double-quote, and keep the anchored extension branch.
A bearer 401 from the dashboard gate was returned to the client but
never written to dashboard-auth.log. Record session_rejected with the
client-facing reason, path, and IP, and never the bearer.
When that rejection is the app's saved bearer, the desktop mint error
says the app token is invalid instead of telling the user to
re-authenticate the server OAuth session.
Fixes#103117
An entered lane stopped at 5 chats behind a hover-only "…" that added 5
at a time, and an expanded overview project or entered Home mounted every
loaded row at once. Lanes, expanded projects and Home now page with a
labeled "Show N more in <lane>" row (50 per step), reusing the overview's
show-all row. The collapsed preview and tree request are unchanged.
Co-authored-by: cherrish333013 <139594807+cherrish333013@users.noreply.github.com>
Co-authored-by: George Shalhoub <george@georgeshalhoub.com>
A session row with an empty cwd but a persisted git_repo_root fell to Home:
_project_for_session bailed on the empty cwd before matching the repo root,
and _place_session refused to place it, so the backend owner map made Home
the chat's single owner while the renderer (root-aware) still labeled it with
its project. Match and place on cwd, else git_repo_root; a row is homeless
only when it has neither, matching the renderer's isDetachedSession.
Entered Home's live overlay also upserted every briefly-detached live row
without consulting the authoritative owner map, so a named-project chat whose
session.info landed with an empty cwd listed in Home as well. overlayHomeLane
now takes the owner map from overlayLiveLanes and skips rows (by id or
lineage root) owned by a named project.
Refs #77591 (symptom D, entered-project refetch lag, is not addressed).
Co-authored-by: networthexplained <300128320+networthexplained@users.noreply.github.com>
Both local `hermes serve` spawn sites spread process.env into the child.
Route them through profileBackendParentEnv so a pooled profile backend, and
a primary pinned to (or sticky on) a non-launch profile, only get the
credentials their own profile scope provides (#68367).
Co-authored-by: Yash Jain <23dec512@lnmiit.ac.in>
`hermes desktop` loads its launch profile's .env/.op.env into os.environ
before exec'ing Electron, so every Desktop-spawned backend inherited that
profile's credentials. A named profile with no TLON_* (or its own .op.env)
still came up with the default profile's values ahead of its own dotenv.
profileBackendParentEnv() returns the parent env for a `hermes serve`
child of a given profile: unchanged when the child is the launch profile,
otherwise minus every name the launch profile's .env/.op.env declares
(OS names like PATH/HOME excepted). Mirrors _profile_action_environment on
the dashboard side. Windows folds env names and home paths.
Co-authored-by: Yash Jain <23dec512@lnmiit.ac.in>
Rebased onto main, where params are validated against tui_gateway/contracts
and unknown keys are rejected: the copy_parent_history / omit_messages flags
on session.create and session.branch now answered 4000. Keep both methods'
wire shape unchanged and give the two new methods their own contracts
(session.branch_stored, session.branch_whole) with messages_omitted results;
the handlers take the behaviour as keyword arguments, not wire flags.
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).
- forward:true is macOS/Windows only; on Linux the spawn-time click-through
left the pet unclickable, so it stays a solid window there (same call as
the X11 HUD) and renderer ignore requests are vetoed.
- A stale overlay's 'closed' handler popped the pet back in over its
replacement; it now only acts while it is still the current window.
- Drop the re-home click-through re-assert: setBounds keeps the ignore
state, and forcing it desynced the renderer's hover dedupe.
Every exit 2 from `hermes update` was reported as "Hermes is still
running. Close all Hermes windows", including when the holder was another
live update. The child prints the specific refusal as a ✗ block right
before exiting; keep a bounded stdout tail and show that block, falling
back to the generic text only when none was captured.
Co-authored-by: RelaxJonh <92573950+RelaxJonh@users.noreply.github.com>
The recents empty state renders the existing SidebarLoadErrorState, the same one the project drill-in uses, when $sessionsLoadError is set. Retry re-runs refreshSessions through a new onRetrySessions sidebar action. Projects load separately, so the flag does not replace the project empty state.
Refs #67600
A first read that throws, or that reports a scan failure and returns no rows, now sets $sessionsLoadError. A later refresh recomputes it, and a gateway switch clears it. A failure over rows already on screen never sets it. Errors from a store latched corrupt are left out, since that store already has its own notice and a retry can't repair it.
Refs #67600
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.
Own module for host.skills/toolsets/profiles/pluginDecisions so sdk/index.ts
carries only the import and the host keys (the file is a god-facade already
and every SDK lane adds a hunk to it).
Drop `host.pluginDecisions.set()` from the salvaged shape. A plugin flipping
another plugin's enable state is exactly the "plugins messing with each
other" the SDK ruling forbids, and `host` is a module singleton that cannot
tell which plugin is calling to restrict `set` to the caller's own id.
Toggling stays in the app's Capabilities → Plugins tab. `pluginDecisions` is
now a nanostore ReadableAtom view (get/subscribe/listen) with NO `set` at
runtime — a hand-built delegate, because both a type cast and `computed()`
still carry `.set` on the object.
Tests: one invariant per behaviour (api-module routing with the ProfileScope
forwarded; read-only live view). Both red on the contributor commit
(`subscribe is not a function`) and on origin/main (no host.skills).
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).
useComposerModelPillLabel took the whole ctx object but listed only its
fields as useMemo deps, which the hooks lint flags (missing dependency
'ctx'). Destructure the primitives at the boundary and rebuild the ctx
inside the memo: same behaviour, and the memo now honestly depends on
exactly what providers can observe, so a plugin's label() runs only when
the registry or the pill inputs change, never per keystroke.
Fold the compact-mode guarantee into the existing provider test (floating
composer renders only the chevron; provider text must not leak) and assert
the core label is actually restored after a declining provider, so the
"first non-null wins, else core label" rule is pinned end to end.
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).
RAW_URL_RE admits `]` and `(`, and the only guard looked at the two
characters before each match. A URL in link text (`[https://x](https://x)`)
matched through `](https://x)` and was wrapped as one autolink, so the href
became `https://x%5D(https://x)` (#49822, #85234).
Split each prose segment on the markdown that owns its URLs (inline links
and images, full/collapsed references, definitions, and a label or target
still streaming in) and autolink only the gaps. The trailing peel now also
drops a stray `]` and the punctuation a peeled closer exposes, while paired
closers inside the URL (`/wiki/Foo_(bar)`, `http://[::1]/`) stay.
Co-authored-by: Evi Nova <66773372+Tranquil-Flow@users.noreply.github.com>
trimUnbalancedTrailingParens recounted every paren on each stripped
character (O(n·k)). Count opens/closes once, then peel trailing closers
off the end — behaviorally identical, since the loop only ever removes
trailing `)` so `opens` never changes. Add an intent comment documenting
the balance heuristic to match the file's style.
No behavior change: markdown-text.test.ts 23/23 green, typecheck + eslint clean.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
(cherry picked from commit 4c80b4e5d041c426ee26e6fa1715a380b4e42301)
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).
The logged-out notice only opened the portal in a browser, which writes no
credential, so Billing never left the logged-out state (#87792). Route it to
the shared sign-in dialog and drop the /portal hint; refusal notices keep
their portal link under a dedicated openPortal label.
Co-authored-by: George Jieh <99384826+georgejieh@users.noreply.github.com>
With "Show all profiles" on, each messaging platform section listed rows by
source only, so a Telegram thread gave no hint which profile it belonged to.
Rework the cherry-picked idea onto the current owner model:
- Extract buildGatewaySessionGroups() from useGatewaySessionGroups (now a thin
useMemo wrapper) so recents and messaging share one resolver keyed by
[connectionId, profile]. Drop the profile-only profile-groups.ts.
- Under profile grouping, group each platform's rows after the visible cap, so
the platform's load-more count and footer stay coherent. Render through
GatewayProfileGroups in a new embedded mode: flat owner groups, no nested
paging, no new-session button, header slot or profile-wide totals.
- scopeGatewaySessionGroups() namespaces group ids per platform so collapse,
alias and order preferences never collide with the recents groups, and keeps
the gateway in the label only when a platform mixes gateways.
- Under any other grouping, messaging rows carry profile tags like the pinned
and search sections.
Co-authored-by: webtecnica <75556242+webtecnica@users.noreply.github.com>
Messaging platform sections only grouped by profile while the whole
sidebar was in profile-grouped view; individual platforms ignored it,
so rows from different profiles were mixed within one platform section.
Extract buildProfileGroups() (shared helper: same keying/colors/order as
the recents profile groups, default floats first) and apply it per
platform section when showAllProfiles + grouping=profile. 5 vitest tests.
(cherry picked from commit 6941d7cc5f7c5251f068f97856e1a8c741e80d05)