diff --git a/apps/desktop/src/app/settings/plugin-install-modal.tsx b/apps/desktop/src/app/settings/plugin-install-modal.tsx index 40012f238c..d88734a0f2 100644 --- a/apps/desktop/src/app/settings/plugin-install-modal.tsx +++ b/apps/desktop/src/app/settings/plugin-install-modal.tsx @@ -145,7 +145,7 @@ export function PluginInstallModal() { void runProbe(request) }, [request, resetState, runProbe]) - const profileLabel = activeProfile || profileScope || 'default' + const profileLabel = request?.profile || activeProfile || profileScope || 'default' const agentTargetHint = connection?.mode === 'remote' ? m.agentTargetRemote(profileLabel) : m.agentTargetLocal(profileLabel) @@ -183,7 +183,9 @@ export function PluginInstallModal() { const result = await installAgentPlugin(requestGateway, { identifier: request.repo, force: forceReinstall, - enable: enableAgent + enable: enableAgent, + catalogName: request.catalogName, + profile: request.profile }) if (result.ok) { @@ -273,6 +275,11 @@ export function PluginInstallModal() {
{request.repo}
+ {request.catalogName && ( +

+ {m.catalogPinned(request.catalogName, request.sha?.slice(0, 8) ?? '')} +

+ )}
diff --git a/apps/desktop/src/app/settings/plugins-settings.tsx b/apps/desktop/src/app/settings/plugins-settings.tsx index 69598afe43..ace8f45b07 100644 --- a/apps/desktop/src/app/settings/plugins-settings.tsx +++ b/apps/desktop/src/app/settings/plugins-settings.tsx @@ -310,7 +310,21 @@ function AgentPluginsSection() { ) } -function PluginRow({ record }: { record: PluginRecord }) { +/** Folder name when a desktop plugin entry lives in the UNIFIED agent-plugins + * root (`~/.hermes/plugins//desktop/plugin.js`) — i.e. it is the + * desktop half of a bundled agent+desktop package. Null for standalone + * desktop plugins. */ +function unifiedPackageName(file?: string): null | string { + if (!file) { + return null + } + + const match = /[\\/]plugins[\\/]([^\\/]+)[\\/]desktop[\\/]plugin\.js$/.exec(file) + + return match ? match[1] : null +} + +function PluginRow({ record, agentHalfMissing }: { record: PluginRecord; agentHalfMissing?: boolean }) { const { t } = useI18n() const p = t.settings.plugins @@ -348,6 +362,13 @@ function PluginRow({ record }: { record: PluginRecord }) { {record.name} {p.kinds[record.kind]} {record.status === 'error' && {p.failed}} + {agentHalfMissing && ( + + + {p.agentHalfMissing} + + + )} } /> @@ -358,6 +379,13 @@ export function PluginsSettings() { const { t } = useI18n() const p = t.settings.plugins const records = useStore($pluginRecords) + // The agent-plugin list for the CURRENTLY scoped backend/profile — used to + // flag bundled packages whose desktop half is local but whose agent half is + // not installed where the app is now pointing (one desktop app, N agents: + // switching gateway/profile makes this drift visible instead of silent). + const agentRows = useStore($agentPlugins) + const agentStatus = useStore($agentPluginsStatus) + const agentNames = new Set(agentRows.flatMap(row => [row.name, row.key ?? row.name])) // Deep-link from settings search (?plugin=): rows render as soon // as their store hydrates, so "ready" is simply target-present; the polling @@ -400,9 +428,19 @@ export function PluginsSettings() { ) : (
- {rows.map(record => ( - - ))} + {rows.map(record => { + const packageName = unifiedPackageName(record.file) + + return ( + + ) + })}
)} diff --git a/apps/desktop/src/app/skills/index.tsx b/apps/desktop/src/app/skills/index.tsx index 464d84e928..50e88f28cb 100644 --- a/apps/desktop/src/app/skills/index.tsx +++ b/apps/desktop/src/app/skills/index.tsx @@ -63,12 +63,13 @@ import type { SetStatusbarItemGroup } from '../shell/statusbar-controls' import { EmbeddedHubPicker } from './embedded-hub-picker' import { McpTab } from './mcp-tab' +import { PluginsTab } from './plugins-tab' import { $skillsSortDesc, $toolsetsSortDesc } from './store' // 'hub' is gone as a top-level tab — the Skills Hub browser lives inside the // Skills tab now (EmbeddedHubPicker below the installed list). Legacy // `?tab=hub` links fall back to 'skills' via useRouteEnumParam. -const SKILLS_MODES = ['skills', 'toolsets', 'mcp'] as const +const SKILLS_MODES = ['skills', 'toolsets', 'mcp', 'plugins'] as const // Skills + toolsets live in the RQ cache so switching tabs/pages paints the // cached lists instantly (no reload flash) and mount only fires a deduped @@ -771,14 +772,15 @@ export function SkillsView({ onTabChange={id => setMode(id as (typeof SKILLS_MODES)[number])} // MCP manages a handful of entries with the editor right there — // searching it is noise. - searchHidden={mode === 'mcp'} + searchHidden={mode === 'mcp' || mode === 'plugins'} searchHints={searchHints} searchPlaceholder={mode === 'skills' ? t.skills.searchSkills : t.skills.searchToolsets} searchValue={query} tabs={[ { id: 'skills', label: t.skills.tabSkills, meta: skills?.length ?? null }, { id: 'toolsets', label: t.skills.tabToolsets, meta: toolsets ? visibleToolsetCount(toolsets) : null }, - { id: 'mcp', label: t.skills.tabMcp } + { id: 'mcp', label: t.skills.tabMcp }, + { id: 'plugins', label: t.skills.tabPlugins } ]} > {/* One shared column: the scope selector sits above whichever tab is @@ -788,7 +790,12 @@ export function SkillsView({ {profileScopeSelector}
- {mode === 'mcp' ? ( + {mode === 'plugins' ? ( + // Agent plugins for the scoped profile: installed list on top, + // the live catalog picker underneath (same shape as Skills). + // Keyed on scope so a profile/connection switch reloads the list. + + ) : mode === 'mcp' ? ( // The gateway instance backs ONLY the live `reload.mcp` RPC, and // it is the ACTIVE gateway's socket — for a scope pinned to a // different backend that RPC would hot-reload the wrong diff --git a/apps/desktop/src/app/skills/plugins-tab.test.tsx b/apps/desktop/src/app/skills/plugins-tab.test.tsx new file mode 100644 index 0000000000..04009dfe6b --- /dev/null +++ b/apps/desktop/src/app/skills/plugins-tab.test.tsx @@ -0,0 +1,136 @@ +import { cleanup, render, screen, waitFor } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { $agentPlugins, $agentPluginsStatus } from '@/store/agent-plugins' +import { $pluginInstallRequest, closePluginInstallRequest } from '@/store/plugin-install-request' + +import { PluginsTab } from './plugins-tab' + +const requestGateway = vi.fn(async () => ({ plugins: [] })) + +vi.mock('@/app/gateway/hooks/use-gateway-request', () => ({ + useGatewayRequest: () => ({ requestGateway }) +})) + +describe('PluginsTab', () => { + beforeEach(() => { + $agentPlugins.set([]) + $agentPluginsStatus.set('ready') + closePluginInstallRequest() + requestGateway.mockClear() + }) + + afterEach(cleanup) + + it('lists the scoped profile agent plugins with toggles', () => { + $agentPlugins.set([ + { + description: 'A test plugin', + key: 'demo-plugin', + name: 'demo-plugin', + source: 'git', + status: 'enabled', + version: '1.0.0' + } + ]) + + render() + + expect(screen.getByText('demo-plugin')).toBeTruthy() + expect(screen.getByRole('switch', { name: 'demo-plugin' }).getAttribute('aria-checked')).toBe('true') + }) + + it('hides bundled plugins (managed from their own surfaces)', () => { + $agentPlugins.set([ + { + description: '', + key: 'image_gen/fal', + name: 'fal', + source: 'bundled', + status: 'enabled', + version: '' + } + ]) + + render() + + expect(screen.queryByText('fal')).toBeNull() + expect(screen.getByText(/No agent plugins installed/)).toBeTruthy() + }) + + it('loads the plugin list scoped to the selected profile', () => { + render() + + expect(requestGateway).toHaveBeenCalledWith( + 'plugins.manage', + expect.objectContaining({ action: 'list', profile: 'workbot' }) + ) + }) + + it('opens the dual-target install modal from a catalog pick message', async () => { + render() + + window.dispatchEvent( + new MessageEvent('message', { + data: { + name: 'weather-plugin', + repo: 'https://github.com/example/weather-plugin', + sha: 'a'.repeat(40), + subdir: '', + tier: 'community', + type: 'hermes-plugin-pick' + }, + origin: 'https://hermes-agent.nousresearch.com' + }) + ) + + await waitFor(() => { + const request = $pluginInstallRequest.get() + + expect(request).not.toBeNull() + expect(request?.catalogName).toBe('weather-plugin') + expect(request?.repo).toBe('https://github.com/example/weather-plugin') + expect(request?.profile).toBe('workbot') + expect(request?.sha).toBe('a'.repeat(40)) + }) + }) + + it('ignores pick messages from foreign origins', () => { + render() + + window.dispatchEvent( + new MessageEvent('message', { + data: { + name: 'evil-plugin', + repo: 'https://github.com/evil/evil-plugin', + type: 'hermes-plugin-pick' + }, + origin: 'https://evil.example.com' + }) + ) + + expect($pluginInstallRequest.get()).toBeNull() + }) + + it('appends the subdir fragment for multi-plugin repos', async () => { + render() + + window.dispatchEvent( + new MessageEvent('message', { + data: { + name: 'nested-plugin', + repo: 'https://github.com/example/plugins-monorepo', + subdir: 'nested-plugin', + type: 'hermes-plugin-pick' + }, + origin: 'https://hermes-agent.nousresearch.com' + }) + ) + + await waitFor(() => { + expect($pluginInstallRequest.get()?.repo).toBe( + 'https://github.com/example/plugins-monorepo#nested-plugin' + ) + }) + }) +}) diff --git a/apps/desktop/src/app/skills/plugins-tab.tsx b/apps/desktop/src/app/skills/plugins-tab.tsx new file mode 100644 index 0000000000..94da93f140 --- /dev/null +++ b/apps/desktop/src/app/skills/plugins-tab.tsx @@ -0,0 +1,254 @@ +import { useStore } from '@nanostores/react' +import { memo, useEffect, useMemo, useState } from 'react' + +import { useGatewayRequest } from '@/app/gateway/hooks/use-gateway-request' +import { Button } from '@/components/ui/button' +import { Switch } from '@/components/ui/switch' +import { Tip } from '@/components/ui/tooltip' +import type { ProfileScope } from '@/hermes' +import { useI18n } from '@/i18n' +import { Loader2, Package } from '@/lib/icons' +import { cn } from '@/lib/utils' +import { + $agentPluginBusy, + $agentPlugins, + $agentPluginsError, + $agentPluginsStatus, + type AgentPluginRow, + isDesktopRelevantPlugin, + loadAgentPlugins, + toggleAgentPlugin +} from '@/store/agent-plugins' +import { $paneHeightOverride, setPaneHeightOverride } from '@/store/panes' +import { openPluginInstallRequest } from '@/store/plugin-install-request' + +import { PanelEmpty } from '../overlays/panel' + +// The REAL Plugin Catalog page (docs site) embedded as a one-click picker — +// the same pattern as the Skills tab's EmbeddedHubPicker. `?embed=picker` +// hides the docs chrome and adds "+ Add to this Agent" per card, which posts +// { type: 'hermes-plugin-pick', name, repo, sha, subdir, tier, installCmd } +// to the parent window. We validate the origin and open the shared +// dual-target install modal (agent half → catalog-pinned install into the +// scoped profile; desktop half → local app), so bundled agent+desktop +// packages install both halves in one flow. +const CATALOG_ORIGIN = 'https://hermes-agent.nousresearch.com' +const CATALOG_PICKER_URL = `${CATALOG_ORIGIN}/docs/plugins?embed=picker` + +const CATALOG_PANE_ID = 'capabilities-plugin-catalog' +const CATALOG_DEFAULT_PX = 380 +const CATALOG_COLLAPSED_PX = 4 + +interface PluginPickMessage { + installCmd?: string + name?: string + repo?: string + sha?: string + subdir?: string + tier?: string + type?: string +} + +/** Derive the bare profile name a `plugins.manage` call should target. */ +function profileParam(scope: ProfileScope): null | string { + if (!scope) { + return null + } + + return typeof scope === 'string' ? scope : (scope.profile ?? null) +} + +function PluginRow({ + row, + busy, + onToggle +}: { + row: AgentPluginRow + busy: boolean + onToggle: (enable: boolean) => void +}) { + const { t } = useI18n() + const address = row.key ?? '' + const canToggle = Boolean(address) + const enabled = row.status === 'enabled' + + return ( +
+ +
+
+ {row.name} + {row.version && v{row.version}} + {row.portable && ( + + {t.skills.plugins.portableBadge} + + )} +
+ {row.description && ( +
+ {row.description} +
+ )} +
+
+ {busy && } + {canToggle ? ( + + ) : ( + + + + + + )} +
+
+ ) +} + +/** Agent plugins for the Capabilities page: the scoped profile's installed + * plugins on top (toggleable), the live catalog picker underneath — same + * management-plus-discovery shape as the Skills tab. */ +export const PluginsTab = memo(function PluginsTab({ profile }: { profile: ProfileScope }) { + const { t } = useI18n() + const p = t.skills.plugins + const { requestGateway } = useGatewayRequest() + + const rows = useStore($agentPlugins) + const status = useStore($agentPluginsStatus) + const error = useStore($agentPluginsError) + const busyKey = useStore($agentPluginBusy) + + const scope = profileParam(profile) + + useEffect(() => { + void loadAgentPlugins(requestGateway, scope) + }, [requestGateway, scope]) + + const visible = useMemo(() => rows.filter(isDesktopRelevantPlugin), [rows]) + + // Catalog picker viewport (persisted height, collapse toggle) — same pane + // store contract as EmbeddedHubPicker. + const heightOverride = useStore($paneHeightOverride(CATALOG_PANE_ID)) + const height = heightOverride ?? CATALOG_DEFAULT_PX + const open = height > CATALOG_COLLAPSED_PX + const [pickerMounted, setPickerMounted] = useState(open) + + if (open && !pickerMounted) { + setPickerMounted(true) + } + + useEffect(() => { + if (!open) { + return undefined + } + + const onMessage = (event: MessageEvent) => { + if (event.origin !== CATALOG_ORIGIN) { + return + } + + const data = event.data as null | PluginPickMessage + + if (!data || data.type !== 'hermes-plugin-pick' || !data.name || !data.repo) { + return + } + + // Open the shared dual-target install modal: it probes the repo for + // agent/desktop halves, installs the agent half at the catalog pin + // into the scoped profile, and offers the desktop half locally. + openPluginInstallRequest({ + catalogName: String(data.name), + profile: scope, + repo: data.subdir ? `${String(data.repo)}#${String(data.subdir)}` : String(data.repo), + sha: data.sha ? String(data.sha) : undefined + }) + } + + window.addEventListener('message', onMessage) + + return () => window.removeEventListener('message', onMessage) + }, [open, scope]) + + return ( +
+
+ {status === 'error' ? ( + void loadAgentPlugins(requestGateway, scope)} size="sm"> + {t.skills.refresh} + + } + description={error ?? undefined} + icon="error" + title={p.loadFailed} + /> + ) : visible.length === 0 && status === 'ready' ? ( + + ) : ( +
+ {visible.map(row => ( + { + if (!row.key) { + return + } + + void toggleAgentPlugin(requestGateway, row.key, enable, p.toggleFailed(row.name), scope) + }} + row={row} + /> + ))} +
+ )} +
+ +
+
+ {p.catalogTitle} + +
+ {pickerMounted && ( +
+
+