From 76ff06074817cd4359160443b6ffa3f7e8a3a0b7 Mon Sep 17 00:00:00 2001
From: Teknium <127238744+teknium1@users.noreply.github.com>
Date: Mon, 17 Aug 2026 16:01:39 -0700
Subject: [PATCH] =?UTF-8?q?fix(desktop):=20clean=20up=20Plugins=20settings?=
=?UTF-8?q?=20=E2=80=94=20drop=20Example=20Plugin,=20hide=20bundled=20buil?=
=?UTF-8?q?t-ins,=20add=20profile=20scoping?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- Remove the bundled Example Plugin from the desktop renderer plugins
(apps/desktop/src/plugins/example/). Reference/demo plugins live in the
companion hermes-example-plugins repo (already pointed to by
src/plugins/README.md); shipping the counter demo in everyone's Settings
doubled UI noise for no user value.
- Agent plugins section now hides ALL repo-bundled built-ins
(source === 'bundled': browser/browserbase, cron_providers/chronos,
model-providers/deepinfra, platform adapters, image/video backends, …).
The section is the control panel for plugins the user installed
(user/git/project/pip/portable); built-ins ship enabled-by-default and
are configured from their own surfaces. The HIDDEN_KEY_PREFIXES list
stays as a fallback for older backends. Count pill reflects the
filtered list.
- Add an "Applies to" profile scope selector to the Agent plugins section
(same pattern as the Capabilities scope selector): list/toggle any
profile's plugins without switching the whole app. Backend:
plugins.manage now accepts an optional `profile` param via the same
set_hermes_home_override contract as cron.manage / mcp.servers.*;
unscoped calls are unchanged, so older backends keep working.
- i18n: new settings.plugins.agent.appliesTo key (types + en + zh; other
locales fall back through defineLocale).
- Tests: plugins-settings.test.tsx covers bundled hiding, prefix fallback,
count pill, selector visibility, scoped list/toggle payloads; new
tests/test_plugins_manage_profile_scope.py mirrors the cron.manage
profile-scope tests (scoped read, unknown-profile 4064, no override
leak, unscoped contract unchanged).
---
.../app/settings/plugins-settings.test.tsx | 137 +++++++++++++++++-
.../src/app/settings/plugins-settings.tsx | 87 +++++++++--
apps/desktop/src/i18n/en.ts | 3 +-
apps/desktop/src/i18n/types.ts | 1 +
apps/desktop/src/i18n/zh.ts | 3 +-
apps/desktop/src/plugins/example/plugin.tsx | 126 ----------------
apps/desktop/src/store/agent-plugins.ts | 65 +++++++--
tests/test_plugins_manage_profile_scope.py | 74 ++++++++++
tui_gateway/methods_tools.py | 9 ++
9 files changed, 346 insertions(+), 159 deletions(-)
delete mode 100644 apps/desktop/src/plugins/example/plugin.tsx
create mode 100644 tests/test_plugins_manage_profile_scope.py
diff --git a/apps/desktop/src/app/settings/plugins-settings.test.tsx b/apps/desktop/src/app/settings/plugins-settings.test.tsx
index d4d1651e01..917e7e093a 100644
--- a/apps/desktop/src/app/settings/plugins-settings.test.tsx
+++ b/apps/desktop/src/app/settings/plugins-settings.test.tsx
@@ -1,13 +1,25 @@
+import { QueryClientProvider } from '@tanstack/react-query'
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
-const { requestGateway } = vi.hoisted(() => ({ requestGateway: vi.fn() }))
+const { requestGateway, getProfiles } = vi.hoisted(() => ({
+ requestGateway: vi.fn(),
+ getProfiles: vi.fn<() => Promise<{ profiles: { name: string; is_default: boolean }[] }>>(async () => ({
+ profiles: []
+ }))
+}))
vi.mock('@/app/gateway/hooks/use-gateway-request', () => ({
useGatewayRequest: () => ({ requestGateway })
}))
+vi.mock('@/hermes', async importOriginal => ({
+ ...(await importOriginal>()),
+ getProfiles
+}))
+
import { $pluginRecords } from '@/contrib/plugins-store'
+import { queryClient } from '@/lib/query-client'
import {
$agentPluginBusy,
$agentPlugins,
@@ -15,6 +27,7 @@ import {
$agentPluginsStatus,
type AgentPluginRow
} from '@/store/agent-plugins'
+import { $activeGatewayProfile } from '@/store/profile'
import { $connection, $gatewayState } from '@/store/session'
import { PluginsSettings } from './plugins-settings'
@@ -27,8 +40,18 @@ const legacyRow = {
status: 'disabled'
} satisfies AgentPluginRow
+const renderSettings = () =>
+ render(
+
+
+
+ )
+
beforeEach(() => {
requestGateway.mockReset()
+ getProfiles.mockReset()
+ getProfiles.mockResolvedValue({ profiles: [] })
+ queryClient.clear()
$pluginRecords.set({})
$agentPlugins.set([legacyRow])
$agentPluginsStatus.set('ready')
@@ -36,6 +59,7 @@ beforeEach(() => {
$agentPluginBusy.set(null)
$gatewayState.set('idle')
$connection.set(null)
+ $activeGatewayProfile.set('default')
})
afterEach(() => {
@@ -45,7 +69,7 @@ afterEach(() => {
describe('PluginsSettings', () => {
it('renders and searches plugin rows returned without a canonical key', () => {
- render()
+ renderSettings()
expect(screen.getByText('Legacy plugin')).toBeTruthy()
@@ -58,7 +82,7 @@ describe('PluginsSettings', () => {
// Name-addressed toggles flip every same-named plugin across category
// dirs (image_gen/fal vs video_gen/fal) — the reason toggles moved to
// canonical keys. A pre-contract-v6 row must never reach the RPC.
- render()
+ renderSettings()
const toggle = screen.getByRole('switch', { name: 'Enable Legacy plugin' })
@@ -79,7 +103,7 @@ describe('PluginsSettings', () => {
$agentPlugins.set([legacyRow, sibling])
- render()
+ renderSettings()
expect(screen.getAllByRole('switch', { name: 'Enable Legacy plugin' })).toHaveLength(2)
expect(screen.getByText(sibling.description)).toBeTruthy()
@@ -92,7 +116,7 @@ describe('PluginsSettings', () => {
$agentPlugins.set([keyedRow])
requestGateway.mockResolvedValue({ ok: true, plugin: { ...keyedRow, status: 'enabled' } })
- render()
+ renderSettings()
fireEvent.click(screen.getByRole('switch', { name: 'Enable Legacy plugin' }))
await waitFor(() =>
@@ -103,4 +127,107 @@ describe('PluginsSettings', () => {
})
)
})
+
+ it('hides repo-bundled built-ins and keeps the count pill in sync', () => {
+ // The Agent plugins section is the control panel for plugins the USER
+ // installed — built-ins (browser backends, cron providers, model
+ // providers…) ship enabled-by-default and are configured elsewhere.
+ $agentPlugins.set([
+ legacyRow,
+ { ...legacyRow, name: 'browserbase', key: 'browser/browserbase', source: 'bundled' },
+ { ...legacyRow, name: 'chronos', key: 'cron_providers/chronos', source: 'bundled' },
+ { ...legacyRow, name: 'deepinfra', key: 'model-providers/deepinfra', source: 'bundled' }
+ ])
+
+ renderSettings()
+
+ expect(screen.getByText('Legacy plugin')).toBeTruthy()
+ expect(screen.queryByText('browserbase')).toBeNull()
+ expect(screen.queryByText('chronos')).toBeNull()
+ expect(screen.queryByText('deepinfra')).toBeNull()
+ // Count pill reflects the filtered list, not the raw RPC row count.
+ expect(screen.getByText('1 installed', { exact: false })).toBeTruthy()
+ })
+
+ it('hides legacy other-surface categories even when the backend omits source', () => {
+ // Older backends may not report source reliably — the key-prefix
+ // fallback still hides categories other surfaces own.
+ $agentPlugins.set([{ ...legacyRow, name: 'deepinfra', key: 'model-providers/deepinfra', source: 'user' }])
+
+ renderSettings()
+
+ expect(screen.queryByText('deepinfra')).toBeNull()
+ })
+
+ it('shows no profile selector with a single profile', async () => {
+ getProfiles.mockResolvedValue({ profiles: [{ name: 'default', is_default: true }] })
+
+ renderSettings()
+
+ await waitFor(() => expect(getProfiles).toHaveBeenCalled())
+ expect(screen.queryByText('Applies to:')).toBeNull()
+ })
+
+ it('lists the active profile scope without a profile param and reloads scoped on change', async () => {
+ getProfiles.mockResolvedValue({
+ profiles: [
+ { name: 'default', is_default: true },
+ { name: 'work', is_default: false }
+ ]
+ })
+ requestGateway.mockResolvedValue({ plugins: [legacyRow] })
+ $gatewayState.set('open')
+
+ renderSettings()
+
+ // Active profile scope: no profile param — older backends unchanged.
+ await waitFor(() => expect(requestGateway).toHaveBeenCalledWith('plugins.manage', { action: 'list' }))
+ await waitFor(() => expect(screen.getByText('Applies to:')).toBeTruthy())
+ })
+
+ it('sends toggles through the selected profile scope', async () => {
+ // jsdom's scrollIntoView is missing/non-functional; Radix Select calls it
+ // when the dropdown opens.
+ Element.prototype.scrollIntoView = vi.fn()
+
+ const keyedRow = { ...legacyRow, key: 'image_gen/legacy' }
+
+ getProfiles.mockResolvedValue({
+ profiles: [
+ { name: 'default', is_default: true },
+ { name: 'work', is_default: false }
+ ]
+ })
+ requestGateway.mockImplementation(async (method: string, params?: Record) => {
+ if (params?.action === 'list') {
+ return { plugins: [keyedRow] }
+ }
+
+ return { ok: true, plugin: { ...keyedRow, status: 'enabled' } }
+ })
+ $gatewayState.set('open')
+
+ renderSettings()
+
+ await waitFor(() => expect(screen.getByText('Applies to:')).toBeTruthy())
+
+ // Select the non-active profile scope.
+ fireEvent.click(screen.getByRole('combobox'))
+ fireEvent.click(await screen.findByText('work'))
+
+ await waitFor(() =>
+ expect(requestGateway).toHaveBeenCalledWith('plugins.manage', { action: 'list', profile: 'work' })
+ )
+
+ fireEvent.click(screen.getByRole('switch', { name: 'Enable Legacy plugin' }))
+
+ await waitFor(() =>
+ expect(requestGateway).toHaveBeenCalledWith('plugins.manage', {
+ action: 'toggle',
+ key: 'image_gen/legacy',
+ enable: true,
+ profile: 'work'
+ })
+ )
+ })
})
diff --git a/apps/desktop/src/app/settings/plugins-settings.tsx b/apps/desktop/src/app/settings/plugins-settings.tsx
index ca31ff3838..1c379c7fbc 100644
--- a/apps/desktop/src/app/settings/plugins-settings.tsx
+++ b/apps/desktop/src/app/settings/plugins-settings.tsx
@@ -1,13 +1,16 @@
import { useStore } from '@nanostores/react'
+import { useQuery } from '@tanstack/react-query'
import { type ReactNode, useEffect, useState } from 'react'
import { useGatewayRequest } from '@/app/gateway/hooks/use-gateway-request'
import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Switch } from '@/components/ui/switch'
import { Tip } from '@/components/ui/tooltip'
import { $pluginRecords, type PluginRecord, setPluginEnabled } from '@/contrib/plugins-store'
import { discoverRuntimePlugins } from '@/contrib/runtime-loader'
+import { getProfiles } from '@/hermes'
import { useI18n } from '@/i18n'
import { triggerHaptic } from '@/lib/haptics'
import { FolderOpen, Monitor, Package, RefreshCw } from '@/lib/icons'
@@ -23,23 +26,32 @@ import {
toggleAgentPlugin
} from '@/store/agent-plugins'
import { notifyError } from '@/store/notifications'
+import { $activeGatewayProfile } from '@/store/profile'
import { $connection, $gatewayState } from '@/store/session'
import { EmptyState, ListRowSkeleton, Pill, SettingsContent, SettingsSection } from './primitives'
const KIND_ORDER: Record = { disk: 0, runtime: 1, bundled: 2 }
-// User-installed plugins first, bundled last — mirrors `hermes plugins list`.
-const SOURCE_ORDER: Record = { user: 0, git: 0, project: 1, entrypoint: 2, bundled: 3 }
+// User-installed plugins first — mirrors `hermes plugins list --user`.
+const SOURCE_ORDER: Record = { user: 0, git: 0, project: 1, entrypoint: 2 }
-// Plugin categories (by registry key prefix) that other surfaces own — same
-// curation stance as desktop-slash-commands.ts. dashboard_auth/* only matters
-// to `hermes dashboard`; model-providers/* are configured in Settings →
-// Models; platforms/* are managed from Messaging. The plugin switch is not
-// the user-facing control for any of them, so listing them here is noise.
+// Fallback for older backends whose rows predate a reliable `source` field:
+// plugin categories (by registry key prefix) that only exist bundled in the
+// repo — same curation stance as desktop-slash-commands.ts.
const HIDDEN_KEY_PREFIXES = ['dashboard_auth/', 'model-providers/', 'platforms/']
+// This section is the control panel for plugins the USER installed
+// (~/.hermes/plugins, git, project, pip entry points, portable packages).
+// Repo-bundled built-ins (browser backends, cron providers, model providers,
+// platform adapters, image/video backends…) ship enabled-by-default and are
+// configured from their own surfaces (Settings → Models, Messaging, `hermes
+// tools`…), so listing dozens of them here is pure noise — hide them.
const isDesktopRelevant = (row: AgentPluginRow) => {
+ if (row.source === 'bundled') {
+ return false
+ }
+
const key = row.key
return !key || !HIDDEN_KEY_PREFIXES.some(prefix => key.startsWith(prefix))
@@ -129,7 +141,7 @@ function PluginLine({
)
}
-function AgentPluginRowView({ row }: { row: AgentPluginRow }) {
+function AgentPluginRowView({ row, profile }: { row: AgentPluginRow; profile: string | null }) {
const { t } = useI18n()
const p = t.settings.plugins
const { requestGateway } = useGatewayRequest()
@@ -151,7 +163,7 @@ function AgentPluginRowView({ row }: { row: AgentPluginRow }) {
}
triggerHaptic('selection')
- void toggleAgentPlugin(requestGateway, key, on, p.agent.toggleFailed(row.name))
+ void toggleAgentPlugin(requestGateway, key, on, p.agent.toggleFailed(row.name), profile)
}}
/>
)
@@ -182,13 +194,39 @@ function AgentPluginsSection() {
const error = useStore($agentPluginsError)
const [query, setQuery] = useState('')
+ // 'Applies to' profile scope: which profile's plugins we list/toggle.
+ // Defaults to the app-wide active profile; overriding it here lets the user
+ // manage ANY profile's plugins without switching the whole app (same
+ // pattern as the Capabilities scope selector in app/skills). null = the
+ // active profile — the RPC is sent without a profile param so older
+ // backends keep working unchanged.
+ const activeProfile = useStore($activeGatewayProfile)
+ const [scopeOverride, setScopeOverride] = useState(null)
+ const scopeProfile = scopeOverride ?? activeProfile ?? null
+ // The param we actually send: omit it for the active profile.
+ const requestProfile = scopeOverride && scopeOverride !== activeProfile ? scopeOverride : null
+
+ const { data: profilesData } = useQuery({
+ queryKey: ['agent-plugins-profiles'],
+ queryFn: getProfiles,
+ staleTime: 60_000
+ })
+
+ const profiles = profilesData?.profiles ?? []
+
+ // An app-wide profile switch retargets the default scope — drop the
+ // override so the list reloads for the profile the user just switched to.
+ useEffect(() => {
+ setScopeOverride(null)
+ }, [activeProfile])
+
useEffect(() => {
if (gatewayState !== 'open') {
return
}
- void loadAgentPlugins(requestGateway)
- }, [gatewayState, requestGateway])
+ void loadAgentPlugins(requestGateway, requestProfile)
+ }, [gatewayState, requestGateway, requestProfile])
const needle = normalize(query)
@@ -213,7 +251,30 @@ function AgentPluginsSection() {
{p.agent.blurb}
- {connection?.mode !== 'remote' && (
+ {profiles.length > 1 && (
+
+
+ {p.agent.appliesTo}
+
+
+
+ )}
+
+ {connection?.mode !== 'remote' && !requestProfile && (