diff --git a/apps/desktop/electron/desktop-plugins-root.test.ts b/apps/desktop/electron/desktop-plugins-root.test.ts index 4358a08d1d..6427806f26 100644 --- a/apps/desktop/electron/desktop-plugins-root.test.ts +++ b/apps/desktop/electron/desktop-plugins-root.test.ts @@ -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') }) }) diff --git a/apps/desktop/electron/desktop-plugins-root.ts b/apps/desktop/electron/desktop-plugins-root.ts index be41eaa747..1932a0cf9b 100644 --- a/apps/desktop/electron/desktop-plugins-root.ts +++ b/apps/desktop/electron/desktop-plugins-root.ts @@ -1,15 +1,71 @@ -// The standalone desktop-plugin root (`/desktop-plugins`) and its -// one-time migration out of the per-profile folders earlier builds used. +// The standalone desktop-plugin root (`/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//desktop-plugins/` — earlier builds scoped the +// standalone root per profile. +// 2. `plugins//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 `//` 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/` 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> { + 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 { try { @@ -22,45 +78,37 @@ export async function ensureDir(dir: string): Promise { return dir } +async function listDirs(dir: string): Promise { + 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 { + const profiles = await listDirs(path.join(hermesHome, 'profiles')) + + return [hermesHome, ...profiles.map(name => path.join(hermesHome, 'profiles', name))] +} + /** Move every `profiles//desktop-plugins/` 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 { - 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 { + try { + const raw = await fs.promises.readFile(path.join(dir, PACKAGE_MARKER), 'utf8') + const parsed = JSON.parse(raw) as Partial + + return parsed.package && parsed.source ? (parsed as DesktopHalfMarker) : null + } catch { + return null + } +} + +/** Copy one unified package's `desktop/` half into the app root as + * `//`, 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 { + 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 { + const touched: string[] = [] + const seen = new Set() + + 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 +} diff --git a/apps/desktop/electron/fs-ipc.ts b/apps/desktop/electron/fs-ipc.ts index 48c9afc34c..f8bdea257e 100644 --- a/apps/desktop/electron/fs-ipc.ts +++ b/apps/desktop/electron/fs-ipc.ts @@ -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 { 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 (`/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 (`/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() diff --git a/apps/desktop/electron/preload.ts b/apps/desktop/electron/preload.ts index ca8bb45a28..a8ebb1d71f 100644 --- a/apps/desktop/electron/preload.ts +++ b/apps/desktop/electron/preload.ts @@ -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), diff --git a/apps/desktop/src/app/settings/plugin-install-modal.test.tsx b/apps/desktop/src/app/settings/plugin-install-modal.test.tsx index 6820c40595..187d0b3574 100644 --- a/apps/desktop/src/app/settings/plugin-install-modal.test.tsx +++ b/apps/desktop/src/app/settings/plugin-install-modal.test.tsx @@ -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' })) diff --git a/apps/desktop/src/app/settings/plugin-install-modal.tsx b/apps/desktop/src/app/settings/plugin-install-modal.tsx index 92cc4b8066..5aaa636c62 100644 --- a/apps/desktop/src/app/settings/plugin-install-modal.tsx +++ b/apps/desktop/src/app/settings/plugin-install-modal.tsx @@ -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() { {m.desktopLabel} - {m.desktopTarget} - {probe.desktopName ? ` · ${probe.desktopName}` : ''} + {desktopHalfFromPackage ? m.desktopTargetFromPackage : m.desktopTarget} + {desktopHalfFromPackage ? '' : probe.desktopName ? ` · ${probe.desktopName}` : ''} diff --git a/apps/desktop/src/app/skills/desktop-plugins-section.test.tsx b/apps/desktop/src/app/skills/desktop-plugins-section.test.tsx deleted file mode 100644 index d1b6a79825..0000000000 --- a/apps/desktop/src/app/skills/desktop-plugins-section.test.tsx +++ /dev/null @@ -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() - - 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() - - 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() - 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') - }) - }) -}) diff --git a/apps/desktop/src/app/skills/desktop-plugins-section.tsx b/apps/desktop/src/app/skills/desktop-plugins-section.tsx deleted file mode 100644 index 54ffb9dbd1..0000000000 --- a/apps/desktop/src/app/skills/desktop-plugins-section.tsx +++ /dev/null @@ -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 = { disk: 0, runtime: 1, bundled: 2 } - -/** Deep-link anchor for a plugin row (`/skills?tab=plugins&plugin=`); 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//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 ( -
- {icon} -
-
- {title} -
- {description && ( -
- {description} -
- )} -
-
{controls}
-
- ) -} - -function DesktopPluginRow({ - record, - agentHalfMissing, - profile -}: { - record: PluginRecord - agentHalfMissing?: boolean - profile: null | string -}) { - const { t } = useI18n() - const p = t.settings.plugins - - return ( - - {record.file && ( - - - - )} - { - triggerHaptic('selection') - void setPluginEnabled(record.id, on) - }} - /> - - } - description={ - record.status === 'error' ? ( - {record.error} - ) : ( - (record.description ?? record.file ?? record.id) - ) - } - icon={} - id={pluginElementId(record.id)} - title={ - <> - {record.name} - {p.kinds[record.kind]} - {record.status === 'error' && {p.failed}} - {agentHalfMissing && ( - - - - )} - - } - /> - ) -} - -/** 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 ( -
-
-
-
- {p.title} · {p.count(rows.length)} -
-

- {p.blurb} -

-
-
- - - - - - -
-
- - {rows.length === 0 ? ( -

- {p.empty} -

- ) : ( -
- {rows.map(record => { - const packageName = unifiedPackageName(record.file) - - return ( - - ) - })} -
- )} -
- ) -} diff --git a/apps/desktop/src/app/skills/index.tsx b/apps/desktop/src/app/skills/index.tsx index e402052d7f..3a17f97b62 100644 --- a/apps/desktop/src/app/skills/index.tsx +++ b/apps/desktop/src/app/skills/index.tsx @@ -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: ". + const compactSelector = mode === 'plugins' + const scopeLabel = scopeOptions.find(option => option.value === scopeSelectValue)?.label + const profileScopeSelector = scopeOptions.length > 1 ? (
- {t.skills.configuringProfile} + {!compactSelector && ( + {t.skills.configuringProfile} + )}