feat(desktop): one row per plugin; desktop halves are app-level copies, never profile-scoped

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.
This commit is contained in:
Teknium
2026-09-10 06:17:32 -07:00
parent ff93f0ee9b
commit 248ff2d3e8
28 changed files with 1275 additions and 770 deletions
@@ -4,7 +4,11 @@ import path from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import { migrateProfileScopedDesktopPlugins } from './desktop-plugins-root'
import {
migrateProfileScopedDesktopPlugins,
PACKAGE_MARKER,
reconcileUnifiedDesktopHalves
} from './desktop-plugins-root'
const homes: string[] = []
@@ -15,6 +19,11 @@ function makeHome(): string {
return home
}
function write(file: string, text: string) {
fs.mkdirSync(path.dirname(file), { recursive: true })
fs.writeFileSync(file, text)
}
afterEach(() => {
for (const home of homes.splice(0)) {
fs.rmSync(home, { force: true, recursive: true })
@@ -25,9 +34,7 @@ describe('migrateProfileScopedDesktopPlugins', () => {
it('lifts per-profile desktop plugins into the app root so they survive a profile switch', async () => {
const home = makeHome()
const appRoot = path.join(home, 'desktop-plugins')
const scoped = path.join(home, 'profiles', 'workbot', 'desktop-plugins', 'hello')
fs.mkdirSync(scoped, { recursive: true })
fs.writeFileSync(path.join(scoped, 'plugin.js'), 'export default { id: "hello" }')
write(path.join(home, 'profiles', 'workbot', 'desktop-plugins', 'hello', 'plugin.js'), 'export default {}')
fs.mkdirSync(appRoot, { recursive: true })
const moved = await migrateProfileScopedDesktopPlugins(home, appRoot)
@@ -40,16 +47,86 @@ describe('migrateProfileScopedDesktopPlugins', () => {
it('never overwrites a plugin already installed at the app root', async () => {
const home = makeHome()
const appRoot = path.join(home, 'desktop-plugins')
fs.mkdirSync(path.join(appRoot, 'hello'), { recursive: true })
fs.writeFileSync(path.join(appRoot, 'hello', 'plugin.js'), 'root copy')
const scoped = path.join(home, 'profiles', 'workbot', 'desktop-plugins', 'hello')
fs.mkdirSync(scoped, { recursive: true })
fs.writeFileSync(path.join(scoped, 'plugin.js'), 'profile copy')
write(path.join(appRoot, 'hello', 'plugin.js'), 'root copy')
const scoped = path.join(home, 'profiles', 'workbot', 'desktop-plugins', 'hello', 'plugin.js')
write(scoped, 'profile copy')
const moved = await migrateProfileScopedDesktopPlugins(home, appRoot)
expect(moved).toEqual([])
expect(await migrateProfileScopedDesktopPlugins(home, appRoot)).toEqual([])
expect(fs.readFileSync(path.join(appRoot, 'hello', 'plugin.js'), 'utf8')).toBe('root copy')
expect(fs.existsSync(path.join(scoped, 'plugin.js'))).toBe(true)
expect(fs.existsSync(scoped)).toBe(true)
})
})
describe('reconcileUnifiedDesktopHalves', () => {
it('copies each unified package desktop half into the app root ONCE across all profiles, stamping the package marker', async () => {
const home = makeHome()
const appRoot = path.join(home, 'desktop-plugins')
write(path.join(home, 'plugins', 'media', 'plugin.yaml'), 'name: media')
write(path.join(home, 'plugins', 'media', 'desktop', 'plugin.js'), 'default home copy')
// Same package installed in a second profile — must not produce a second row.
write(path.join(home, 'profiles', 'workbot', 'plugins', 'media', 'desktop', 'plugin.js'), 'profile copy')
// Agent-only package: nothing to copy.
write(path.join(home, 'profiles', 'workbot', 'plugins', 'snap', 'plugin.yaml'), 'name: snap')
const touched = await reconcileUnifiedDesktopHalves(home, appRoot)
expect(touched).toEqual([path.join(appRoot, 'media')])
expect(fs.readFileSync(path.join(appRoot, 'media', 'plugin.js'), 'utf8')).toBe('default home copy')
const marker = JSON.parse(fs.readFileSync(path.join(appRoot, 'media', PACKAGE_MARKER), 'utf8'))
expect(marker.package).toBe('media')
expect(marker.repo).toBeUndefined()
expect(fs.existsSync(path.join(appRoot, 'snap'))).toBe(false)
// Idempotent: a second pass with nothing changed touches nothing.
expect(await reconcileUnifiedDesktopHalves(home, appRoot)).toEqual([])
})
it('re-copies when the source half changes, and removes the copy when the package is uninstalled', async () => {
const home = makeHome()
const appRoot = path.join(home, 'desktop-plugins')
const source = path.join(home, 'plugins', 'media', 'desktop', 'plugin.js')
write(source, 'v1')
await reconcileUnifiedDesktopHalves(home, appRoot)
// Update: bump mtime forward and change content.
write(source, 'v2')
const future = new Date(Date.now() + 60_000)
fs.utimesSync(source, future, future)
expect(await reconcileUnifiedDesktopHalves(home, appRoot)).toEqual([path.join(appRoot, 'media')])
expect(fs.readFileSync(path.join(appRoot, 'media', 'plugin.js'), 'utf8')).toBe('v2')
// Uninstall: the package folder goes away → so does the app-root copy.
fs.rmSync(path.join(home, 'plugins', 'media'), { force: true, recursive: true })
expect(await reconcileUnifiedDesktopHalves(home, appRoot)).toEqual([path.join(appRoot, 'media')])
expect(fs.existsSync(path.join(appRoot, 'media'))).toBe(false)
})
it('stamps the package origin (catalog sidecar, else git remote) so "Install here" can reinstall the agent half', async () => {
const home = makeHome()
const appRoot = path.join(home, 'desktop-plugins')
write(path.join(home, 'plugins', 'cat', 'desktop', 'plugin.js'), 'x')
write(
path.join(home, 'plugins', 'cat', '.hermes-catalog.json'),
JSON.stringify({ catalog_name: 'cat', repo: 'https://github.com/o/cat.git', sha: 'deadbeef' })
)
write(path.join(home, 'plugins', 'raw', 'desktop', 'plugin.js'), 'y')
write(path.join(home, 'plugins', 'raw', '.git', 'config'), '[core]\n\tbare = false\n[remote "origin"]\n\turl = file:///srv/raw.git\n\tfetch = +refs/heads/*:refs/remotes/origin/*\n')
await reconcileUnifiedDesktopHalves(home, appRoot)
const cat = JSON.parse(fs.readFileSync(path.join(appRoot, 'cat', PACKAGE_MARKER), 'utf8'))
expect(cat).toMatchObject({ catalogName: 'cat', repo: 'https://github.com/o/cat.git', sha: 'deadbeef' })
const raw = JSON.parse(fs.readFileSync(path.join(appRoot, 'raw', PACKAGE_MARKER), 'utf8'))
expect(raw.repo).toBe('file:///srv/raw.git')
})
it('never overwrites a standalone plugin the user installed under the same name', async () => {
const home = makeHome()
const appRoot = path.join(home, 'desktop-plugins')
write(path.join(appRoot, 'media', 'plugin.js'), 'user standalone')
write(path.join(home, 'plugins', 'media', 'desktop', 'plugin.js'), 'package half')
expect(await reconcileUnifiedDesktopHalves(home, appRoot)).toEqual([])
expect(fs.readFileSync(path.join(appRoot, 'media', 'plugin.js'), 'utf8')).toBe('user standalone')
})
})
+192 -36
View File
@@ -1,15 +1,71 @@
// The standalone desktop-plugin root (`<HERMES_HOME>/desktop-plugins`) and its
// one-time migration out of the per-profile folders earlier builds used.
// The standalone desktop-plugin root (`<HERMES_HOME>/desktop-plugins`) and the
// one-time migrations that make it the ONLY place desktop code loads from.
//
// A desktop plugin extends THIS APP — panes, palette commands, themes — not an
// agent. Profiles are agents; a plugin that appeared and disappeared with the
// active profile read as "my plugin vanished" every time the user switched.
// Bundled plugins never had that problem (they ship in the app), which hid
// the bug for disk installs.
// agent. Profiles are agents; anything discovered through a profile's folder
// appeared and disappeared with the active profile, which read as "my plugin
// vanished" every time the user switched. Bundled plugins never had that
// problem (they ship in the app), which hid the bug for disk installs.
//
// Two profile-shaped sources are lifted into the app root:
// 1. `profiles/<name>/desktop-plugins/<id>` — earlier builds scoped the
// standalone root per profile.
// 2. `plugins/<name>/desktop/plugin.js` (default home AND every profile) —
// the desktop half of a unified agent+desktop package. The agent half
// stays where it is (it runs in that profile's gateway); the desktop half
// is COPIED out as `<root>/<name>/` with a `.hermes-package.json` marker
// so the UI can pair it back to the agent row and re-copy on update.
import fs from 'node:fs'
import path from 'node:path'
export const DESKTOP_PLUGINS_DIR = 'desktop-plugins'
/** Marker inside a materialized desktop half: which agent package it came from. */
export const PACKAGE_MARKER = '.hermes-package.json'
export interface DesktopHalfMarker {
/** Agent package folder name (the `plugins/<name>` key). */
package: string
/** Where the half was copied from — refreshed whenever that source changes. */
source: string
/** mtimeMs of the source `plugin.js` at copy time; a newer source re-copies. */
sourceMtimeMs: number
/** Where the PACKAGE came from, so "Install here" can install its agent half
* into another profile: the catalog sidecar's repo/sha, else the git remote. */
repo?: string
sha?: string
catalogName?: string
}
/** Provenance of an installed agent package: catalog sidecar first, then the
* git remote. Undefined for a folder that was copied in by hand. */
async function packageOrigin(packageDir: string): Promise<Pick<DesktopHalfMarker, 'catalogName' | 'repo' | 'sha'>> {
try {
const sidecar = JSON.parse(await fs.promises.readFile(path.join(packageDir, '.hermes-catalog.json'), 'utf8')) as {
catalog_name?: string
repo?: string
sha?: string
}
if (sidecar.repo) {
return { catalogName: sidecar.catalog_name, repo: sidecar.repo, sha: sidecar.sha }
}
} catch {
// No sidecar — not a catalog install.
}
try {
const config = await fs.promises.readFile(path.join(packageDir, '.git', 'config'), 'utf8')
const match = /\[remote "origin"\][^[]*?url\s*=\s*(\S+)/.exec(config)
if (match) {
return { repo: match[1] }
}
} catch {
// Not a git checkout.
}
return {}
}
export async function ensureDir(dir: string): Promise<string> {
try {
@@ -22,45 +78,37 @@ export async function ensureDir(dir: string): Promise<string> {
return dir
}
async function listDirs(dir: string): Promise<string[]> {
try {
const entries = await fs.promises.readdir(dir, { withFileTypes: true })
return entries.filter(entry => entry.isDirectory()).map(entry => entry.name)
} catch {
return []
}
}
/** Every hermes home the app knows about locally: the default plus each profile. */
export async function localHomes(hermesHome: string): Promise<string[]> {
const profiles = await listDirs(path.join(hermesHome, 'profiles'))
return [hermesHome, ...profiles.map(name => path.join(hermesHome, 'profiles', name))]
}
/** Move every `profiles/<name>/desktop-plugins/<id>` folder into the app-level
* root. A plugin already present at the root wins (folders are keyed by plugin
* id, so a duplicate is the same plugin installed twice); the profile copy is
* left in place for the user to delete rather than destroyed. Emptied profile
* roots are removed so the migration is a no-op on the next launch. */
export async function migrateProfileScopedDesktopPlugins(hermesHome: string, appRoot: string): Promise<string[]> {
const profilesDir = path.join(hermesHome, 'profiles')
const moved: string[] = []
let profiles: fs.Dirent[]
for (const profile of await listDirs(path.join(hermesHome, 'profiles'))) {
const scopedRoot = path.join(hermesHome, 'profiles', profile, DESKTOP_PLUGINS_DIR)
try {
profiles = await fs.promises.readdir(profilesDir, { withFileTypes: true })
} catch {
return moved
}
for (const profile of profiles) {
if (!profile.isDirectory()) {
continue
}
const scopedRoot = path.join(profilesDir, profile.name, DESKTOP_PLUGINS_DIR)
let entries: fs.Dirent[]
try {
entries = await fs.promises.readdir(scopedRoot, { withFileTypes: true })
} catch {
continue
}
for (const entry of entries) {
if (!entry.isDirectory()) {
continue
}
const from = path.join(scopedRoot, entry.name)
const to = path.join(appRoot, entry.name)
for (const entry of await listDirs(scopedRoot)) {
const from = path.join(scopedRoot, entry)
const to = path.join(appRoot, entry)
if (fs.existsSync(to)) {
continue
@@ -86,3 +134,111 @@ export async function migrateProfileScopedDesktopPlugins(hermesHome: string, app
return moved
}
async function readMarker(dir: string): Promise<DesktopHalfMarker | null> {
try {
const raw = await fs.promises.readFile(path.join(dir, PACKAGE_MARKER), 'utf8')
const parsed = JSON.parse(raw) as Partial<DesktopHalfMarker>
return parsed.package && parsed.source ? (parsed as DesktopHalfMarker) : null
} catch {
return null
}
}
/** Copy one unified package's `desktop/` half into the app root as
* `<appRoot>/<packageName>/`, stamping the marker. Skips when the root copy is
* already current for this source; replaces it when the source is newer. A
* root folder of the same name WITHOUT a marker is a standalone install the
* user made on purpose and is never overwritten. Returns the target path
* when a copy happened. */
export async function materializeDesktopHalf(
packageDir: string,
appRoot: string,
packageName = path.basename(packageDir)
): Promise<null | string> {
const sourceDir = path.join(packageDir, 'desktop')
const entry = path.join(sourceDir, 'plugin.js')
let stat: fs.Stats
try {
stat = await fs.promises.stat(entry)
} catch {
return null
}
if (!stat.isFile()) {
return null
}
const target = path.join(appRoot, packageName)
const existing = await readMarker(target)
if (fs.existsSync(target)) {
if (!existing) {
return null
}
if (existing.source === sourceDir && existing.sourceMtimeMs >= stat.mtimeMs) {
return null
}
await fs.promises.rm(target, { force: true, recursive: true })
}
await fs.promises.mkdir(appRoot, { recursive: true })
await fs.promises.cp(sourceDir, target, { force: true, recursive: true })
const marker: DesktopHalfMarker = {
package: packageName,
source: sourceDir,
sourceMtimeMs: stat.mtimeMs,
...(await packageOrigin(packageDir))
}
await fs.promises.writeFile(path.join(target, PACKAGE_MARKER), JSON.stringify(marker, null, 2) + '\n')
return target
}
/** Walk every local home's `plugins/` root and materialize each package's
* desktop half. First home wins for a name that appears in several profiles
* (the default home is first). Also drops root copies whose source package
* is gone — an uninstalled agent package must not leave a ghost pane. */
export async function reconcileUnifiedDesktopHalves(hermesHome: string, appRoot: string): Promise<string[]> {
const touched: string[] = []
const seen = new Set<string>()
for (const home of await localHomes(hermesHome)) {
const pluginsRoot = path.join(home, 'plugins')
for (const name of await listDirs(pluginsRoot)) {
if (seen.has(name)) {
continue
}
const result = await materializeDesktopHalf(path.join(pluginsRoot, name), appRoot, name)
if (result || fs.existsSync(path.join(pluginsRoot, name, 'desktop', 'plugin.js'))) {
seen.add(name)
}
if (result) {
touched.push(result)
}
}
}
for (const name of await listDirs(appRoot)) {
const dir = path.join(appRoot, name)
const marker = await readMarker(dir)
if (marker && !fs.existsSync(path.join(marker.source, 'plugin.js'))) {
await fs.promises.rm(dir, { force: true, recursive: true })
touched.push(dir)
}
}
return touched
}
+16 -8
View File
@@ -8,7 +8,12 @@ import path from 'node:path'
import { ipcMain, shell } from 'electron'
import { installDesktopPluginFromGit, probePluginRepo } from './desktop-plugin-install'
import { DESKTOP_PLUGINS_DIR, ensureDir, migrateProfileScopedDesktopPlugins } from './desktop-plugins-root'
import {
DESKTOP_PLUGINS_DIR,
ensureDir,
migrateProfileScopedDesktopPlugins,
reconcileUnifiedDesktopHalves
} from './desktop-plugins-root'
import { readDirForIpc } from './fs-read-dir'
import { gitRootForIpc } from './git-root'
@@ -97,25 +102,28 @@ export function registerFsIpc({
async function desktopPluginsRoot(): Promise<string> {
const root = await ensureDir(path.join(hermesHome, DESKTOP_PLUGINS_DIR))
await migrateProfileScopedDesktopPlugins(hermesHome, root)
await reconcileUnifiedDesktopHalves(hermesHome, root)
return root
}
ipcMain.handle('hermes:fs:desktopPluginsRoot', async () => desktopPluginsRoot())
// Re-run the unified-half reconcile on demand (after an agent-plugin install /
// update / uninstall through the gateway) so the app-level copy tracks the
// package without waiting for the next root resolution.
ipcMain.handle('hermes:fs:reconcileDesktopPlugins', async () => {
const root = await ensureDir(path.join(hermesHome, DESKTOP_PLUGINS_DIR))
return reconcileUnifiedDesktopHalves(hermesHome, root)
})
// The LOCAL logs root (`<HERMES_HOME>/logs`, profile-aware) — the error
// card's "Open Logs" action reveals agent.log/gateway.log without the user
// knowing where HERMES_HOME lives. Same Electron-local resolution as the
// plugin roots: valid in every connection mode, created on demand.
ipcMain.handle('hermes:fs:logsRoot', async () => localPluginsRoot('logs'))
// The LOCAL agent-plugin root (`<HERMES_HOME>/plugins`), same Electron-local
// resolution as above. This is the desktop half of a UNIFIED plugin package:
// an agent plugin may ship `desktop/plugin.js` alongside its Python code (the
// same shape as `dashboard/manifest.json`), and the renderer's disk door scans
// this root for it — one installable folder serving both SDKs.
ipcMain.handle('hermes:fs:agentPluginsRoot', async () => localPluginsRoot('plugins'))
ipcMain.handle('hermes:plugin:probe', async (_event, payload) => {
const identifier = String(payload?.identifier || payload?.repo || '').trim()
+1 -1
View File
@@ -322,8 +322,8 @@ contextBridge.exposeInMainWorld('hermesDesktop', {
revealPath: targetPath => ipcRenderer.invoke('hermes:fs:reveal', targetPath),
openDir: dirPath => ipcRenderer.invoke('hermes:fs:openDir', dirPath),
desktopPluginsRoot: () => ipcRenderer.invoke('hermes:fs:desktopPluginsRoot'),
reconcileDesktopPlugins: () => ipcRenderer.invoke('hermes:fs:reconcileDesktopPlugins'),
logsRoot: () => ipcRenderer.invoke('hermes:fs:logsRoot'),
agentPluginsRoot: () => ipcRenderer.invoke('hermes:fs:agentPluginsRoot'),
renamePath: (targetPath, newName) => ipcRenderer.invoke('hermes:fs:rename', targetPath, newName),
writeTextFile: (filePath, content) => ipcRenderer.invoke('hermes:fs:writeText', filePath, content),
trashPath: targetPath => ipcRenderer.invoke('hermes:fs:trash', targetPath),
@@ -85,7 +85,15 @@ describe('Install from Git entry flow', () => {
: 'Installs into the default backend (~/.hermes/plugins/)'
)
).toBeTruthy()
expect(screen.getByText("Installs into this app's local desktop-plugins folder")).toBeTruthy()
// Local backend: the desktop half is copied out of the installed package
// (one source of truth). Remote backend: cloned separately, as before.
expect(
screen.getByText(
mode === 'remote'
? "Installs into this app's local desktop-plugins folder"
: 'Loaded into this app from the package above — same for every profile'
)
).toBeTruthy()
expect(requestGateway).not.toHaveBeenCalledWith('plugins.manage', expect.objectContaining({ action: 'install' }))
expect(installDesktopPlugin).not.toHaveBeenCalled()
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }))
@@ -159,7 +159,20 @@ export function PluginInstallModal() {
const profileLabel = request?.profile || activeProfile || profileScope || 'default'
const agentTargetHint =
connection?.mode === 'remote' ? m.agentTargetRemote(profileLabel) : m.agentTargetLocal(profileLabel)
connection?.mode === 'remote'
? m.agentTargetRemote(profileLabel)
: m.agentTargetLocal(
profileLabel,
request?.profile && request.profile !== 'default'
? `~/.hermes/profiles/${request.profile}/plugins/`
: '~/.hermes/plugins/'
)
// A unified package installed into a local backend carries its own desktop
// half; the app copies that half out of the package folder. Only a remote
// backend (whose plugins/ folder this machine cannot read) or a desktop-only
// repo needs a separate desktop clone.
const desktopHalfFromPackage = Boolean(probe?.agent && installAgent && connection?.mode !== 'remote')
const sourceLinks = useMemo(() => (request ? resolvePluginSourceLinks(request.repo) : null), [request])
@@ -229,18 +242,32 @@ export function PluginInstallModal() {
}
if (installDesktop && probe.desktop) {
const installFn = window.hermesDesktop?.installDesktopPlugin
if (agentInstalled && desktopHalfFromPackage) {
// Unified package into a LOCAL backend: the desktop half ships inside
// the package folder Electron just watched land. Materialise it from
// there (one source of truth, follows updates/uninstall) instead of
// cloning a second, standalone copy under another folder name.
const touched = (await window.hermesDesktop?.reconcileDesktopPlugins?.()) ?? []
if (!installFn) {
errors.push(m.desktopUnavailable)
} else {
const result = await installFn({ identifier: request.repo, force: forceReinstall })
successes.push(m.desktopSuccess(probe.agentName ?? request.repo))
if (result.ok) {
successes.push(m.desktopSuccess(result.pluginName ?? request.repo))
if (touched.length > 0) {
await discoverRuntimePlugins()
}
} else {
const installFn = window.hermesDesktop?.installDesktopPlugin
if (!installFn) {
errors.push(m.desktopUnavailable)
} else {
errors.push(result.error || m.desktopFailed)
const result = await installFn({ identifier: request.repo, force: forceReinstall })
if (result.ok) {
successes.push(m.desktopSuccess(result.pluginName ?? request.repo))
await discoverRuntimePlugins()
} else {
errors.push(result.error || m.desktopFailed)
}
}
}
}
@@ -418,8 +445,8 @@ export function PluginInstallModal() {
<span className="min-w-0">
<span className="block font-medium text-foreground">{m.desktopLabel}</span>
<span className="block text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
{m.desktopTarget}
{probe.desktopName ? ` · ${probe.desktopName}` : ''}
{desktopHalfFromPackage ? m.desktopTargetFromPackage : m.desktopTarget}
{desktopHalfFromPackage ? '' : probe.desktopName ? ` · ${probe.desktopName}` : ''}
</span>
</span>
</label>
@@ -1,97 +0,0 @@
import { cleanup, render, screen } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { $pluginRecords } from '@/contrib/plugins-store'
import { $agentPlugins, $agentPluginsStatus } from '@/store/agent-plugins'
import { $pluginInstallRequest, closePluginInstallRequest } from '@/store/plugin-install-request'
import { DesktopPluginsSection } from './desktop-plugins-section'
beforeEach(() => {
$pluginRecords.set({})
$agentPlugins.set([])
$agentPluginsStatus.set('ready')
closePluginInstallRequest()
})
afterEach(() => {
cleanup()
vi.restoreAllMocks()
})
describe('DesktopPluginsSection', () => {
it('flags a unified-root desktop half whose agent half is missing in the scoped profile', () => {
$pluginRecords.set({
'pixel-overlay': {
id: 'pixel-overlay',
name: 'Pixel Overlay',
kind: 'disk',
status: 'loaded',
file: '/home/user/.hermes/plugins/pixel-overlay/desktop/plugin.js'
}
})
$agentPlugins.set([]) // scoped backend has no agent half
render(<DesktopPluginsSection profile="workbot" />)
expect(screen.getByText('agent half missing here')).toBeTruthy()
})
it('does not flag when the agent half exists, nor standalone desktop plugins', () => {
$pluginRecords.set({
'pixel-overlay': {
id: 'pixel-overlay',
name: 'Pixel Overlay',
kind: 'disk',
status: 'loaded',
file: '/home/user/.hermes/plugins/pixel-overlay/desktop/plugin.js'
},
standalone: {
id: 'standalone',
name: 'Standalone Theme',
kind: 'disk',
status: 'loaded',
file: '/home/user/.config/hermes-desktop/desktop-plugins/standalone/plugin.js'
}
})
$agentPlugins.set([
{
description: '',
key: 'pixel-overlay',
name: 'pixel-overlay',
source: 'user',
status: 'enabled',
version: '1.0.0'
}
])
render(<DesktopPluginsSection profile={null} />)
expect(screen.queryByText('agent half missing here')).toBeNull()
expect(screen.getByText('Pixel Overlay')).toBeTruthy()
expect(screen.getByText('Standalone Theme')).toBeTruthy()
})
it('repairs the agent half into the SCOPED profile, not the active one', async () => {
$pluginRecords.set({
'pixel-overlay': {
id: 'pixel-overlay',
name: 'Pixel Overlay',
kind: 'disk',
status: 'loaded',
file: '/home/user/.hermes/plugins/pixel-overlay/desktop/plugin.js'
}
})
render(<DesktopPluginsSection profile="workbot" />)
screen.getByRole('button', { name: 'agent half missing here' }).click()
await vi.waitFor(() => {
const request = $pluginInstallRequest.get()
expect(request?.profile).toBe('workbot')
expect(request?.legacyHint).toBe('agent')
expect(request?.repo).toBe('pixel-overlay')
})
})
})
@@ -1,276 +0,0 @@
import { useStore } from '@nanostores/react'
import type { ReactNode } from 'react'
import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
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 { useI18n } from '@/i18n'
import { triggerHaptic } from '@/lib/haptics'
import { FolderOpen, Monitor, RefreshCw } from '@/lib/icons'
import { $agentPlugins, $agentPluginsStatus } from '@/store/agent-plugins'
import { notifyError } from '@/store/notifications'
import { openPluginInstallRequest } from '@/store/plugin-install-request'
import { Pill } from '../settings/primitives'
const KIND_ORDER: Record<PluginRecord['kind'], number> = { disk: 0, runtime: 1, bundled: 2 }
/** Deep-link anchor for a plugin row (`/skills?tab=plugins&plugin=<id>`); shared
* by desktop rows (record id) and agent rows (canonical key). */
export const pluginElementId = (target: string) => `plugin-${target}`
function reveal(file: string) {
void window.hermesDesktop?.revealPath?.(file)?.catch(() => undefined)
}
async function revealPluginsDir() {
try {
// Electron owns the local plugin root — deriving it from the backend's
// hermes_home breaks against a remote backend (#66899).
const dir = await window.hermesDesktop?.desktopPluginsRoot?.()
if (!dir) {
notifyError('Desktop plugins are unavailable', 'Could not resolve the plugins folder')
return
}
// openDir (not reveal): the door often doesn't exist on first use, and
// showItemInFolder on a missing path silently no-ops (esp. Windows).
const result = await window.hermesDesktop?.openDir?.(dir)
if (result && !result.ok) {
notifyError(result.error ?? 'unknown error', 'Could not open the plugins folder')
}
} catch (err) {
notifyError(err, 'Could not resolve the plugins folder')
}
}
/** Folder name when a desktop plugin entry lives in the UNIFIED agent-plugins
* root (`~/.hermes/plugins/<name>/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
}
/** Open the dual-target install modal pre-filled to install ONLY the agent
* half of a bundled package (drift repair). Provenance comes from the
* package's catalog sidecar when present; otherwise the git remote of the
* plugin folder is unknown and we fall back to the folder name as the
* identifier hint. */
async function repairAgentHalf(record: PluginRecord, packageName: string, profile: null | string) {
let repo = ''
let catalogName: string | undefined
let sha: string | undefined
try {
const pluginDir = record.file?.replace(/[\\/]desktop[\\/]plugin\.js$/, '')
const raw = pluginDir ? await window.hermesDesktop?.readFileText?.(`${pluginDir}/.hermes-catalog.json`) : null
if (raw) {
const sidecar = JSON.parse(typeof raw === 'string' ? raw : ((raw as { content?: string }).content ?? '')) as {
catalog_name?: string
repo?: string
sha?: string
}
repo = sidecar.repo ?? ''
catalogName = sidecar.catalog_name
sha = sidecar.sha
}
} catch {
// No sidecar (raw-git bundled install) — fall through to the name hint.
}
openPluginInstallRequest({
catalogName,
legacyHint: 'agent',
profile,
repo: repo || packageName,
sha
})
}
/** One list row, same type scale and rhythm as the agent-plugin rows above it. */
export function PluginListRow({
title,
description,
controls,
icon,
id
}: {
title: ReactNode
description?: ReactNode
controls: ReactNode
icon: ReactNode
id?: string
}) {
return (
<div className="flex items-start gap-3 border-b border-(--ui-stroke-tertiary) px-3 py-2 last:border-b-0" id={id}>
{icon}
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2 text-[length:var(--conversation-text-font-size)] font-medium text-foreground">
{title}
</div>
{description && (
<div className="mt-0.5 text-[length:var(--conversation-caption-font-size)] break-words text-(--ui-text-tertiary)">
{description}
</div>
)}
</div>
<div className="flex shrink-0 items-center gap-2">{controls}</div>
</div>
)
}
function DesktopPluginRow({
record,
agentHalfMissing,
profile
}: {
record: PluginRecord
agentHalfMissing?: boolean
profile: null | string
}) {
const { t } = useI18n()
const p = t.settings.plugins
return (
<PluginListRow
controls={
<>
{record.file && (
<Tip label={p.reveal}>
<Button onClick={() => reveal(record.file!)} size="icon" variant="ghost">
<Codicon name="folder-opened" size="0.85rem" />
</Button>
</Tip>
)}
<Switch
aria-label={`${record.status === 'disabled' ? p.enable : p.disable} ${record.name}`}
checked={record.status !== 'disabled'}
onCheckedChange={on => {
triggerHaptic('selection')
void setPluginEnabled(record.id, on)
}}
/>
</>
}
description={
record.status === 'error' ? (
<span className="text-(--ui-danger,#f87171)">{record.error}</span>
) : (
(record.description ?? record.file ?? record.id)
)
}
icon={<Monitor aria-hidden className="mt-0.5 size-4 shrink-0 text-(--ui-text-tertiary)" />}
id={pluginElementId(record.id)}
title={
<>
<span>{record.name}</span>
<Pill>{p.kinds[record.kind]}</Pill>
{record.status === 'error' && <Pill tone="primary">{p.failed}</Pill>}
{agentHalfMissing && (
<Tip label={p.agentHalfMissingTip}>
<Button
className="h-5 px-1.5 text-[0.65rem]"
onClick={() => void repairAgentHalf(record, unifiedPackageName(record.file) ?? record.name, profile)}
size="xs"
variant="outline"
>
{p.agentHalfMissing}
</Button>
</Tip>
)}
</>
}
/>
)
}
/** Plugins that extend THIS app (bundled, dropped into the desktop-plugins
* folder, or the desktop half of a unified package). They belong to the
* desktop, not to a profile, so the section is the same for every scope;
* the drift badge compares against the SCOPED profile's agent list so a
* bundled package missing its agent half where the user is looking gets a
* one-click repair. */
export function DesktopPluginsSection({ profile }: { profile: null | string }) {
const { t } = useI18n()
const p = t.settings.plugins
const records = useStore($pluginRecords)
const agentRows = useStore($agentPlugins)
const agentStatus = useStore($agentPluginsStatus)
const agentNames = new Set(agentRows.flatMap(row => [row.name, row.key ?? row.name]))
const rows = Object.values(records).sort(
(a, b) => KIND_ORDER[a.kind] - KIND_ORDER[b.kind] || a.name.localeCompare(b.name)
)
return (
<section>
<div className="flex items-center justify-between gap-3 px-3 pt-3 pb-1">
<div className="min-w-0">
<div className="text-[0.62rem] font-medium tracking-wide uppercase text-(--ui-text-quaternary)">
{p.title} · {p.count(rows.length)}
</div>
<p className="mt-0.5 text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
{p.blurb}
</p>
</div>
<div className="flex shrink-0 items-center gap-1">
<Tip label={p.openFolder}>
<Button onClick={() => void revealPluginsDir()} size="icon" type="button" variant="ghost">
<FolderOpen className="size-3.5" />
</Button>
</Tip>
<Tip label={p.rescan}>
<Button
onClick={() => {
triggerHaptic('selection')
void discoverRuntimePlugins()
}}
size="icon"
type="button"
variant="ghost"
>
<RefreshCw className="size-3.5" />
</Button>
</Tip>
</div>
</div>
{rows.length === 0 ? (
<p className="px-3 py-3 text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
{p.empty}
</p>
) : (
<div className="flex flex-col">
{rows.map(record => {
const packageName = unifiedPackageName(record.file)
return (
<DesktopPluginRow
agentHalfMissing={packageName !== null && agentStatus === 'ready' && !agentNames.has(packageName)}
key={record.id}
profile={profile}
record={record}
/>
)
})}
</div>
)}
</section>
)
}
+20 -4
View File
@@ -822,14 +822,25 @@ export function SkillsView({
// Browse Hub). Lets the user configure ANY profile's capabilities — on any
// registered gateway — without switching the whole app. Only meaningful
// with >1 option; hidden otherwise to avoid clutter.
// Plugins embeds the selector in its Agent-column header (compact, no label,
// no border): desktop halves on that page are app-level and must not read as
// governed by "Configuring: <profile>".
const compactSelector = mode === 'plugins'
const scopeLabel = scopeOptions.find(option => option.value === scopeSelectValue)?.label
const profileScopeSelector =
scopeOptions.length > 1 ? (
<div
className={cn('flex items-center gap-2 px-3 py-2', mode !== 'plugins' && 'border-b border-(--ui-stroke-secondary)')}
className={cn(
'flex min-w-0 items-center gap-2',
compactSelector ? 'flex-1' : 'border-b border-(--ui-stroke-secondary) px-3 py-2'
)}
>
<span className="text-[0.7rem] font-medium text-(--ui-text-tertiary)">{t.skills.configuringProfile}</span>
{!compactSelector && (
<span className="text-[0.7rem] font-medium text-(--ui-text-tertiary)">{t.skills.configuringProfile}</span>
)}
<Select onValueChange={changeScope} value={scopeSelectValue}>
<SelectTrigger className="h-7 w-56 text-xs">
<SelectTrigger className={cn('text-xs', compactSelector ? 'h-6 w-full max-w-64 px-2' : 'h-7 w-56')}>
<SelectValue />
</SelectTrigger>
<SelectContent>
@@ -877,7 +888,12 @@ export function SkillsView({
// header), app-level desktop plugins, the live catalog picker
// underneath. Keyed on scope so a profile/connection switch
// reloads the agent list.
<PluginsTab key={`plugins-${scopeKey}`} profile={scopeProfile} scopeSelector={profileScopeSelector} />
<PluginsTab
key={`plugins-${scopeKey}`}
profile={scopeProfile}
scopeLabel={scopeLabel}
scopeSelector={profileScopeSelector}
/>
) : 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
@@ -0,0 +1,64 @@
import { describe, expect, it } from 'vitest'
import type { PluginRecord } from '@/contrib/plugins-store'
import type { AgentPluginRow } from '@/store/agent-plugins'
import { mergePluginPackages } from './plugin-packages'
const agent = (over: Partial<AgentPluginRow>): AgentPluginRow => ({
description: '',
key: over.name ?? 'x',
name: 'x',
source: 'git',
status: 'enabled',
version: '1.0.0',
...over
})
const desktop = (over: Partial<PluginRecord>): PluginRecord => ({
id: 'x',
kind: 'disk',
name: 'x',
status: 'loaded',
...over
})
describe('mergePluginPackages', () => {
it('shows a unified package as ONE row with both halves, never two rows', () => {
const rows = mergePluginPackages(
[desktop({ id: 'media', name: 'Media Studio', packageName: 'hermes-media-studio' })],
[agent({ name: 'hermes-media-studio', has_desktop_half: true, description: 'Generate media.' })]
)
expect(rows).toHaveLength(1)
expect(rows[0]).toMatchObject({ kind: 'both', agentMissingInProfile: false, desktopMissing: false })
expect(rows[0].desktop?.id).toBe('media')
expect(rows[0].agent?.name).toBe('hermes-media-studio')
})
it('a desktop half whose agent half is absent from THIS profile offers the install-here affordance', () => {
const rows = mergePluginPackages([desktop({ id: 'media', packageName: 'hermes-media-studio' })], [])
expect(rows).toHaveLength(1)
expect(rows[0]).toMatchObject({ kind: 'both', agent: null, agentMissingInProfile: true })
})
it('an agent package that declares a desktop half not yet copied to the app is flagged pending', () => {
const rows = mergePluginPackages([], [agent({ name: 'pkg', has_desktop_half: true })])
expect(rows[0]).toMatchObject({ kind: 'both', desktop: null, desktopMissing: true })
})
it('standalone desktop plugins and agent-only packages keep one empty side; unified rows sort first', () => {
const rows = mergePluginPackages(
[desktop({ id: 'bots', name: 'Bots', kind: 'bundled' }), desktop({ id: 'u', packageName: 'unified' })],
[agent({ name: 'snapcompact' }), agent({ name: 'unified', has_desktop_half: true })]
)
expect(rows.map(r => [r.key, r.kind])).toEqual([
['unified', 'both'],
['snapcompact', 'agent'],
['desktop:bots', 'desktop']
])
})
})
@@ -0,0 +1,135 @@
import type { PluginRecord } from '@/contrib/plugins-store'
import type { AgentPluginRow } from '@/store/agent-plugins'
/**
* One row per PACKAGE on the Plugins page. A package may have a desktop half
* (loaded into this app, same for every profile), an agent half (installed in
* the SELECTED profile's backend), or both — a unified package whose folder
* ships `plugin.yaml` next to `desktop/plugin.js`.
*
* The join key is the package folder name: the agent row's `name` and the
* desktop record's `packageName` (stamped by Electron when it copies the half
* out to the app root). A standalone desktop plugin or an agent-only package
* simply has one side empty.
*/
export type PackageKind = 'agent' | 'both' | 'desktop'
export interface PluginPackage {
key: string
name: string
description: string
kind: PackageKind
desktop: null | PluginRecord
agent: AgentPluginRow | null
/** Agent half exists on disk somewhere (its desktop half is here) but is not
* installed in the selected profile — the "Install here" affordance. */
agentMissingInProfile: boolean
/** Desktop half is declared by the agent package but the app has no copy yet
* (Electron's reconcile has not run, or the copy failed). */
desktopMissing: boolean
}
/** Folder name when a desktop record lives in the UNIFIED agent-plugins root
* (`~/.hermes/plugins/<name>/desktop/plugin.js`) — legacy records from before
* halves were copied to the app root. */
function legacyPackageName(file?: string): null | string {
if (!file) {
return null
}
const match = /[\\/]plugins[\\/]([^\\/]+)[\\/]desktop[\\/]plugin\.js$/.exec(file)
return match ? match[1] : null
}
export function desktopPackageName(record: PluginRecord): null | string {
return record.packageName ?? legacyPackageName(record.file)
}
const KIND_RANK: Record<PackageKind, number> = { both: 0, agent: 1, desktop: 2 }
const DESKTOP_KIND_RANK: Record<PluginRecord['kind'], number> = { disk: 0, runtime: 1, bundled: 2 }
export function mergePluginPackages(
desktopRecords: readonly PluginRecord[],
agentRows: readonly AgentPluginRow[]
): PluginPackage[] {
const byKey = new Map<string, PluginPackage>()
for (const row of agentRows) {
const key = row.name
byKey.set(key, {
key,
name: row.name,
description: row.description,
kind: row.has_desktop_half ? 'both' : 'agent',
desktop: null,
agent: row,
agentMissingInProfile: false,
desktopMissing: Boolean(row.has_desktop_half)
})
}
for (const record of desktopRecords) {
const pkg = desktopPackageName(record)
if (pkg) {
const existing = byKey.get(pkg)
if (existing) {
existing.desktop = record
existing.kind = 'both'
existing.desktopMissing = false
// The desktop half carries the human display name ("Pixel Overlay"
// vs the folder-shaped agent name); keep the title stable whichever
// profile is selected.
existing.name = record.name
existing.description ||= record.description ?? ''
continue
}
// Desktop half present, agent half absent from THIS profile.
byKey.set(pkg, {
key: pkg,
name: record.name,
description: record.description ?? '',
kind: 'both',
desktop: record,
agent: null,
agentMissingInProfile: true,
desktopMissing: false
})
continue
}
byKey.set(`desktop:${record.id}`, {
key: `desktop:${record.id}`,
name: record.name,
description: record.description ?? '',
kind: 'desktop',
desktop: record,
agent: null,
agentMissingInProfile: false,
desktopMissing: false
})
}
return [...byKey.values()].sort((a, b) => {
const rank = KIND_RANK[a.kind] - KIND_RANK[b.kind]
if (rank !== 0) {
return rank
}
if (a.desktop && b.desktop) {
const dk = DESKTOP_KIND_RANK[a.desktop.kind] - DESKTOP_KIND_RANK[b.desktop.kind]
if (dk !== 0) {
return dk
}
}
return a.name.localeCompare(b.name)
})
}
@@ -1,6 +1,7 @@
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { $pluginRecords } from '@/contrib/plugins-store'
import { $agentPlugins, $agentPluginsStatus } from '@/store/agent-plugins'
import { $paneHeightOverride, setPaneHeightOverride } from '@/store/panes'
import { $pluginInstallRequest, closePluginInstallRequest } from '@/store/plugin-install-request'
@@ -15,6 +16,7 @@ vi.mock('@/app/gateway/hooks/use-gateway-request', () => ({
describe('PluginsTab', () => {
beforeEach(() => {
$pluginRecords.set({})
$agentPlugins.set([])
$agentPluginsStatus.set('ready')
closePluginInstallRequest()
@@ -38,7 +40,7 @@ describe('PluginsTab', () => {
render(<PluginsTab profile="workbot" />)
expect(screen.getByText('demo-plugin')).toBeTruthy()
expect(screen.getByRole('switch', { name: 'demo-plugin' }).getAttribute('aria-checked')).toBe('true')
expect(screen.getByRole('switch', { name: 'Agent: demo-plugin' }).getAttribute('aria-checked')).toBe('true')
})
it('hides bundled plugins (managed from their own surfaces)', () => {
@@ -56,7 +58,61 @@ describe('PluginsTab', () => {
render(<PluginsTab profile={null} />)
expect(screen.queryByText('fal')).toBeNull()
expect(screen.getByText(/No agent plugins installed/)).toBeTruthy()
expect(screen.getByText(/No plugins yet/)).toBeTruthy()
})
it('renders a unified package as ONE row with a Desktop switch and an Agent switch', () => {
$pluginRecords.set({
media: { id: 'media', name: 'Media Studio', kind: 'disk', status: 'loaded', packageName: 'hermes-media-studio' }
})
$agentPlugins.set([
{ description: '', key: 'hermes-media-studio', name: 'hermes-media-studio', source: 'git', status: 'disabled', version: '1' }
])
render(<PluginsTab profile="workbot" scopeLabel="workbot" />)
expect(screen.getAllByTestId(/^plugin-row-/)).toHaveLength(1)
expect(screen.getByText('Agent + Desktop')).toBeTruthy()
expect(screen.getByRole('switch', { name: 'Desktop: Media Studio' }).getAttribute('aria-checked')).toBe('true')
expect(screen.getByRole('switch', { name: 'Agent: Media Studio' }).getAttribute('aria-checked')).toBe('false')
expect(screen.getAllByText('Agent in workbot').length).toBeGreaterThan(0)
})
it('offers "Install here" for a desktop half whose agent half is not in the selected profile', async () => {
$pluginRecords.set({
media: {
id: 'media',
name: 'Media Studio',
kind: 'disk',
status: 'loaded',
packageName: 'hermes-media-studio',
packageOrigin: { repo: 'https://github.com/NousResearch/hermes-media-studio.git', sha: 'abc' }
}
})
render(<PluginsTab profile="workbot" scopeLabel="workbot" />)
expect(screen.queryByRole('switch', { name: /^Agent:/ })).toBeNull()
screen.getByRole('button', { name: 'Install here' }).click()
// Pre-filled from the package marker: repo + pinned sha, agent half only.
await waitFor(() => {
expect($pluginInstallRequest.get()).toMatchObject({
legacyHint: 'agent',
profile: 'workbot',
repo: 'https://github.com/NousResearch/hermes-media-studio.git',
sha: 'abc'
})
})
})
it('disables "Install here" when the package has no known origin (hand-copied folder)', () => {
$pluginRecords.set({
media: { id: 'media', name: 'Media Studio', kind: 'disk', status: 'loaded', packageName: 'hermes-media-studio' }
})
render(<PluginsTab profile="workbot" scopeLabel="workbot" />)
expect((screen.getByRole('button', { name: 'Install here' }) as HTMLButtonElement).disabled).toBe(true)
})
it('loads the plugin list scoped to the selected profile', () => {
@@ -131,7 +187,7 @@ describe('PluginsTab', () => {
render(<PluginsTab profile={null} />)
screen.getByRole('switch', { name: 'Legacy plugin' }).click()
screen.getByRole('switch', { name: 'Agent: Legacy plugin' }).click()
await waitFor(() =>
expect(requestGateway).toHaveBeenCalledWith(
@@ -156,7 +212,7 @@ describe('PluginsTab', () => {
render(<PluginsTab profile={null} />)
const toggle = screen.getByRole('switch', { name: 'Legacy plugin' })
const toggle = screen.getByRole('switch', { name: 'Agent: Legacy plugin' })
expect(toggle.hasAttribute('disabled') || toggle.getAttribute('aria-disabled') === 'true').toBe(true)
+401 -182
View File
@@ -3,11 +3,15 @@ import { memo, type ReactNode, type PointerEvent as ReactPointerEvent, useEffect
import { useGatewayRequest } from '@/app/gateway/hooks/use-gateway-request'
import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
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 type { ProfileScope } from '@/hermes'
import { useI18n } from '@/i18n'
import { Loader2, Package } from '@/lib/icons'
import { triggerHaptic } from '@/lib/haptics'
import { FolderOpen, Loader2, Monitor, Package, RefreshCw } from '@/lib/icons'
import { cn } from '@/lib/utils'
import {
$agentPluginBusy,
@@ -15,19 +19,21 @@ import {
$agentPluginsError,
$agentPluginsStatus,
type AgentPluginRow,
type GatewayRequest,
isDesktopRelevantPlugin,
loadAgentPlugins,
toggleAgentPlugin,
updateAgentPlugin
} from '@/store/agent-plugins'
import { notify } from '@/store/notifications'
import { notify, notifyError } from '@/store/notifications'
import { $paneHeightOverride, setPaneHeightOverride } from '@/store/panes'
import { openPluginInstallRequest } from '@/store/plugin-install-request'
import { PanelEmpty } from '../overlays/panel'
import { Pill } from '../settings/primitives'
import { useDeepLinkHighlight } from '../settings/use-deep-link-highlight'
import { DesktopPluginsSection, pluginElementId, PluginListRow } from './desktop-plugins-section'
import { mergePluginPackages, type PackageKind, type PluginPackage } from './plugin-packages'
// The REAL Plugin Catalog page (docs site) embedded as a one-click picker —
// the same pattern as the Skills tab's EmbeddedHubPicker. `?embed=picker`
@@ -35,20 +41,19 @@ import { DesktopPluginsSection, pluginElementId, PluginListRow } from './desktop
// { 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.
// scoped profile; desktop half → this app), so unified 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`
// Catalog viewport: persisted through the shared pane store, dragged from the
// section's TOP edge ("pull the catalog up"), clamped so neither the catalog
// nor the plugin lists above can vanish. Same contract as EmbeddedHubPicker.
// nor the plugin list above can vanish. Same contract as EmbeddedHubPicker.
const CATALOG_PANE_ID = 'capabilities-plugin-catalog'
const CATALOG_DEFAULT_PX = 380
const CATALOG_MIN_PX = 120
const CATALOG_MAX_VH = 0.75
const CATALOG_COLLAPSED_PX = 4
// Room the sash must always leave for the lists above the catalog.
const CATALOG_LIST_RESERVED_PX = 176
interface PluginPickMessage {
@@ -61,6 +66,10 @@ interface PluginPickMessage {
type?: string
}
/** Deep-link anchor for a package row (`/skills?tab=plugins&plugin=<key>`).
* Accepts the agent key, the agent name, or the desktop record id. */
export const pluginElementId = (target: string) => `plugin-${target}`
/** Derive the bare profile name a `plugins.manage` call should target. */
function profileParam(scope: ProfileScope): null | string {
if (!scope) {
@@ -70,124 +79,315 @@ function profileParam(scope: ProfileScope): null | string {
return typeof scope === 'string' ? scope : (scope.profile ?? null)
}
function AgentPluginListRow({
row,
busy,
onToggle,
onUpdate
}: {
row: AgentPluginRow
busy: boolean
onToggle: (enable: boolean) => void
onUpdate?: () => void
}) {
function reveal(file: string) {
void window.hermesDesktop?.revealPath?.(file)?.catch(() => undefined)
}
async function revealPluginsDir() {
try {
// Electron owns the app-level plugin root — deriving it from the backend's
// hermes_home breaks against a remote backend (#66899).
const dir = await window.hermesDesktop?.desktopPluginsRoot?.()
if (!dir) {
notifyError('Desktop plugins are unavailable', 'Could not resolve the plugins folder')
return
}
const result = await window.hermesDesktop?.openDir?.(dir)
if (result && !result.ok) {
notifyError(result.error ?? 'unknown error', 'Could not open the plugins folder')
}
} catch (err) {
notifyError(err, 'Could not resolve the plugins folder')
}
}
/** Copy any changed unified desktop halves into the app root FIRST, then
* rescan the root — a concurrent scan would read the pre-copy state. */
async function rescanAll(requestGateway: GatewayRequest, scope: null | string) {
await window.hermesDesktop?.reconcileDesktopPlugins?.().catch(() => undefined)
await discoverRuntimePlugins()
await loadAgentPlugins(requestGateway, scope)
}
/** Open the dual-target install modal pre-filled to install ONLY the agent
* half of a unified package into the scoped profile (the desktop half is
* already here). Provenance comes from the package marker Electron stamped
* when it copied the half out (catalog sidecar or git remote). */
function installAgentHalfHere(record: PluginRecord, profile: null | string) {
const origin = record.packageOrigin
if (!origin?.repo) {
return
}
openPluginInstallRequest({
catalogName: origin.catalogName,
legacyHint: 'agent',
profile,
repo: origin.repo,
sha: origin.sha
})
}
function KindBadge({ kind }: { kind: PackageKind }) {
const { t } = useI18n()
const address = row.key ?? ''
const canToggle = Boolean(address)
const enabled = row.status === 'enabled'
const p = t.skills.plugins
return (
<PluginListRow
controls={
<>
{busy && <Loader2 className="size-3.5 animate-spin text-(--ui-text-tertiary)" />}
{canToggle ? (
<Switch aria-label={row.name} checked={enabled} disabled={busy} onCheckedChange={onToggle} />
) : (
<Tip label={t.skills.plugins.legacyBackend}>
<span>
<Switch aria-label={row.name} checked={enabled} disabled />
</span>
</Tip>
)}
</>
}
description={row.description || undefined}
icon={<Package aria-hidden className="mt-0.5 size-4 shrink-0 text-(--ui-text-tertiary)" />}
id={pluginElementId(row.key ?? row.name)}
title={
<>
{row.name}
{row.version && <span className="text-(--ui-text-quaternary)">v{row.version}</span>}
{row.portable && (
<span className="rounded border border-(--ui-stroke-tertiary) px-1 text-[0.65rem] text-(--ui-text-tertiary)">
{t.skills.plugins.portableBadge}
</span>
)}
{row.catalog_name && (
<Tip label={t.skills.plugins.catalogProvenance(row.installed_sha?.slice(0, 8) ?? '')}>
<span className="rounded border border-(--ui-stroke-tertiary) px-1 text-[0.65rem] text-(--ui-text-tertiary)">
{row.catalog_tier === 'official' ? t.skills.plugins.tierOfficial : t.skills.plugins.tierCommunity}
</span>
</Tip>
)}
{row.pinned_sha && (
<Tip label={t.skills.plugins.pinnedProvenance(row.pinned_sha.slice(0, 8))}>
<span className="rounded border border-(--ui-stroke-tertiary) px-1 font-mono text-[0.65rem] text-(--ui-text-tertiary)">
{t.skills.plugins.pinnedBadge(row.pinned_sha.slice(0, 8))}
</span>
</Tip>
)}
{row.update_available && onUpdate && (
<Button
className="h-5 px-1.5 text-[0.65rem]"
disabled={busy}
onClick={onUpdate}
size="xs"
variant="outline"
>
{t.skills.plugins.updateToPin(row.catalog_sha?.slice(0, 8) ?? '')}
</Button>
)}
</>
}
/>
<span className="inline-flex items-center gap-1 rounded border border-(--ui-stroke-tertiary) px-1.5 py-px text-[0.65rem] text-(--ui-text-tertiary)">
{kind !== 'desktop' && <Package aria-hidden className="size-3" />}
{kind !== 'agent' && <Monitor aria-hidden className="size-3" />}
{kind === 'both' ? p.kindBoth : kind === 'agent' ? p.kindAgent : p.kindDesktop}
</span>
)
}
/** THE plugins surface. One page answers "what extends my Hermes?" for both
* halves: agent plugins (backend, per profile — the scope selector above
* picks which) and desktop plugins (this app, every profile). Discovery
* sits with management: the live catalog picker underneath, plus a manual
* "Install from Git" for anything not in the catalog. Settings no longer
* carries a second, partial copy of this. */
export const PluginsTab = memo(function PluginsTab({
profile,
scopeSelector
/** Provenance pill: where the package came from. */
function ProvenancePill({ pkg }: { pkg: PluginPackage }) {
const { t } = useI18n()
const p = t.skills.plugins
if (pkg.agent?.catalog_name) {
return (
<Tip label={p.catalogProvenance(pkg.agent.installed_sha?.slice(0, 8) ?? '')}>
<span>
<Pill>{pkg.agent.catalog_tier === 'official' ? p.tierOfficial : p.tierCommunity}</Pill>
</span>
</Tip>
)
}
if (pkg.agent?.pinned_sha) {
return (
<Tip label={p.pinnedProvenance(pkg.agent.pinned_sha.slice(0, 8))}>
<span>
<Pill>
<span className="font-mono">{p.pinnedBadge(pkg.agent.pinned_sha.slice(0, 8))}</span>
</Pill>
</span>
</Tip>
)
}
if (pkg.agent) {
return <Pill>{pkg.agent.source}</Pill>
}
if (pkg.desktop) {
return <Pill>{t.settings.plugins.kinds[pkg.desktop.kind]}</Pill>
}
return null
}
/** Column widths shared by the header and every row so the two control
* columns line up down the page like a table. */
const HALF_COL = 'flex w-36 shrink-0 items-center gap-1.5'
/** One control cell; `label` is the accessible name for screen readers only
* (the visible column label lives once, in the header). */
function HalfCell({ label, children }: { label: string; children: ReactNode }) {
return (
<div aria-label={label} className={HALF_COL} role="cell">
{children}
</div>
)
}
function Dash() {
return (
<span aria-hidden className="w-9 text-center text-(--ui-text-quaternary)">
—
</span>
)
}
function PackageRow({
pkg,
scope,
scopeLabel,
busy,
onAgentToggle,
onAgentUpdate
}: {
profile: ProfileScope
/** The Capabilities profile selector, rendered INSIDE the agent section so
* the app-level desktop section below it is visibly outside its scope. */
scopeSelector?: ReactNode
pkg: PluginPackage
scope: null | string
scopeLabel: string
busy: boolean
onAgentToggle: (row: AgentPluginRow, enable: boolean) => void
onAgentUpdate: (row: AgentPluginRow) => void
}) {
const { t } = useI18n()
const p = t.skills.plugins
const d = t.settings.plugins
const desktop = pkg.desktop
const agent = pkg.agent
const desktopOn = desktop ? desktop.status !== 'disabled' : false
const agentOn = agent?.status === 'enabled'
const agentToggleable = Boolean(agent?.key)
return (
<div
className="flex items-center gap-3 border-b border-(--ui-stroke-tertiary) px-3 py-2.5 last:border-b-0"
data-testid={`plugin-row-${pkg.key}`}
id={pluginElementId(agent?.key ?? agent?.name ?? desktop?.id ?? pkg.key)}
role="row"
>
<div className="flex min-w-0 flex-1 items-start gap-2" role="cell">
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2 text-[length:var(--conversation-text-font-size)] font-medium text-foreground">
<span>{pkg.name}</span>
{agent?.version && <span className="text-(--ui-text-quaternary)">v{agent.version}</span>}
<KindBadge kind={pkg.kind} />
<ProvenancePill pkg={pkg} />
{agent?.portable && <Pill>{p.portableBadge}</Pill>}
{desktop?.status === 'error' && <Pill tone="primary">{d.failed}</Pill>}
</div>
{(desktop?.status === 'error' ? desktop.error : pkg.description) && (
<div
className={cn(
'mt-0.5 text-[length:var(--conversation-caption-font-size)] break-words',
desktop?.status === 'error' ? 'text-(--ui-danger,#f87171)' : 'text-(--ui-text-tertiary)'
)}
>
{desktop?.status === 'error' ? desktop.error : pkg.description}
</div>
)}
</div>
{/* Fixed slot so the switch column stays straight whether or not
this row has a folder to reveal (bundled plugins have none). */}
<span className="flex size-7 shrink-0 items-center justify-center">
{desktop?.file && (
<Tip label={d.reveal}>
<Button onClick={() => reveal(desktop.file!)} size="icon" variant="ghost">
<Codicon name="folder-opened" size="0.85rem" />
</Button>
</Tip>
)}
</span>
</div>
{/* The two halves. Desktop is app-level and reads the same whichever
profile is selected; Agent follows the selector. A half the package
lacks shows a dash; a half it has but which is missing on this side
shows the install affordance. */}
<HalfCell label={p.halfDesktop}>
{desktop ? (
<Switch
aria-label={`${p.halfDesktop}: ${pkg.name}`}
checked={desktopOn}
onCheckedChange={on => {
triggerHaptic('selection')
void setPluginEnabled(desktop.id, on)
}}
/>
) : pkg.desktopMissing ? (
<Tip label={p.desktopHalfPendingTip}>
<span className="text-[0.65rem] text-(--ui-text-tertiary)">{p.desktopHalfPending}</span>
</Tip>
) : (
<Dash />
)}
</HalfCell>
<HalfCell label={p.halfAgentIn(scopeLabel)}>
{agent ? (
<>
{agent.update_available && (
<Button
className="h-5 px-1.5 text-[0.65rem]"
disabled={busy}
onClick={() => onAgentUpdate(agent)}
size="xs"
variant="outline"
>
{p.updateToPin(agent.catalog_sha?.slice(0, 8) ?? '')}
</Button>
)}
{busy && <Loader2 className="size-3.5 animate-spin text-(--ui-text-tertiary)" />}
{agentToggleable ? (
<Switch
aria-label={`${p.halfAgent}: ${pkg.name}`}
checked={agentOn}
disabled={busy}
onCheckedChange={on => onAgentToggle(agent, on)}
/>
) : (
<Tip label={p.legacyBackend}>
<span>
<Switch aria-label={`${p.halfAgent}: ${pkg.name}`} checked={agentOn} disabled />
</span>
</Tip>
)}
</>
) : pkg.agentMissingInProfile && desktop ? (
<Tip label={desktop.packageOrigin?.repo ? p.installAgentHereTip(scopeLabel) : p.installAgentHereNoOrigin}>
<span>
<Button
className="h-5 px-1.5 text-[0.65rem]"
disabled={!desktop.packageOrigin?.repo}
onClick={() => installAgentHalfHere(desktop, scope)}
size="xs"
variant="outline"
>
{p.installAgentHere}
</Button>
</span>
</Tip>
) : (
<Dash />
)}
</HalfCell>
</div>
)
}
/** THE plugins surface: one row per package. Each row shows its Desktop half
* (this app — the same for every profile, gateway, or machine) and its Agent
* half (the selected profile's backend). Discovery sits underneath: the live
* catalog picker plus Install from Git for anything not in the catalog. */
export const PluginsTab = memo(function PluginsTab({
profile,
scopeSelector,
scopeLabel
}: {
profile: ProfileScope
/** The Capabilities profile selector; rendered in the Agent column header so
* it visibly governs only that column. */
scopeSelector?: ReactNode
/** Display name of the selected profile for the Agent column label. */
scopeLabel?: string
}) {
const { t } = useI18n()
const p = t.skills.plugins
const d = t.settings.plugins
const { requestGateway } = useGatewayRequest()
const rows = useStore($agentPlugins)
const desktopRecords = useStore($pluginRecords)
const agentRows = useStore($agentPlugins)
const status = useStore($agentPluginsStatus)
const error = useStore($agentPluginsError)
const busyKey = useStore($agentPluginBusy)
const scope = profileParam(profile)
const label = scopeLabel ?? scope ?? t.skills.plugins.defaultProfile
useEffect(() => {
void loadAgentPlugins(requestGateway, scope)
}, [requestGateway, scope])
const visible = useMemo(() => rows.filter(isDesktopRelevantPlugin), [rows])
const packages = useMemo(
() => mergePluginPackages(Object.values(desktopRecords), agentRows.filter(isDesktopRelevantPlugin)),
[agentRows, desktopRecords]
)
// Deep-link from settings search / command palette (?plugin=<id or key>):
// rows render as soon as their store hydrates, so "ready" is simply
// target-present; the hook's polling rides out the async list loads.
useDeepLinkHighlight({
param: 'plugin',
ready: () => true,
elementId: pluginElementId
})
useDeepLinkHighlight({ param: 'plugin', ready: () => true, elementId: pluginElementId })
// Catalog picker viewport (persisted height, collapse toggle) — same pane
// store contract as EmbeddedHubPicker.
// Catalog picker viewport (persisted height, collapse toggle, top-edge sash).
const heightOverride = useStore($paneHeightOverride(CATALOG_PANE_ID))
const height = heightOverride ?? CATALOG_DEFAULT_PX
const open = height > CATALOG_COLLAPSED_PX
@@ -199,9 +399,6 @@ export const PluginsTab = memo(function PluginsTab({
setPickerMounted(true)
}
// Top-edge sash: dragging UP grows the catalog (shrinking the lists above,
// the flex-1 sibling); double-click resets. The cross-origin iframe gets
// pointer-events disabled for the duration or it swallows the pointermoves.
const startDrag = (event: ReactPointerEvent<HTMLDivElement>) => {
if (event.button !== 0) {
return
@@ -247,20 +444,14 @@ export const PluginsTab = memo(function PluginsTab({
return
}
// Already installed at (or past) this pin in the scoped profile →
// tell the user instead of re-running the install ceremony. Rows with
// update_available keep their explicit Update chip in the list above.
const existing = $agentPlugins.get().find(row => row.catalog_name === data.name || row.name === data.name)
if (existing && !existing.update_available) {
notify({ kind: 'success', message: t.skills.plugins.alreadyInstalled(String(data.name)) })
notify({ kind: 'success', message: p.alreadyInstalled(String(data.name)) })
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,
@@ -272,96 +463,124 @@ export const PluginsTab = memo(function PluginsTab({
window.addEventListener('message', onMessage)
return () => window.removeEventListener('message', onMessage)
}, [open, scope, t])
}, [open, p, scope])
const agentBusy = (row: AgentPluginRow) => busyKey === (row.key ?? row.name) || busyKey === row.name
return (
<div className="flex h-full min-h-0 flex-col">
<div className="min-h-32 flex-1 overflow-y-auto">
{/* The profile-scoped block is a visible container: the selector is
its header, so everything OUTSIDE the frame (desktop plugins) is
read as not-per-profile without a paragraph of explanation. */}
<section className="mx-3 mt-3 rounded-lg border border-(--ui-stroke-secondary)">
{scopeSelector && <div className="border-b border-(--ui-stroke-tertiary)">{scopeSelector}</div>}
<div className="flex items-center justify-between gap-3 px-3 pt-3 pb-1">
<div className="min-w-0">
<div className="text-[0.62rem] font-medium tracking-wide uppercase text-(--ui-text-quaternary)">
{p.agentTitle}
</div>
<p className="mt-0.5 text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
{p.agentBlurb}
</p>
</div>
{/* Header: what the two columns mean, and the controls that act on
the whole page (install, folder, rescan). */}
<div className="flex flex-wrap items-start justify-between gap-3 px-3 pt-3 pb-2">
<p className="min-w-0 flex-1 text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
{p.pageBlurb}
</p>
<div className="flex shrink-0 items-center gap-1">
<Button
className="shrink-0"
onClick={() => openPluginInstallRequest({ profile: scope, repo: '' })}
size="sm"
type="button"
variant="secondary"
>
{t.settings.plugins.installModal.installFromGit}
{d.installModal.installFromGit}
</Button>
<Tip label={d.openFolder}>
<Button
aria-label={d.openFolder}
onClick={() => void revealPluginsDir()}
size="icon"
type="button"
variant="ghost"
>
<FolderOpen className="size-3.5" />
</Button>
</Tip>
<Tip label={d.rescan}>
<Button
aria-label={d.rescan}
onClick={() => {
triggerHaptic('selection')
void rescanAll(requestGateway, scope)
}}
size="icon"
type="button"
variant="ghost"
>
<RefreshCw className="size-3.5" />
</Button>
</Tip>
</div>
{status === 'error' ? (
<PanelEmpty
action={
<Button onClick={() => void loadAgentPlugins(requestGateway, scope)} size="sm">
{t.skills.refresh}
</Button>
}
description={error ?? undefined}
icon="error"
title={p.loadFailed}
/>
) : visible.length === 0 && status === 'ready' ? (
<p className="px-3 py-3 text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
{p.empty} {p.emptyHint}
</p>
) : (
<div className="flex flex-col">
{visible.map(row => (
<AgentPluginListRow
busy={busyKey === (row.key ?? row.name) || busyKey === row.name}
key={row.key ?? row.name}
onToggle={enable => {
if (!row.key) {
return
}
void toggleAgentPlugin(requestGateway, row.key, enable, p.toggleFailed(row.name), scope)
}}
onUpdate={
row.update_available
? () => {
void updateAgentPlugin(requestGateway, row.name, p.updateFailed(row.name), scope).then(
applied => {
if (applied) {
notify({ kind: 'success', message: p.updated(row.name) })
}
}
)
}
: undefined
}
row={row}
/>
))}
</div>
)}
</section>
{/* App-level: a heavier rule than the row dividers marks the end of
the profile-scoped block above. */}
<div className="mt-4 mb-3">
<DesktopPluginsSection profile={scope} />
</div>
{status === 'error' ? (
<PanelEmpty
action={
<Button onClick={() => void loadAgentPlugins(requestGateway, scope)} size="sm">
{t.skills.refresh}
</Button>
}
description={error ?? undefined}
icon="error"
title={p.loadFailed}
/>
) : packages.length === 0 && status === 'ready' ? (
<p className="px-3 py-3 text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
{p.emptyAll} {p.emptyHint}
</p>
) : (
<div className="flex flex-col" role="table">
{/* Column header: the visible labels for the two control columns,
aligned with the cells below. The profile selector sits INSIDE
the Agent header so it visibly governs only that column. */}
<div
className="flex items-center gap-3 border-y border-(--ui-stroke-tertiary) bg-(--ui-bg-quinary) px-3 py-1.5 text-[0.68rem] text-(--ui-text-tertiary)"
role="row"
>
<div className="min-w-0 flex-1" role="columnheader" />
<div className={HALF_COL} role="columnheader">
<Monitor aria-hidden className="size-3.5 shrink-0" />
<Tip label={p.halfDesktopHint}>
<span className="font-medium">{p.halfDesktop}</span>
</Tip>
</div>
<div className={HALF_COL} role="columnheader">
<Package aria-hidden className="size-3.5 shrink-0" />
{scopeSelector ?? <span className="truncate font-medium">{p.halfAgentIn(label)}</span>}
</div>
</div>
{packages.map(pkg => (
<PackageRow
busy={pkg.agent ? agentBusy(pkg.agent) : false}
key={pkg.key}
onAgentToggle={(row, enable) => {
if (!row.key) {
return
}
void toggleAgentPlugin(requestGateway, row.key, enable, p.toggleFailed(row.name), scope)
}}
onAgentUpdate={row => {
void updateAgentPlugin(requestGateway, row.name, p.updateFailed(row.name), scope).then(applied => {
if (applied) {
notify({ kind: 'success', message: p.updated(row.name) })
void rescanAll(requestGateway, scope)
}
})
}}
pkg={pkg}
scope={scope}
scopeLabel={label}
/>
))}
</div>
)}
</div>
<section
className="relative flex min-h-9 flex-col overflow-hidden border-t border-(--ui-stroke-secondary)"
ref={sectionRef}
>
{/* Top-edge drag sash — pull the catalog up/down. */}
<div
className="group/catsash absolute inset-x-0 top-0 z-10 h-1 -translate-y-1/2 cursor-row-resize"
data-testid="plugin-catalog-sash"
@@ -24,6 +24,10 @@ export interface PluginRecord {
error?: string
/** Absolute plugin.js path (disk plugins) — powers "Reveal in Finder". */
file?: string
/** Agent package this is the desktop half of (unified agent+desktop packages). */
packageName?: string
/** Where that package came from (catalog sidecar or git remote), when known. */
packageOrigin?: { catalogName?: string; repo?: string; sha?: string }
}
// Explicit user enable/disable choices, id -> boolean. ABSENCE means "no
+33 -62
View File
@@ -16,7 +16,6 @@ vi.mock('@/hermes', async importActual => ({
}))
const desktopPluginsRoot = vi.fn<() => Promise<string>>()
const agentPluginsRoot = vi.fn<() => Promise<string>>()
const readDir = vi.fn<(path: string) => Promise<HermesReadDirResult>>()
const readFileText = vi.fn<(path: string) => Promise<{ text: string; truncated?: boolean }>>()
const readPluginSource = vi.fn<(path: string) => Promise<{ text: string; truncated?: boolean }>>()
@@ -27,7 +26,6 @@ const onPreviewFileChanged = vi.fn()
beforeEach(() => {
desktopPluginsRoot.mockReset()
agentPluginsRoot.mockReset()
readDir.mockReset()
readFileText.mockReset()
readPluginSource.mockReset()
@@ -38,7 +36,6 @@ beforeEach(() => {
onPreviewFileChanged.mockReset()
getStatus.mockClear()
;(window as unknown as { hermesDesktop: unknown }).hermesDesktop = {
agentPluginsRoot,
desktopPluginsRoot,
onPreviewFileChanged,
readDir,
@@ -56,14 +53,15 @@ afterEach(() => {
describe('scanDiskPlugins (#66899)', () => {
it('scans the Electron-resolved local roots, never the backend hermes_home', async () => {
desktopPluginsRoot.mockResolvedValue('/local/.hermes/desktop-plugins')
agentPluginsRoot.mockResolvedValue('/local/.hermes/plugins')
readDir.mockResolvedValue({ entries: [] })
await discoverRuntimePlugins()
expect(desktopPluginsRoot).toHaveBeenCalled()
expect(readDir).toHaveBeenCalledWith('/local/.hermes/desktop-plugins')
expect(readDir).toHaveBeenCalledWith('/local/.hermes/plugins')
// Unified halves are COPIED into the app root by Electron; the renderer
// never scans the (profile-shaped) agent-plugins root itself.
expect(readDir).not.toHaveBeenCalledWith('/local/.hermes/plugins')
// The remote backend's hermes_home must never feed the local plugin scan.
expect(getStatus).not.toHaveBeenCalled()
expect(readDir).not.toHaveBeenCalledWith('/remote/box/.hermes/desktop-plugins')
@@ -71,24 +69,24 @@ describe('scanDiskPlugins (#66899)', () => {
it('no-ops when the resolvers yield no local root', async () => {
desktopPluginsRoot.mockResolvedValue('')
agentPluginsRoot.mockResolvedValue('')
await discoverRuntimePlugins()
expect(readDir).not.toHaveBeenCalled()
})
it('treats a package without a Desktop half as metadata, not a throwing file read', async () => {
it('treats a folder without plugin.js as metadata, not a throwing file read', async () => {
desktopPluginsRoot.mockResolvedValue('/local/.hermes/desktop-plugins')
agentPluginsRoot.mockResolvedValue('/local/.hermes/plugins')
readDir.mockImplementation(async dir => {
if (dir === '/local/.hermes/plugins') {
return { entries: [{ isDirectory: true, name: 'my-feature', path: '/local/.hermes/plugins/my-feature' }] }
if (dir === '/local/.hermes/desktop-plugins') {
return {
entries: [{ isDirectory: true, name: 'my-feature', path: '/local/.hermes/desktop-plugins/my-feature' }]
}
}
if (dir === '/local/.hermes/plugins/my-feature') {
if (dir === '/local/.hermes/desktop-plugins/my-feature') {
return {
entries: [{ isDirectory: false, name: 'plugin.yaml', path: '/local/.hermes/plugins/my-feature/plugin.yaml' }]
entries: [{ isDirectory: false, name: 'README.md', path: '/local/.hermes/desktop-plugins/my-feature/README.md' }]
}
}
@@ -97,14 +95,12 @@ describe('scanDiskPlugins (#66899)', () => {
await discoverRuntimePlugins()
expect(readDir).toHaveBeenCalledWith('/local/.hermes/plugins/my-feature')
expect(readDir).not.toHaveBeenCalledWith('/local/.hermes/plugins/my-feature/desktop')
expect(readDir).toHaveBeenCalledWith('/local/.hermes/desktop-plugins/my-feature')
expect(readFileText).not.toHaveBeenCalled()
})
it('a DIRECTORY named plugin.js is not a plugin entry (metadata walk rejects it)', async () => {
desktopPluginsRoot.mockResolvedValue('/local/.hermes/desktop-plugins')
agentPluginsRoot.mockResolvedValue('')
readDir.mockImplementation(async dir => {
if (dir === '/local/.hermes/desktop-plugins') {
return { entries: [{ isDirectory: true, name: 'odd', path: '/local/.hermes/desktop-plugins/odd' }] }
@@ -126,44 +122,23 @@ describe('scanDiskPlugins (#66899)', () => {
expect($pluginRecords.get().odd).toBeUndefined()
})
it('still scans the standalone root when agentPluginsRoot is absent (older shell)', async () => {
delete (window.hermesDesktop as unknown as { agentPluginsRoot?: unknown }).agentPluginsRoot
it('loads a unified desktop half (app-root copy + package marker) OPT-IN and tags it with its package', async () => {
desktopPluginsRoot.mockResolvedValue('/local/.hermes/desktop-plugins')
readDir.mockResolvedValue({ entries: [] })
await discoverRuntimePlugins()
expect(readDir).toHaveBeenCalledWith('/local/.hermes/desktop-plugins')
expect(readDir).toHaveBeenCalledTimes(1)
})
it('loads a unified desktop half OPT-IN: inventoried but not activated by default', async () => {
desktopPluginsRoot.mockResolvedValue('/local/.hermes/desktop-plugins')
agentPluginsRoot.mockResolvedValue('/local/.hermes/plugins')
let desktopEntryPresent = true
const root = '/local/.hermes/desktop-plugins'
readDir.mockImplementation(async dir => {
if (dir === '/local/.hermes/plugins') {
return { entries: [{ isDirectory: true, name: 'uni', path: '/local/.hermes/plugins/uni' }] }
if (dir === root) {
return { entries: desktopEntryPresent ? [{ isDirectory: true, name: 'uni', path: `${root}/uni` }] : [] }
}
if (dir === '/local/.hermes/plugins/uni') {
if (dir === `${root}/uni`) {
return {
entries: [{ isDirectory: true, name: 'desktop', path: '/local/.hermes/plugins/uni/desktop' }]
}
}
if (dir === '/local/.hermes/plugins/uni/desktop') {
return {
entries: desktopEntryPresent
? [
{
isDirectory: false,
name: 'plugin.js',
path: '/local/.hermes/plugins/uni/desktop/plugin.js'
}
]
: []
entries: [
{ isDirectory: false, name: '.hermes-package.json', path: `${root}/uni/.hermes-package.json` },
{ isDirectory: false, name: 'plugin.js', path: `${root}/uni/plugin.js` }
]
}
}
@@ -173,9 +148,11 @@ describe('scanDiskPlugins (#66899)', () => {
const register = vi.fn()
;(globalThis as unknown as { __uniRegister: unknown }).__uniRegister = register
readFileText.mockResolvedValue({
text: 'export default { id: "uni", register: globalThis.__uniRegister }'
})
readFileText.mockImplementation(async file =>
file.endsWith('.hermes-package.json')
? { text: JSON.stringify({ package: 'uni-pkg', source: '/x/plugins/uni-pkg/desktop', sourceMtimeMs: 1 }) }
: { text: 'export default { id: "uni", register: globalThis.__uniRegister }' }
)
watchPreviewFile.mockResolvedValue({ id: 'w-uni' })
// The loader evaluates plugins via blob-URL import(), which vite's module
@@ -203,9 +180,9 @@ describe('scanDiskPlugins (#66899)', () => {
try {
await discoverRuntimePlugins()
// Inventoried for Capabilities → Plugins, but the root's opt-in posture wins:
// ~/.hermes/plugins stays installed-but-inert until the user toggles it.
expect($pluginRecords.get().uni).toMatchObject({ kind: 'disk', status: 'disabled' })
// Inventoried for Capabilities → Plugins with its package identity, but
// the unified posture wins: installed-but-inert until the user toggles.
expect($pluginRecords.get().uni).toMatchObject({ kind: 'disk', status: 'disabled', packageName: 'uni-pkg' })
expect(register).not.toHaveBeenCalled()
// The user's explicit enable still activates it.
@@ -213,9 +190,8 @@ describe('scanDiskPlugins (#66899)', () => {
expect(register).toHaveBeenCalledTimes(1)
expect($pluginRecords.get().uni.status).toBe('loaded')
// Removing only desktop/plugin.js (while the Python package folder
// remains) unloads the previous Desktop registration instead of leaving
// a live ghost behind.
// Electron removing the copy (package uninstalled) unloads the previous
// Desktop registration instead of leaving a live ghost behind.
desktopEntryPresent = false
await discoverRuntimePlugins()
expect($pluginRecords.get().uni).toBeUndefined()
@@ -230,18 +206,16 @@ describe('scanDiskPlugins (#66899)', () => {
})
describe('watchRuntimePlugins dir watch (#66899)', () => {
it('watches both Electron-resolved local roots, never the backend hermes_home', async () => {
it('watches the Electron-resolved app root, never the backend hermes_home', async () => {
desktopPluginsRoot.mockResolvedValue('/local/.hermes/desktop-plugins')
agentPluginsRoot.mockResolvedValue('/local/.hermes/plugins')
readDir.mockResolvedValue({ entries: [] })
watchDirectory.mockResolvedValue({ id: 'watch-1' })
watchRuntimePlugins()
// Drain the async scan + startDirWatches chains.
await vi.waitFor(() => expect(watchDirectory).toHaveBeenCalledTimes(2))
await vi.waitFor(() => expect(watchDirectory).toHaveBeenCalledTimes(1))
expect(watchDirectory).toHaveBeenCalledWith('/local/.hermes/desktop-plugins')
expect(watchDirectory).toHaveBeenCalledWith('/local/.hermes/plugins')
expect(watchDirectory).not.toHaveBeenCalledWith('/remote/box/.hermes/desktop-plugins')
expect(getStatus).not.toHaveBeenCalled()
})
@@ -296,7 +270,6 @@ describe('plugin source reads (512 KiB preview-cap bug)', () => {
it('loads the full source via readPluginSource when the shell offers it', async () => {
;(window.hermesDesktop as unknown as { readPluginSource: unknown }).readPluginSource = readPluginSource
desktopPluginsRoot.mockResolvedValue('/local/.hermes/desktop-plugins')
agentPluginsRoot.mockResolvedValue('')
standaloneRootWith('big')
// The preview read would truncate this source — it must never be used.
readFileText.mockResolvedValue({ text: '// first 512 KiB only', truncated: true })
@@ -326,7 +299,6 @@ describe('plugin source reads (512 KiB preview-cap bug)', () => {
it('older shell without readPluginSource: a truncated preview read fails LOUDLY, never evaluates', async () => {
desktopPluginsRoot.mockResolvedValue('/local/.hermes/desktop-plugins')
agentPluginsRoot.mockResolvedValue('')
standaloneRootWith('huge')
// 512 KiB window of a larger file — parses fine, but is NOT the plugin.
readFileText.mockResolvedValue({
@@ -354,7 +326,6 @@ describe('plugin source reads (512 KiB preview-cap bug)', () => {
it('older shell, small plugin (not truncated): still loads through readFileText', async () => {
desktopPluginsRoot.mockResolvedValue('/local/.hermes/desktop-plugins')
agentPluginsRoot.mockResolvedValue('')
standaloneRootWith('small')
const register = vi.fn()
+73 -40
View File
@@ -46,6 +46,9 @@ interface LoadOptions {
integrity?: string
/** Inventory bucket; the disk door is the default runtime source. */
kind?: PluginKind
/** Agent package whose desktop half this is (unified packages). */
packageName?: string
packageOrigin?: PackageMarker['origin']
}
/** Live runtime plugins: id -> disposers (unload/reload support). */
@@ -169,7 +172,9 @@ export async function loadRuntimePlugin(
name: plugin.name ?? plugin.id,
description: plugin.description,
kind: options.kind ?? 'disk',
file: options.file
file: options.file,
packageName: options.packageName,
packageOrigin: options.packageOrigin
}
const activate = () => {
@@ -200,6 +205,8 @@ export async function loadRuntimePlugin(
name: origin,
kind: options.kind ?? 'disk',
file: options.file,
packageName: options.packageName,
packageOrigin: options.packageOrigin,
status: 'error',
error: error instanceof Error ? error.message : String(error)
})
@@ -209,13 +216,13 @@ export async function loadRuntimePlugin(
}
// ---------------------------------------------------------------------------
// The on-disk plugin door — TWO roots, one pipeline:
// - `<hermes home>/desktop-plugins/<name>/plugin.js` — the standalone door
// (agent- or user-written desktop-only plugins);
// - `<hermes home>/plugins/<name>/desktop/plugin.js` — the desktop HALF of a
// unified agent-plugin package: the same installed folder that carries the
// Python plugin (plugin.yaml / plugin.json) ships its desktop UI beside it,
// so one feature is ONE install instead of two co-dependent plugins.
// The on-disk plugin door — ONE app-level root, `<hermes home>/desktop-plugins/`:
// - `<id>/plugin.js` — a standalone desktop plugin (agent- or user-written);
// - `<package>/plugin.js` + `.hermes-package.json` — the desktop HALF of a
// unified agent+desktop package, COPIED here by Electron from the package's
// `plugins/<package>/desktop/` folder (electron/desktop-plugins-root.ts).
// The agent half stays in its profile; the desktop half lives with the app,
// so it neither appears nor disappears when the active profile changes.
// SELF-MAINTAINING — no reload ceremony:
// - each plugin.js is fs-watched (the preview watcher IPC, debounced in
// main): saving the file hot-reloads the plugin in place;
@@ -230,49 +237,68 @@ export async function loadRuntimePlugin(
const DISK_POLL_MS = 5_000
interface DiskRoot {
/** Root-level enable posture, forwarded to the loader (see LoadOptions). */
defaultEnabled?: boolean
dir: string
/** Path segments below each scanned package folder. Discovery walks
* directory metadata to this file instead of throwing a content read for
* every ordinary package that has no Desktop half. */
/** Path segments below each scanned folder to the entry file. */
entrySegments: readonly string[]
}
/** Both scan roots, resolved fresh each pass (Electron-local, never the
* backend's hermes_home — #66899). `agentPluginsRoot` is optional: older
* shells predate it and the unified-package half simply doesn't scan. */
/** The app-level root, resolved fresh each pass (Electron-local, never the
* backend's hermes_home — #66899). Resolving it also runs Electron's
* reconcile, so unified packages' desktop halves are current before we scan. */
async function diskRoots(): Promise<DiskRoot[]> {
const desktop = window.hermesDesktop
const root = await window.hermesDesktop?.desktopPluginsRoot?.()
if (!desktop) {
return []
return root ? [{ dir: root, entrySegments: ['plugin.js'] }] : []
}
/** Marker Electron writes beside a materialized unified-package half. Its
* presence means: opt-in posture (the Python half is installed-but-inert
* until allowlisted — GHSA-mcfc-hp25-cjv7 — so the desktop half matches), and
* the record carries the package name so the Plugins page pairs it with the
* agent row. */
const PACKAGE_MARKER = '.hermes-package.json'
interface PackageMarker {
origin?: { catalogName?: string; repo?: string; sha?: string }
package: string
}
async function readPackageMarker(desktop: Window['hermesDesktop'], folder: string): Promise<null | PackageMarker> {
try {
const { entries } = await desktop.readDir(folder)
const marker = entries.find(entry => entry.name === PACKAGE_MARKER && !entry.isDirectory)
if (!marker) {
return null
}
const parsed = JSON.parse((await desktop.readFileText(marker.path)).text) as {
catalogName?: string
package?: string
repo?: string
sha?: string
}
if (!parsed.package) {
return null
}
return {
origin: parsed.repo ? { catalogName: parsed.catalogName, repo: parsed.repo, sha: parsed.sha } : undefined,
package: parsed.package
}
} catch {
return null
}
const roots: DiskRoot[] = []
const standalone = await desktop.desktopPluginsRoot?.()
if (standalone) {
roots.push({ dir: standalone, entrySegments: ['plugin.js'] })
}
const unified = await desktop.agentPluginsRoot?.()
if (unified) {
// Opt-in by default: `~/.hermes/plugins` is installed-but-inert until the
// user allowlists the Python half (plugins.enabled), so the desktop half
// matches that posture — inventoried in Capabilities → Plugins, off until
// toggled. The standalone desktop-plugins door keeps its default-on trust.
roots.push({ defaultEnabled: false, dir: unified, entrySegments: ['desktop', 'plugin.js'] })
}
return roots
}
interface DiskPlugin {
/** Root posture, forwarded on every (re)load of this entry. */
defaultEnabled?: boolean
file: string
/** Agent package this folder is the desktop half of (unified packages). */
packageName?: string
packageOrigin?: PackageMarker['origin']
/** Loaded plugin id (null while broken — kept so a fixing save reloads). */
id: null | string
/** Origin label (folder name) — the toast/inventory name for load errors. */
@@ -336,7 +362,9 @@ async function loadDiskPlugin(entry: DiskPlugin): Promise<boolean> {
const id = await loadRuntimePlugin(text, entry.origin, {
defaultEnabled: entry.defaultEnabled,
file: entry.file
file: entry.file,
packageName: entry.packageName,
packageOrigin: entry.packageOrigin
})
// A hot-edit that changes `plugin.id`: loadRuntimePlugin only disposes the
@@ -460,11 +488,16 @@ async function scanDiskPlugins(): Promise<void> {
continue
}
const marker = await readPackageMarker(desktop, dir.path)
const record: DiskPlugin = {
defaultEnabled: root.defaultEnabled,
// A unified package's desktop half ships opt-in, like its agent half.
defaultEnabled: marker ? false : undefined,
file,
id: null,
origin: dir.name,
packageName: marker?.package,
packageOrigin: marker?.origin,
watchId: null
}
+2 -5
View File
@@ -379,11 +379,8 @@ declare global {
desktopPluginsRoot?: () => Promise<string>
/** LOCAL `<HERMES_HOME>/logs` (profile-aware) — error card "Open Logs". */
logsRoot?: () => Promise<string>
// Local AGENT-plugin root (<HERMES_HOME>/plugins), same Electron-local
// resolution. The disk door also scans it for `<name>/desktop/plugin.js`
// so one agent-plugin package can ship a desktop UI half. Optional:
// older Electron shells predate it — the scanner then skips this root.
agentPluginsRoot?: () => Promise<string>
/** Re-copy unified packages' desktop halves into the app-level root; returns touched paths. */
reconcileDesktopPlugins?: () => Promise<string[]>
// Rename a file/folder in place (new base name, same parent dir).
renamePath?: (path: string, newName: string) => Promise<{ path: string }>
// Write a small UTF-8 text file (hardened path, parent must exist).
+21 -1
View File
@@ -467,7 +467,7 @@ export const en: Translations = {
includesHeading: 'This package includes',
agentLabel: 'Agent plugin',
desktopLabel: 'Desktop UI',
agentTargetLocal: profile => `Installs into the ${profile} backend (~/.hermes/plugins/)`,
agentTargetLocal: (profile, dir) => `Installs into the ${profile} backend (${dir})`,
agentTargetRemote: profile => `Installs into the connected ${profile} backend`,
catalogPinned: (name, sha) =>
`Hermes catalog entry "${name}" — the agent component installs at the reviewed pin${sha ? ` ${sha}` : ''}, not the branch tip.`,
@@ -479,6 +479,7 @@ export const en: Translations = {
missingEnvAction: 'Set it up',
alreadyInstalled: (name: string) => `${name} is already installed.`,
desktopTarget: "Installs into this app's local desktop-plugins folder",
desktopTargetFromPackage: 'Loaded into this app from the package above — same for every profile',
desktopOnlyNote: 'Desktop-only packages do not install a backend agent plugin.',
insecureWarning: 'This URL uses an insecure or local scheme. Prefer https:// or git@ for production installs.',
securityHeading: 'Before you install',
@@ -1512,6 +1513,25 @@ export const en: Translations = {
agentTitle: 'Agent plugins',
agentBlurb:
'Extend the agent for the selected profile — tools, hooks, providers. Take effect after a gateway restart.',
pageBlurb:
'One row per plugin. A plugin can extend this app, the agent, or both — each half has its own switch.',
halfDesktop: 'Desktop',
halfDesktopHint: 'this app, same for every profile',
halfAgent: 'Agent',
halfAgentIn: (profile: string) => `Agent in ${profile}`,
defaultProfile: 'Hermes (default)',
kindAgent: 'Agent',
kindDesktop: 'Desktop',
kindBoth: 'Agent + Desktop',
installAgentHere: 'Install here',
installAgentHereTip: (profile: string) =>
`The desktop half is loaded in this app, but the agent half is not installed in ${profile}. Install it there.`,
installAgentHereNoOrigin:
'The agent half is not installed in this profile, and this package was copied in by hand (no catalog entry or git remote), so it cannot be installed from here. Copy its folder into the profile or reinstall from Git.',
desktopHalfPending: 'copying…',
desktopHalfPendingTip:
'This package ships a desktop half that has not been copied into the app yet. Use Rescan, or restart the app.',
emptyAll: 'No plugins yet.',
empty: 'No agent plugins installed for this profile.',
emptyHint: 'Browse the catalog below and install a reviewed plugin with one click.',
loadFailed: 'Could not load agent plugins',
+1 -1
View File
@@ -443,7 +443,7 @@ export const ru = defineLocale({
includesHeading: 'Состав пакета',
agentLabel: 'Плагин агента',
desktopLabel: 'UI приложения',
agentTargetLocal: profile => `Устанавливается в локальный бэкенд ${profile} (~/.hermes/plugins/)`,
agentTargetLocal: (profile, dir) => `Устанавливается в локальный бэкенд ${profile} (${dir})`,
agentTargetRemote: profile => `Устанавливается в подключённый бэкенд ${profile}`,
desktopTarget: 'Устанавливается в локальную папку desktop-plugins этого приложения',
desktopOnlyNote: 'Пакеты только для приложения не устанавливают плагин агента.',
+17 -1
View File
@@ -407,7 +407,7 @@ export interface Translations {
includesHeading: string
agentLabel: string
desktopLabel: string
agentTargetLocal: (profile: string) => string
agentTargetLocal: (profile: string, dir: string) => string
agentTargetRemote: (profile: string) => string
catalogPinned: (name: string, sha: string) => string
reviewedHeading: string
@@ -417,6 +417,7 @@ export interface Translations {
missingEnvAction: string
alreadyInstalled: (name: string) => string
desktopTarget: string
desktopTargetFromPackage: string
desktopOnlyNote: string
insecureWarning: string
securityHeading: string
@@ -1323,6 +1324,21 @@ export interface Translations {
plugins: {
agentTitle: string
agentBlurb: string
pageBlurb: string
halfDesktop: string
halfDesktopHint: string
halfAgent: string
halfAgentIn: (profile: string) => string
defaultProfile: string
kindAgent: string
kindDesktop: string
kindBoth: string
installAgentHere: string
installAgentHereTip: (profile: string) => string
installAgentHereNoOrigin: string
desktopHalfPending: string
desktopHalfPendingTip: string
emptyAll: string
empty: string
emptyHint: string
loadFailed: string
+17 -1
View File
@@ -453,7 +453,7 @@ export const zh: Translations = {
includesHeading: '此包包含',
agentLabel: '智能体插件',
desktopLabel: '桌面 UI',
agentTargetLocal: profile => `安装到 ${profile} 后端(~/.hermes/plugins/)`,
agentTargetLocal: (profile, dir) => `安装到 ${profile} 后端(${dir})`,
agentTargetRemote: profile => `安装到已连接的 ${profile} 后端`,
catalogPinned: (name, sha) =>
`Hermes 目录条目「${name}」— agent 部分将安装在经过审核的固定提交${sha ? ` ${sha}` : ''},而不是分支最新代码。`,
@@ -464,6 +464,7 @@ export const zh: Translations = {
missingEnvAction: '去设置',
alreadyInstalled: (name: string) => `${name} 已安装。`,
desktopTarget: '安装到此应用的本地 desktop-plugins 文件夹',
desktopTargetFromPackage: '从上方的包加载到本应用 — 所有配置相同',
desktopOnlyNote: '仅桌面包不会安装后端智能体插件。',
insecureWarning: '此 URL 使用了不安全的本地 scheme。生产环境请优先使用 https:// 或 git@。',
securityHeading: '安装前须知',
@@ -1682,6 +1683,21 @@ export const zh: Translations = {
plugins: {
agentTitle: 'Agent 插件',
agentBlurb: '为所选配置扩展 agent — 工具、钩子、模型提供方。重启网关后生效。',
pageBlurb: '每个插件一行。插件可以扩展本应用、agent,或两者 — 每一半都有自己的开关。',
halfDesktop: '桌面',
halfDesktopHint: '本应用,所有配置相同',
halfAgent: 'Agent',
halfAgentIn: (profile: string) => `${profile} 中的 Agent`,
defaultProfile: 'Hermes(默认)',
kindAgent: 'Agent',
kindDesktop: '桌面',
kindBoth: 'Agent + 桌面',
installAgentHere: '在此安装',
installAgentHereTip: (profile: string) => `桌面部分已加载到本应用,但 agent 部分尚未安装到 ${profile}。在那里安装它。`,
installAgentHereNoOrigin: '此配置未安装 agent 部分,且该包是手动复制的(无目录条目或 git 远程),无法从此处安装。请将其文件夹复制到该配置或从 Git 重新安装。',
desktopHalfPending: '复制中…',
desktopHalfPendingTip: '此包附带的桌面部分尚未复制到应用中。请重新扫描或重启应用。',
emptyAll: '还没有插件。',
empty: '此配置尚未安装任何 agent 插件。',
emptyHint: '在下方目录中浏览,一键安装经过审核的插件。',
loadFailed: '无法加载 agent 插件',
+4
View File
@@ -36,6 +36,10 @@ export interface AgentPluginRow {
update_available?: boolean
/** Full commit SHA a `--ref` install is pinned to (custom sources; refuses `update`). */
pinned_sha?: string
/** The package folder also ships `desktop/plugin.js` (unified agent+desktop package). */
has_desktop_half?: boolean
/** Absolute install dir on the backend (informational). */
install_dir?: string
}
/** A `--ref` pin is a full 40-hex commit SHA; branches and tags are refused server-side. */
@@ -15,7 +15,10 @@ There are TWO on-disk doors, same contract and hot reload:
unified agent-plugin package: the same folder that carries the Python
plugin (`plugin.yaml`) and its `dashboard/plugin_api.py` backend ships its
desktop UI beside them, so one feature installs/uninstalls as one folder.
This half is OPT-IN: it inventories in Settings → Plugins but stays off
The Electron shell copies that half into `desktop-plugins/<id>/` (with a
`.hermes-package.json` marker) — the ONLY root the renderer loads from — so
the pane is app-level and does not come and go with the selected profile.
This half is OPT-IN: it inventories in Capabilities → Plugins but stays off
until the user toggles it (matching the Python half's `plugins.enabled`
gate). Tell the user to flip it on after installing — don't debug a
"plugin not appearing" report before checking that toggle.
@@ -122,3 +122,26 @@ def test_plugins_manage_update_requires_catalog_sidecar(tmp_path, monkeypatch):
assert "error" in resp
assert "not a catalog install" in resp["error"]["message"]
def test_plugins_manage_list_reports_desktop_half(tmp_path):
"""A unified package (plugin.yaml + desktop/plugin.js) is reported with ``has_desktop_half`` so the
desktop app can pair its app-level copy of that half with the agent row — one package, ONE row."""
unified = tmp_path / "media"
(unified / "desktop").mkdir(parents=True)
(unified / "desktop" / "plugin.js").write_text("export default {}")
agent_only = tmp_path / "snap"
agent_only.mkdir()
rows = [
("media", "1.0", "Media", "user", unified, "media"),
("snap", "1.0", "Snap", "user", agent_only, "snap"),
]
with patch("hermes_cli.plugins_cmd._discover_all_plugins", return_value=rows), \
patch("hermes_cli.plugins_cmd._get_enabled_set", return_value=set()), \
patch("hermes_cli.plugins_cmd._get_disabled_set", return_value=set()), \
patch("hermes_cli.plugins_cmd_catalog.catalog_pins", return_value={}):
resp = server.handle_request({"id": "1", "method": "plugins.manage", "params": {"action": "list"}})
by_name = {r["name"]: r for r in resp["result"]["plugins"]}
assert by_name["media"]["has_desktop_half"] is True
assert by_name["snap"]["has_desktop_half"] is False
+6
View File
@@ -6,6 +6,7 @@ Helper names must not collide with server.py's own (``_cmd_`` / ``_toolset_`` /
"""
import sys
from pathlib import Path
from .method_ctx import HandlerRegistry, bind_module
@@ -1340,9 +1341,14 @@ def _plugin_rows() -> list[dict]:
if status == "not enabled" and source == "bundled" and pc._bundled_default_on(_dir):
status = "enabled"
# key = canonical registry key (names collide across category dirs); portable = Agent Plugins v1.
# ``has_desktop_half``: the package also ships a Desktop UI half (``desktop/plugin.js``). The
# desktop app pairs its app-level copy of that half with this row so one package is ONE row.
_dir_path = Path(str(_dir)) if _dir else None
out.append({
"name": name, "key": key, "version": str(version or ""), "description": desc or "",
"source": source, "status": status, "portable": pc._is_portable_plugin_dir(_dir),
"install_dir": str(_dir_path) if _dir_path else "",
"has_desktop_half": bool(_dir_path and (_dir_path / "desktop" / "plugin.js").is_file()),
**cat.catalog_row_fields(_dir, pins),
**({"pinned_sha": sha} if (sha := pc.pinned_revision(name, ref_pins)) else {})})
return out
@@ -696,10 +696,12 @@ construction**.
### One package, both SDKs {#one-package-both-sdks}
A feature that needs a desktop UI **and** agent-side code (a Python plugin, its
backend routes, skills) doesn't have to ship as two co-dependent installs. The
desktop app also scans `$HERMES_HOME/plugins/<id>/` — the regular agent-plugin
root — for a `desktop/plugin.js`, and loads it through the exact same pipeline
as the standalone disk door (hot reload included):
backend routes, skills) doesn't have to ship as two co-dependent installs. Put a
`desktop/plugin.js` inside the agent package. When the package lands in any
local `plugins/` root (default home or a profile), the Electron main process
copies that half into `$HERMES_HOME/desktop-plugins/<id>/` beside a
`.hermes-package.json` marker, and the renderer loads it through the exact same
pipeline as the standalone disk door (hot reload included):
```
~/.hermes/plugins/<id>/ # ONE installable folder
@@ -714,7 +716,15 @@ as the standalone disk door (hot reload included):
The `desktop/plugin.js` half is an ordinary disk plugin — same contract, same
imports, same `ctx.rest('/…')` reaching the `plugin_api.py` sitting beside it.
Installing, sharing, or removing the feature is one folder.
Installing, sharing, or removing the feature is one folder: the app-root copy
is refreshed when the source `plugin.js` changes (`hermes plugins update`, or
**Rescan**) and removed when the package folder disappears. The copy is what
makes the desktop half **app-level**: it exists once, however many profiles
carry the package, and it never appears or disappears when the user switches
the Capabilities profile selector. The renderer never scans `plugins/` itself.
The marker records the package name and its origin (catalog sidecar or git
remote), which is what the **Install here** button on the Plugins page uses to
install the agent half into another profile.
Two enable switches still apply, on purpose, and both default to **off**: the
desktop half ships opt-in — it inventories in **Capabilities → Plugins** but stays
@@ -725,10 +735,11 @@ says otherwise. The desktop half degrades gracefully when the backend half is
off — `ctx.rest` returns errors, not crashes.
:::note
The scan is local to the machine the desktop app runs on. Against a remote
The copy is local to the machine the desktop app runs on. Against a remote
backend, the remote box's `~/.hermes/plugins` is not reachable as a filesystem —
only locally installed packages contribute a desktop half (same rule as the
standalone door).
only locally installed packages contribute a desktop half this way. For a
remote backend the install dialog clones the desktop half separately into
`desktop-plugins/`, the same as a desktop-only repo.
:::
### Distributing with an install link {#install-link}
+1 -1
View File
@@ -257,7 +257,7 @@ See [Connecting Desktop to Many Hermes Instances](./multi-connection-desktop.md)
## Turning it off
Bot Mode is a bundled desktop plugin. Flip it off in **Capabilities → Plugins → Desktop plugins → Bots** — the roster, the Routines pane, and the composer middleware unregister live, no restart needed. Your profiles, sessions, and cron jobs are untouched either way; Bot Mode never owns your data, it only renders it.
Bot Mode is a bundled desktop plugin. Flip its **Desktop** switch off in **Capabilities → Plugins → Bots** — the roster, the Routines pane, and the composer middleware unregister live, no restart needed. Your profiles, sessions, and cron jobs are untouched either way; Bot Mode never owns your data, it only renders it.
There is also a preference to hide the canonical Bot Chats from the regular sidebar session list, so they only appear inside the Bots pane. (This uses the core hidden-session flag; on older gateways the chats simply stay visible.)
+24 -16
View File
@@ -260,7 +260,7 @@ and the eye shows a dot when a hidden bot has unread activity. Hidden
state is stored in the bot's profile, so it follows the bot across
machines.
Don't want it? Flip it off in **Capabilities → Plugins → Desktop plugins → Bots** — the roster,
Don't want it? Flip its **Desktop** switch off in **Capabilities → Plugins → Bots** — the roster,
routines pane, and composer middleware unregister live, no restart needed.
Full guide — creating agents (including the multi-machine **Create on**
@@ -451,22 +451,30 @@ See [Desktop Plugin SDK](../developer-guide/desktop-plugin-sdk.md) for the full
reference. (This is separate from the [web dashboard plugin system](./features/extending-the-dashboard.md).)
**Capabilities → Plugins** is the one place for everything that extends
Hermes, in two sections on one page:
Hermes: **one row per plugin**, with two switch columns.
- **Agent plugins** — backend (agent-side) [plugins](./features/plugins.md)
you installed for the selected profile: user, git, project, pip, and
portable installs, with enable/disable toggles and an **Update** chip when a
catalog pin moved. The page's profile selector picks which agent you are
configuring (the backend `plugins.manage` RPC takes a `profile` parameter).
Repo-bundled built-ins (platform adapters, provider plugins) are not listed:
they ship enabled and are configured from their own surfaces.
- **Desktop plugins** — extensions loaded into this app. They are not tied to
a profile: one install under `~/.hermes/desktop-plugins/` serves every
profile, gateway, or remote machine the window connects to, which is why the
section sits outside the profile selector. Toggles apply live; the desktop
half of a bundled agent+desktop package shows an **agent half missing here**
chip when the selected profile's backend lacks its agent half, with a
one-click repair. Optional extras such as the
- A plugin can extend **this app**, **the agent**, or **both** — the badge on
each row says which, inferred from what the package contains (`plugin.yaml`
→ agent half, `plugin.js` → desktop half). A plugin with both halves is one
row, never two.
- **Desktop column** — the half loaded into this app. It is app-level: the
same switch, the same value, whichever profile, gateway, or remote machine
the window is looking at. Desktop code loads from exactly one place,
`~/.hermes/desktop-plugins/`; the desktop half of a unified agent+desktop
package is copied there by the app when the package is installed (and
follows its updates and uninstall), so switching profiles never loads,
unloads, or re-scopes a pane. Toggles apply live.
- **Agent column** — the half installed in the selected profile's backend
([agent plugins](./features/plugins.md): user, git, project, pip and
portable installs), with an **Update** chip when a catalog pin moved. The
profile selector lives in this column's header because it governs only
this column; with a single profile there is no selector at all.
Repo-bundled built-ins (platform adapters, provider plugins) are not
listed: they ship enabled and are configured from their own surfaces.
- A half the plugin does not ship shows a dash. A desktop half whose agent
half is **not** installed in the selected profile shows **Install here**,
which pre-fills the install dialog from the package's origin (catalog entry
or git remote) for that profile only. Optional extras such as the
[Accent Picker](https://github.com/NousResearch/hermes-desktop-accent-picker)
install from their own repos via **Install from Git**.