Merge origin/main (#31: 94 commits to d2639c0dc8) into feat/unified-gateway-runtime

No textual conflicts. ecf9655b5a moved the plugin-contract re-export in the Desktop SDK barrel;
the branch's profile-request type re-export now follows it so perfectionist/sort-exports holds
(CI lints the merge ref and failed on that order).
This commit is contained in:
Teknium committed 2026-10-09 02:45:01 -07:00
commit 30b63bda20
61 files changed
+1546 -159

No files matched your search

@@ -9,6 +9,7 @@ import { Mail } from '@/lib/icons'
import { isSubmitEnter } from '@/lib/ime'
import { $petActivity, $petInfo, setPetInfo } from '@/store/pet'
import { overlayWindowSize } from '@/store/pet-overlay'
import { mirrorPetPluginMessages } from '@/store/pet-plugin-messages'
import { setAwaitingResponse, setBusy } from '@/store/session'
// Fallbacks mirror pet-sprite's defaults; the gateway normally sends real values.
@@ -96,6 +97,7 @@ export function PetOverlayApp() {
setBusy(Boolean(payload.busy))
setAwaitingResponse(Boolean(payload.awaiting))
setUnread(Boolean(payload.unread))
mirrorPetPluginMessages(payload.pluginMessages)
// Play a reaction on a new id (ignore the first sync, which just primes it).
const reaction = payload.reaction ?? null
@@ -5,6 +5,7 @@ import { useGatewayRequest } from '@/app/gateway/hooks/use-gateway-request'
import { useOnProfileSwitch } from '@/app/hooks/use-on-profile-switch'
import { useRouteOverlayActive } from '@/app/hooks/use-route-overlay-active'
import { PetHeartField } from '@/components/chat/vibe-hearts'
import { PetBubble } from '@/components/pet/pet-bubble'
import { persistString, storedString } from '@/lib/storage'
import { $changeEventsAvailable, $petChange } from '@/store/live-sync'
import {
@@ -508,6 +509,22 @@ export function FloatingPet() {
{/* Hearts puff off the pet; its celebrate ("yay"/jump) pose is driven by
burstVibeHearts's router. */}
<PetHeartField petH={petH} petW={petW} />
{/* Plugin lines (ctx.pet.say) float above the pet, upright (outside the
mirrored sprite wrapper) and grown toward the window's middle so a pet
parked at an edge never pushes its bubble off-screen. Core status
lines stay in the pop-out overlay; in-window the app is the surface. */}
<div
style={{
bottom: '100%',
marginBottom: 4,
pointerEvents: 'none',
position: 'absolute',
zIndex: 2,
...(position.x + petW / 2 < (window.innerWidth || 800) / 2 ? { left: 0 } : { right: 0 })
}}
>
<PetBubble showStatus={false} />
</div>
</div>
)
}
+85 -17
View File
@@ -3,13 +3,18 @@ import { useEffect, useState } from 'react'
import { AlertCircle, Clock, type IconComponent } from '@/lib/icons'
import { $petActivity, $petState, type PetState } from '@/store/pet'
import { $petPluginMessage, type PetMessageTone } from '@/store/pet-plugin-messages'
/**
* Speech bubble + status glyph for the popped-out pet overlay — the
* "notification" half of the mascot. It externalizes what the agent is doing
* (Codex-style) so a glance at the desktop pet replaces switching back to the
* window. The in-window pet doesn't show it (the app itself is the surface);
* only the overlay renders it.
* Speech bubble + status glyph for the pet — the "notification" half of the
* mascot. It externalizes what the agent is doing (Codex-style) so a glance at
* the desktop pet replaces switching back to the window. The core status lines
* show only in the popped-out overlay (`showStatus`); in-window the app itself
* is the surface, so the in-window pet renders the bubble for plugin lines only.
*
* Plugin lines (`ctx.pet.say`, see store/pet-plugin-messages) share the bubble:
* the newest live line shows with its plugin's name as a small label, unless
* the agent is in an error or waiting-on-you state — those keep the bubble.
*
* Text is derived purely from the same `$petState` / `$petActivity` the sprite
* already reacts to, so it never drifts from the animation. The bubble is shown
@@ -17,7 +22,7 @@ import { $petActivity, $petState, type PetState } from '@/store/pet'
* done/error beat / waiting on the user) and is hidden at plain idle.
*/
type Tone = 'error' | 'wait'
type Tone = PetMessageTone
interface Spec {
lines: string[]
@@ -70,9 +75,28 @@ const SPECS: Partial<Record<PetState, Spec>> = {
const TONE_COLOR: Record<Tone, string> = {
error: 'var(--ui-red)',
info: 'currentColor',
wait: 'var(--ui-yellow)'
}
const TONE_GLYPH: Partial<Record<Tone, IconComponent>> = { error: AlertCircle, wait: Clock }
// Core states that outrank a plugin line: the pet is flagging trouble or the
// turn is paused on the user, and a plugin must not talk over that.
const PRIORITY_TONES: ReadonlySet<Tone | undefined> = new Set(['error', 'wait'])
const BUBBLE_SURFACE = {
// Solid, theme-driven surface (the prior --ui-bg-card mixes in
// `transparent`, so the bubble was see-through).
background: 'var(--ui-bg-elevated)',
border: '1px solid var(--ui-stroke-secondary)',
boxShadow: '0 4px 14px rgba(0,0,0,0.22)',
color: 'var(--foreground)',
fontSize: 11,
fontWeight: 500,
pointerEvents: 'none'
} as const
// Random pick that avoids repeating the line we're already showing.
function pick(lines: string[], prev: string): string {
if (lines.length <= 1) {
@@ -88,9 +112,16 @@ function pick(lines: string[], prev: string): string {
return next
}
export function PetBubble() {
export interface PetBubbleProps {
/** Render the core status lines (working…, your turn). The overlay passes
* true; the in-window pet leaves them to the app and shows plugin lines only. */
showStatus?: boolean
}
export function PetBubble({ showStatus = true }: PetBubbleProps = {}) {
const state = useStore($petState)
const activity = useStore($petActivity)
const pluginLine = useStore($petPluginMessage)
const [line, setLine] = useState('')
// Finish beats are carried by the sprite/mail icon; idle only speaks up when
@@ -124,7 +155,52 @@ export function PetBubble() {
const spec = specKey ? SPECS[specKey] : null
if (!spec) {
if (pluginLine && !PRIORITY_TONES.has(spec?.tone)) {
const Glyph = TONE_GLYPH[pluginLine.tone]
return (
<div
data-pet-plugin={pluginLine.pluginId}
data-slot="pet-plugin-bubble"
style={{
...BUBBLE_SURFACE,
borderRadius: 10,
display: 'inline-flex',
flexDirection: 'column',
gap: 3,
lineHeight: 1.3,
maxWidth: 220,
padding: '5px 8px',
width: 'max-content'
}}
>
<span
style={{
color: 'var(--ui-text-secondary, var(--muted-foreground))',
fontSize: 9,
fontWeight: 600,
letterSpacing: 0.2,
lineHeight: 1,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap'
}}
>
{pluginLine.pluginName}
</span>
<span style={{ alignItems: 'flex-start', display: 'inline-flex', gap: 5, overflowWrap: 'anywhere' }}>
{Glyph && (
<span style={{ display: 'inline-flex', flex: 'none', paddingTop: 1 }}>
<Glyph style={{ color: TONE_COLOR[pluginLine.tone], height: 12, width: 12 }} />
</span>
)}
{pluginLine.text}
</span>
</div>
)
}
if (!spec || !showStatus) {
return null
}
@@ -135,22 +211,14 @@ export function PetBubble() {
return (
<div
style={{
...BUBBLE_SURFACE,
alignItems: 'center',
// Solid, theme-driven surface (the prior --ui-bg-card mixes in
// `transparent`, so the bubble was see-through).
background: 'var(--ui-bg-elevated)',
border: '1px solid var(--ui-stroke-secondary)',
borderRadius: hasText ? 10 : 999,
boxShadow: '0 4px 14px rgba(0,0,0,0.22)',
color: 'var(--foreground)',
display: 'inline-flex',
fontSize: 11,
fontWeight: 500,
gap: hasText ? 5 : 0,
lineHeight: 1,
// Glyph-only bubbles collapse to a tight, symmetric badge.
padding: hasText ? '5px 8px' : 5,
pointerEvents: 'none',
whiteSpace: 'nowrap'
}}
>
@@ -0,0 +1,295 @@
import { act, cleanup, render, screen } from '@testing-library/react'
import { MemoryRouter } from 'react-router'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { FloatingPet } from '@/components/pet/floating-pet'
import { PetBubble } from '@/components/pet/pet-bubble'
import { $petActivity, setPetInfo } from '@/store/pet'
import { $petOverlayActive, popInPet, popOutPet } from '@/store/pet-overlay'
import { $petPluginMessages, mirrorPetPluginMessages, resetPetPluginMessages } from '@/store/pet-plugin-messages'
import { createPluginContext } from './plugin'
import { publishPlugin } from './plugins-store'
// The in-window pet's gateway/theme plumbing is unrelated to the bubble; the
// sprite is a canvas jsdom can't paint, so stand it in with the same a11y
// label the real one carries.
vi.mock('@/app/gateway/hooks/use-gateway-request', () => ({
useGatewayRequest: () => ({ requestGateway: vi.fn(async () => null) })
}))
vi.mock('@/themes/context', () => ({ useTheme: () => ({ resolvedMode: 'dark' }) }))
vi.mock('@/components/pet/pet-sprite', () => ({
PetSprite: () => <canvas aria-label="Boba pet" />,
roamWalkRow: () => ({ mirror: false, row: undefined })
}))
const PET = {
displayName: 'Boba',
enabled: true,
frameH: 208,
frameW: 192,
scale: 0.33,
spritesheetBase64: 'stub'
}
// Each test gets its own plugin id, so per-plugin rate windows never leak
// between tests; afterEach resets the store so a live line cannot leak.
let n = 0
function plugin(name = 'Pet Wallet') {
const id = `pet-wallet-${++n}`
const disposers: Array<() => void> = []
publishPlugin({ id, kind: 'disk', name, status: 'loaded' })
const ctx = createPluginContext(id, dispose => disposers.push(dispose))
return { ctx, id, unload: () => act(() => disposers.splice(0).forEach(dispose => dispose())) }
}
function mountPet() {
return render(
<MemoryRouter>
<FloatingPet />
</MemoryRouter>
)
}
const bubble = () => document.querySelector('[data-slot="pet-plugin-bubble"]')
describe('ctx.pet — plugin lines in the core pet bubble', () => {
beforeEach(() => {
vi.useFakeTimers()
$petActivity.set({})
$petOverlayActive.set(false)
setPetInfo(PET)
})
afterEach(() => {
cleanup()
resetPetPluginMessages()
vi.clearAllTimers()
vi.useRealTimers()
setPetInfo({ enabled: false })
})
it('shows the line above the in-window pet, labelled with the plugin name', () => {
const { ctx, id } = plugin()
mountPet()
expect(bubble()).toBeNull()
act(() => void ctx.pet.say('DeepSeek ¥12.40 left'))
expect(screen.getByText('DeepSeek ¥12.40 left')).toBeTruthy()
expect(bubble()?.getAttribute('data-pet-plugin')).toBe(id)
expect(bubble()?.textContent).toContain('Pet Wallet')
expect(ctx.pet.visible.get()).toBe(true)
})
it('clears the line when its TTL runs out (default 6 s, clamped)', () => {
const { ctx } = plugin()
mountPet()
act(() => void ctx.pet.say('short', { ttlMs: 2_000 }))
expect(screen.queryByText('short')).toBeTruthy()
act(() => void vi.advanceTimersByTime(1_999))
expect(screen.queryByText('short')).toBeTruthy()
act(() => void vi.advanceTimersByTime(1))
expect(screen.queryByText('short')).toBeNull()
act(() => void ctx.pet.say('default'))
act(() => void vi.advanceTimersByTime(5_999))
expect(screen.queryByText('default')).toBeTruthy()
act(() => void vi.advanceTimersByTime(1))
expect(screen.queryByText('default')).toBeNull()
// A huge TTL is capped at 30 s — a plugin can't pin the bubble forever.
act(() => void ctx.pet.say('forever', { ttlMs: 10 * 60_000 }))
act(() => void vi.advanceTimersByTime(30_000))
expect(screen.queryByText('forever')).toBeNull()
})
it('clears the line through the returned disposer and through clear(id)', () => {
const { ctx } = plugin()
mountPet()
let dispose = () => {}
act(() => void (dispose = ctx.pet.say('bye soon')))
act(() => dispose())
expect(screen.queryByText('bye soon')).toBeNull()
act(() => void ctx.pet.say('keyed', { id: 'balance' }))
act(() => ctx.pet.clear('balance'))
expect(bubble()).toBeNull()
})
it('replaces a line in place when the same id is said again', () => {
const { ctx } = plugin()
mountPet()
let first = () => {}
act(() => void (first = ctx.pet.say('balance ¥12', { id: 'balance' })))
act(() => void ctx.pet.say('balance ¥11', { id: 'balance' }))
expect(screen.queryByText('balance ¥12')).toBeNull()
expect(screen.getByText('balance ¥11')).toBeTruthy()
// The first line's disposer must not retire its replacement.
act(() => first())
expect(screen.getByText('balance ¥11')).toBeTruthy()
})
it('clears every line a plugin has up when the plugin unloads or is disabled', () => {
const a = plugin('Pet Wallet')
const b = plugin('Other')
mountPet()
act(() => void b.ctx.pet.say('from other', { ttlMs: 30_000 }))
act(() => void a.ctx.pet.say('from wallet', { ttlMs: 30_000 }))
expect(screen.getByText('from wallet')).toBeTruthy()
a.unload()
expect(screen.queryByText('from wallet')).toBeNull()
// Only the unloaded plugin's lines go; another plugin's line resurfaces.
expect(screen.getByText('from other')).toBeTruthy()
})
it('renders plain text only: markup stays literal, control chars go, length is capped', () => {
const { ctx } = plugin('<b>Evil</b> Wallet')
mountPet()
act(() => void ctx.pet.say('<img src=x onerror="alert(1)"> hi\u202e\n\tthere'))
expect(bubble()?.querySelector('img, b')).toBeNull()
expect(screen.getByText('<img src=x onerror="alert(1)"> hi there')).toBeTruthy()
expect(bubble()?.textContent).toContain('<b>Evil</b> Wallet')
act(() => void ctx.pet.say('x'.repeat(500), { id: 'long' }))
const shown = screen.getByText(/^x+…$/).textContent ?? ''
expect(shown.length).toBe(120)
// Nothing to say → nothing shown, and a no-op disposer.
act(() => void ctx.pet.say(' \n ', { id: 'blank' }))
expect(screen.queryByText(' ')).toBeNull()
})
it('shows nothing while the pet is hidden or turned off', () => {
const { ctx } = plugin()
setPetInfo({ enabled: false })
mountPet()
act(() => void ctx.pet.say('anyone there?', { ttlMs: 30_000 }))
expect(screen.queryByText('anyone there?')).toBeNull()
expect(ctx.pet.visible.get()).toBe(false)
// Turning the pet on while the line is live shows it — the host gates on
// the user's pet setting, not the plugin.
act(() => setPetInfo(PET))
expect(screen.getByText('anyone there?')).toBeTruthy()
act(() => setPetInfo({ enabled: false }))
expect(screen.queryByText('anyone there?')).toBeNull()
})
it('rate-limits a chatty plugin', () => {
const { ctx } = plugin()
mountPet()
vi.spyOn(console, 'warn').mockImplementation(() => {})
act(() => {
for (let i = 0; i < 10; i++) {
ctx.pet.say(`line ${i}`, { id: `l${i % 3}` })
}
})
expect(screen.getByText('line 9')).toBeTruthy()
act(() => void ctx.pet.say('one too many'))
expect(screen.queryByText('one too many')).toBeNull()
act(() => void vi.advanceTimersByTime(10_000))
act(() => void ctx.pet.say('window reopened'))
expect(screen.getByText('window reopened')).toBeTruthy()
})
})
describe('PetBubble — core status keeps priority', () => {
beforeEach(() => {
vi.useFakeTimers()
$petActivity.set({})
})
afterEach(() => {
cleanup()
resetPetPluginMessages()
vi.clearAllTimers()
vi.useRealTimers()
$petActivity.set({})
})
it('lets error / waiting-on-you states win over a plugin line, then yields back', () => {
const { ctx } = plugin()
render(<PetBubble />)
act(() => void ctx.pet.say('balance ¥12', { ttlMs: 30_000 }))
expect(screen.getByText('balance ¥12')).toBeTruthy()
act(() => $petActivity.set({ error: true }))
expect(screen.queryByText('balance ¥12')).toBeNull()
act(() => $petActivity.set({ awaitingInput: true }))
expect(screen.queryByText('balance ¥12')).toBeNull()
act(() => $petActivity.set({}))
expect(screen.getByText('balance ¥12')).toBeTruthy()
})
it('shows a plugin tone glyph and leaves the core status line to the overlay', () => {
const { ctx } = plugin()
render(<PetBubble showStatus={false} />)
// In-window (showStatus=false) a busy turn says nothing on its own…
act(() => $petActivity.set({ busy: true }))
expect(document.body.textContent).toBe('')
// …but a plugin line still shows, with its tone glyph.
act(() => void ctx.pet.say('quota low', { tone: 'wait' }))
expect(screen.getByText('quota low')).toBeTruthy()
expect(bubble()?.querySelector('svg')).toBeTruthy()
})
})
describe('pop-out overlay', () => {
afterEach(() => {
popInPet()
cleanup()
resetPetPluginMessages()
delete (window as { hermesDesktop?: unknown }).hermesDesktop
setPetInfo({ enabled: false })
})
it('pushes live plugin lines to the overlay window, which renders them in its bubble', async () => {
// The overlay is a separate gateway-less window: the main renderer pushes
// its pet state over IPC. Capture that push and replay it the way the
// overlay's onState handler does.
const pushState = vi.fn()
;(window as { hermesDesktop?: unknown }).hermesDesktop = {
petOverlay: { close: vi.fn(async () => ({ ok: true })), open: vi.fn(async () => ({ ok: true })), pushState }
}
setPetInfo(PET)
popOutPet({ height: 70, width: 64, x: 10, y: 10 })
const { ctx, id } = plugin()
act(() => void ctx.pet.say('popped-out hello', { ttlMs: 30_000 }))
const last = pushState.mock.calls.at(-1)?.[0] as { pluginMessages?: unknown[] }
expect(last.pluginMessages).toEqual([expect.objectContaining({ pluginId: id, text: 'popped-out hello' })])
// Overlay side: adopt the pushed list verbatim and render the core bubble.
const pushed = structuredClone(last.pluginMessages)
resetPetPluginMessages()
act(() => mirrorPetPluginMessages(pushed))
render(<PetBubble />)
expect(screen.getByText('popped-out hello')).toBeTruthy()
// A malformed push clears rather than throws.
act(() => mirrorPetPluginMessages(undefined))
expect($petPluginMessages.get()).toEqual([])
})
})
+49
View File
@@ -12,12 +12,17 @@
* through the plugin host loader (next phase); this is that seam.
*/
import type { ReadableAtom } from 'nanostores'
import { pluginRest, type PluginRestOptions, pluginSocket } from '@/hermes'
import { createPluginI18n, type PluginI18n } from '@/i18n'
import { readKey, writeKey } from '@/lib/storage'
import { dispatchPluginNativeNotification, type PluginNativeNotificationInput } from '@/store/native-notifications'
import { $petActive } from '@/store/pet'
import { clearPetMessages, type PetSayOptions, sayPetMessage } from '@/store/pet-plugin-messages'
import { type GatewayEventListener, onGatewayEvent } from './events'
import { $pluginRecords } from './plugins-store'
import { registry } from './registry'
import { type PluginSettingsPage, settingsPageContribution } from './settings-pages'
@@ -27,6 +32,7 @@ import type { Contribution } from './types'
export type { PluginRestOptions } from '@/hermes'
export type { HermesOpenTarget } from '@/lib/hermes-open-target'
export type { PluginNativeNotificationInput, PluginNotificationAction } from '@/store/native-notifications'
export type { PetMessageTone, PetSayOptions } from '@/store/pet-plugin-messages'
/** A contribution as a plugin author writes it — provenance + id scoping are
* the host's job, so those fields are off-limits here. */
@@ -70,6 +76,24 @@ export interface PluginOs {
writeClipboard: (text: string) => Promise<boolean>
}
/** The pet door: short lines in the core pet's speech bubble, attributed to
* this plugin, instead of locating the pet in the app DOM and drawing over
* it. The host renders them in the in-window pet and the pop-out overlay;
* they show only while the user has a pet visible. */
export interface PluginPet {
/** Show one plain-text line (no HTML; control characters stripped, capped
* at 120 chars) with this plugin's name as a small label. Lasts `ttlMs`
* (default 6 s, clamped 1–30 s); the same `id` replaces in place. Core
* error / waiting-on-you states keep priority over it. Rate-limited per
* plugin. Returns a disposer that removes the line early. */
say: (text: string, options?: PetSayOptions) => () => void
/** Remove one line by `id`, or every line this plugin has up. */
clear: (id?: string) => void
/** True while a pet is installed and shown (in-window or popped out). A
* plugin can fall back to its own chip or pane while this is false. */
visible: ReadableAtom<boolean>
}
export interface PluginFileDialogOptions {
defaultPath?: string
filters?: Array<{ extensions: string[]; name: string }>
@@ -125,6 +149,10 @@ export interface PluginContext {
* manager, clipboard — attributed to this plugin, result-shaped (never
* throws for a missing capability). */
os: PluginOs
/** The pet door: lines in the core pet's speech bubble, attributed to this
* plugin and cleared when it unloads. Feature-detect on older hosts:
* `ctx.pet?.say(...)`. */
pet: PluginPet
/** Plugin-scoped persistence. */
storage: PluginStorage
/** Plugin-scoped i18n: ship + register locale bundles under this plugin,
@@ -222,6 +250,26 @@ function createPluginOs(pluginId: string): PluginOs {
}
}
// Lines are attributed by the inventory name the loader published before
// register() ran; the id is the fallback. Every line this plugin still has up
// is cleared with it on unload/disable (tracked on first use).
function createPluginPet(pluginId: string, track: (dispose: () => void) => () => void): PluginPet {
let tracked = false
return {
clear: id => clearPetMessages(pluginId, id),
say: (text, options) => {
if (!tracked) {
tracked = true
track(() => clearPetMessages(pluginId))
}
return sayPetMessage(pluginId, $pluginRecords.get()[pluginId]?.name ?? pluginId, text, options)
},
visible: $petActive
}
}
/** Timers and DOM listeners a plugin takes out through `ctx`, retired as ONE
* tracked disposer. A fired timeout drops out of the set on its own, so a
* long-lived plugin firing many one-shots does not accumulate cleanups. */
@@ -300,6 +348,7 @@ export function createPluginContext(pluginId: string, onDispose?: (dispose: () =
rest: <T>(path: string, opts?: PluginRestOptions) => pluginRest<T>(pluginId, path, opts),
socket: (path, onMessage) => track(pluginSocket(pluginId, path, onMessage)),
os: createPluginOs(pluginId),
pet: createPluginPet(pluginId, track),
storage: createPluginStorage(pluginId),
i18n: createPluginI18n(pluginId, track)
}
+5 -15
View File
@@ -123,7 +123,8 @@ import { desktopSettings } from './settings'
/** Pane, status bar and titlebar slots; see `./areas` for the mount rules. */
export { PANES_AREA, STATUSBAR_AREAS, TITLEBAR_AREAS } from './areas'
export type { PluginProfileRequestOptions, PluginProfileRoute } from './profile-request'
/** The plugin authoring contract (`HermesPlugin`, `PluginContext`, `ctx.*` door types). */
export type * from './plugin-contract'
// -- state: readonly views over the app's live atoms -------------------------
@@ -1571,10 +1572,11 @@ export const host = {
// -- react bridge -------------------------------------------------------------
export type { DesktopSettingKey, DesktopSettingValues } from './settings'
export type { PluginProfileRequestOptions, PluginProfileRoute } from './profile-request'
// -- ui: the design language --------------------------------------------------
export type { DesktopSettingKey, DesktopSettingValues } from './settings'
/** THE whole Capabilities surface (Skills / Tools / MCP tabs, installed
* lists, full-skill detail pane, embedded hub picker with one-click
* installs). For plugin dialogs pass `embedded` (tab state stays local —
@@ -1659,6 +1661,7 @@ export {
PanelRowMenu,
PanelSectionLabel
} from '@/app/overlays/panel'
export {
type ProfileGroupHeaderContribution,
type ProfileGroupRoute,
@@ -1669,7 +1672,6 @@ export {
type SidebarNavContribution,
WORKSPACE_PAGE_HEADER_AREA
} from '@/app/routes'
/** Appearance settings' plugin seam: register a render contribution at
* `APPEARANCE_AREAS.extra` to add controls at the end of the Appearance page.
* `ColorSwatches` is the app's own swatch grid (profile rail / project dialog
@@ -1800,18 +1802,6 @@ export { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'
export { Textarea } from '@/components/ui/textarea'
export { Tip, Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
export type { GatewayEventListener } from '@/contrib/events'
export type {
HermesPlugin,
PluginContext,
PluginContribution,
PluginNativeNotificationInput,
PluginNotificationAction,
PluginOs,
PluginRestOptions,
PluginSettingsPage,
PluginSettingsSubpage,
PluginStorage
} from '@/contrib/plugin'
/** Mount-scoped contribution: while the rendering component is mounted, its
* children render in the target area's slot; unmount disposes it. Use for
* page-owned chrome (a page's titlebar control leaves with the page) —
+17
View File
@@ -0,0 +1,17 @@
// The plugin authoring contract types; the SDK barrel re-exports them.
export type {
HermesPlugin,
PetMessageTone,
PetSayOptions,
PluginContext,
PluginContribution,
PluginNativeNotificationInput,
PluginNotificationAction,
PluginOs,
PluginPet,
PluginRestOptions,
PluginSettingsPage,
PluginSettingsSubpage,
PluginStorage
} from '@/contrib/plugin'
+7 -2
View File
@@ -3,6 +3,7 @@ import { atom } from 'nanostores'
import { PRIMARY_SESSION_VIEW } from '@/app/chat/session-view'
import { persistBoolean, persistString, storedBoolean, storedString } from '@/lib/storage'
import { $petActivity, $petInfo, $petUnread, clearPetUnread, type PetActivity, type PetInfo } from '@/store/pet'
import { $petPluginMessages, type PetPluginMessage } from '@/store/pet-plugin-messages'
/**
* Controller for the pop-out pet overlay (main-renderer side).
@@ -48,6 +49,8 @@ export interface PetOverlayStatePayload {
unread: boolean
/** Latest reaction — bumping its id forwards a burst to the overlay. */
reaction: PetReaction | null
/** Live plugin bubble lines (`ctx.pet.say`); this renderer owns their expiry. */
pluginMessages?: PetPluginMessage[]
}
export type PetOverlayControl =
@@ -147,7 +150,8 @@ function currentPayload(): PetOverlayStatePayload {
busy: PRIMARY_SESSION_VIEW.$busy.get(),
awaiting: PRIMARY_SESSION_VIEW.$awaitingResponse.get(),
unread: $petUnread.get(),
reaction: $petReaction.get()
reaction: $petReaction.get(),
pluginMessages: $petPluginMessages.get()
}
}
@@ -184,7 +188,8 @@ function openOverlay(request: PetOverlayOpenRequest): void {
PRIMARY_SESSION_VIEW.$busy.subscribe(pushNow),
PRIMARY_SESSION_VIEW.$awaitingResponse.subscribe(pushNow),
$petUnread.subscribe(pushNow),
$petReaction.subscribe(pushNow)
$petReaction.subscribe(pushNow),
$petPluginMessages.subscribe(pushNow)
]
}
@@ -0,0 +1,203 @@
import { atom, computed } from 'nanostores'
/**
* Plugin lines for the pet's speech bubble — the store behind `ctx.pet.say`.
*
* A plugin hands the host a short plain-text line; the host owns where and
* when it shows (the core `PetBubble`, in-window and in the pop-out overlay),
* so no plugin ever has to locate the pet in the app DOM or float its own
* overlay over it. Every line is:
*
* - plain text: control characters stripped, whitespace collapsed, capped at
* `PET_MESSAGE_MAX_CHARS` (rendered as a React text node, never HTML);
* - attributed: it carries the plugin id and its display name, which the
* bubble prints as a small label;
* - short-lived: a TTL (default `PET_MESSAGE_DEFAULT_TTL_MS`, clamped) retires
* it, and the disposer `say` returns retires it early;
* - bounded: each plugin holds at most `PET_MESSAGE_MAX_PER_PLUGIN` live lines
* and gets `PET_MESSAGE_RATE_LIMIT` says per `PET_MESSAGE_RATE_WINDOW_MS`.
*
* The bubble shows the newest live line. The owning plugin's context clears
* all of its lines when it unloads or is disabled.
*/
export type PetMessageTone = 'error' | 'info' | 'wait'
export interface PetSayOptions {
/** Stable key within your plugin. Saying again with the same id replaces
* that line in place (a ticking counter, a refreshed balance). */
id?: string
/** Visual tone: `info` (default, no glyph), `wait` (clock), `error` (alert). */
tone?: PetMessageTone
/** How long the line stays up, in ms. Clamped to 1 s – 30 s; default 6 s. */
ttlMs?: number
}
/** One live plugin line. Plain data so it can cross to the overlay window. */
export interface PetPluginMessage {
/** `<pluginId>:<id>` — unique across plugins. */
key: string
pluginId: string
/** Display label (the plugin's name, else its id). */
pluginName: string
text: string
tone: PetMessageTone
/** Monotonic; the bubble shows the highest. */
seq: number
}
export const PET_MESSAGE_MAX_CHARS = 120
export const PET_MESSAGE_LABEL_MAX_CHARS = 32
export const PET_MESSAGE_DEFAULT_TTL_MS = 6_000
export const PET_MESSAGE_MIN_TTL_MS = 1_000
export const PET_MESSAGE_MAX_TTL_MS = 30_000
export const PET_MESSAGE_MAX_PER_PLUGIN = 3
export const PET_MESSAGE_RATE_LIMIT = 10
export const PET_MESSAGE_RATE_WINDOW_MS = 10_000
const TONES: ReadonlySet<PetMessageTone> = new Set(['error', 'info', 'wait'])
export const $petPluginMessages = atom<PetPluginMessage[]>([])
/** The line the bubble shows: the newest live one, or null. */
export const $petPluginMessage = computed($petPluginMessages, list =>
list.reduce<null | PetPluginMessage>((top, m) => (!top || m.seq > top.seq ? m : top), null)
)
const timers = new Map<string, ReturnType<typeof setTimeout>>()
const sayLog = new Map<string, number[]>()
let seq = 0
// C0/C1 control characters and the bidi overrides/isolates, which could make
// a line render as something other than what the plugin passed.
// eslint-disable-next-line no-control-regex
const UNSAFE_CHARS = /[\u0000-\u001f\u007f-\u009f\u200e\u200f\u202a-\u202e\u2066-\u2069]/g
/** Normalise arbitrary input to one capped line of plain text ('' = nothing to say). */
export function sanitizePetText(value: unknown, max = PET_MESSAGE_MAX_CHARS): string {
const text = String(value ?? '')
.replace(UNSAFE_CHARS, ' ')
.replace(/\s+/g, ' ')
.trim()
return text.length > max ? `${text.slice(0, max - 1).trimEnd()}…` : text
}
function clampTtl(ttlMs: unknown): number {
const n = typeof ttlMs === 'number' && Number.isFinite(ttlMs) ? ttlMs : PET_MESSAGE_DEFAULT_TTL_MS
return Math.min(PET_MESSAGE_MAX_TTL_MS, Math.max(PET_MESSAGE_MIN_TTL_MS, Math.round(n)))
}
function allowSay(pluginId: string, now: number): boolean {
const recent = (sayLog.get(pluginId) ?? []).filter(at => now - at < PET_MESSAGE_RATE_WINDOW_MS)
if (recent.length >= PET_MESSAGE_RATE_LIMIT) {
sayLog.set(pluginId, recent)
return false
}
recent.push(now)
sayLog.set(pluginId, recent)
return true
}
function remove(key: string, expectedSeq?: number): void {
const list = $petPluginMessages.get()
const hit = list.find(m => m.key === key)
// A disposer from a replaced line must not retire its replacement.
if (!hit || (expectedSeq !== undefined && hit.seq !== expectedSeq)) {
return
}
clearTimeout(timers.get(key))
timers.delete(key)
$petPluginMessages.set(list.filter(m => m.key !== key))
}
const noop = () => {}
/**
* Show `text` in the pet bubble on behalf of `pluginId`. Returns a disposer
* that removes the line early (idempotent; a no-op once replaced or expired).
* Empty text, or a say over the plugin's rate limit, shows nothing.
*/
export function sayPetMessage(
pluginId: string,
pluginName: string,
text: unknown,
options: PetSayOptions = {}
): () => void {
const clean = sanitizePetText(text)
if (!clean) {
return noop
}
if (!allowSay(pluginId, Date.now())) {
console.warn(`[plugins] ${pluginId}: pet.say rate limit hit — line dropped`)
return noop
}
const localId = sanitizePetText(options.id ?? '', 64) || `m${seq + 1}`
const key = `${pluginId}:${localId}`
const tone: PetMessageTone = options.tone && TONES.has(options.tone) ? options.tone : 'info'
const message: PetPluginMessage = {
key,
pluginId,
pluginName: sanitizePetText(pluginName, PET_MESSAGE_LABEL_MAX_CHARS) || pluginId,
seq: ++seq,
text: clean,
tone
}
clearTimeout(timers.get(key))
let next = [...$petPluginMessages.get().filter(m => m.key !== key), message]
const own = next.filter(m => m.pluginId === pluginId)
// Keep each plugin to its newest few lines; evict the oldest.
for (const stale of own.slice(0, Math.max(0, own.length - PET_MESSAGE_MAX_PER_PLUGIN))) {
clearTimeout(timers.get(stale.key))
timers.delete(stale.key)
next = next.filter(m => m.key !== stale.key)
}
$petPluginMessages.set(next)
timers.set(
key,
setTimeout(() => remove(key, message.seq), clampTtl(options.ttlMs))
)
return () => remove(key, message.seq)
}
/** Remove one of a plugin's lines by its `id`, or every line it owns. */
export function clearPetMessages(pluginId: string, id?: string): void {
const prefix = `${pluginId}:`
const key = id === undefined ? null : `${prefix}${sanitizePetText(id, 64)}`
for (const m of $petPluginMessages.get()) {
if (m.pluginId === pluginId && (key === null || m.key === key)) {
remove(m.key)
}
}
}
/** Overlay window: adopt the main renderer's list verbatim (it owns expiry). */
export function mirrorPetPluginMessages(list: unknown): void {
$petPluginMessages.set(Array.isArray(list) ? (list as PetPluginMessage[]) : [])
}
/** Test seam: drop every line, timer, and rate-limit window. */
export function resetPetPluginMessages(): void {
timers.forEach(clearTimeout)
timers.clear()
sayLog.clear()
$petPluginMessages.set([])
}
@@ -0,0 +1,2 @@
hellohanchen
# PR #134699 catalog sweep 1009
@@ -0,0 +1,2 @@
raaaas
# PR #134512 catalog sweep 1009
@@ -0,0 +1,2 @@
silentpr0
# PR #134716 catalog sweep 1009
@@ -0,0 +1,2 @@
tuancookiez-hub
# PR #135259 catalog sweep 1009
@@ -0,0 +1,2 @@
vladzablotski-adaption
# PR #135205 catalog sweep 1009
+3 -1
View File
@@ -802,10 +802,12 @@ def probe_failure_class(exc: BaseException) -> str:
if tagged := tagged_failure_class(exc):
return tagged
from tools.mcp_tool_errors import _is_auth_error, _iter_exception_nodes, _unwrap_exception_group
from tools.mcp_tool_errors import InvalidMcpUrlError, _is_auth_error, _iter_exception_nodes, _unwrap_exception_group
from tools.mcp_tool_node_abi import NodeAbiMismatchError
try:
root = _unwrap_exception_group(exc)
if isinstance(root, InvalidMcpUrlError): # the configured URL is not http(s)://host
return "config_invalid"
if _is_auth_error(root) or getattr(getattr(root, "response", None), "status_code", None) in (401, 403):
return "auth_required"
# A missing stdio command (FileNotFoundError) or a native module built for another Node.
+17
View File
@@ -0,0 +1,17 @@
name: adaption
repo: https://github.com/adaptionlabs/adaption-hermes-plugin
sha: 958ad2e9077107be1919a5365541caa4e0acdcc8
description: "Dataset import, adaptation, augmentation, translation, synthetic generation, and AutoScientist fine-tuning through the hosted Adaption MCP. Portable Agent Plugins v1 package with three workflow skills; the MCP server is added separately with OAuth (or an API key for headless use). Disclosure — skill-only, no code; the MCP tools call api.prod.adaptionlabs.ai, and launches spend Adaption credits (the skills request a cost estimate first, Hermes does not enforce it). Hermes does not prompt before those tools unless the server is configured with trust: untrusted."
maintainer: adaptionlabs
tier: community
category: tools
requires_hermes: ">=0.21.5"
docs_url: https://github.com/adaptionlabs/adaption-hermes-plugin#readme
version: "0.1.0"
readme: true
platforms: []
capabilities:
provides_tools: []
provides_hooks: []
provides_middleware: []
requires_env: []
+25
View File
@@ -0,0 +1,25 @@
# Community catalog entry — agent-batch
#
# Owner-submitted: the PR author (`raaaas`) owns and maintains the repo below.
# Exact-SHA pin (40-hex). No self-updating code: the desktop half is a static
# `desktop/plugin.js` that only calls its own local backend router; it never
# downloads, replaces, or reloads itself.
name: agent-batch
repo: https://github.com/raaaas/hermes-agent-batch
sha: "064afc5dba8869df703ba055aa2760730c0d30dc"
subdir: plugin
description: "Parallel AI task orchestrator. Paste a task list into the desktop panel; Hermes phases it into dependency-ordered groups through the agent_batch_plan / agent_batch_phase tools (or you group it yourself in one click), then each phase dispatches one independent coding agent per task to a GitHub Actions workflow in a repo you name — own branch, own PR — and the panel tracks the runs and the PRs. GITHUB_TOKEN is the only required credential; AGENT_BATCH_REPO, AGENT_BATCH_WORKFLOW, AGENT_BATCH_MODEL and AGENT_BATCH_STATE are optional (the last is a directory override for the plan file, not a secret) and each can be set per plan in the panel instead. Disclosure — Hermes can read and rewrite the stored phase plan through the agent_batch_plan / agent_batch_phase tools, but dispatch is always a confirmed click in the panel. Each dispatch fires a GitHub workflow_dispatch with your GITHUB_TOKEN (Actions + pull-requests write) on the repo you name. The workflow you copy into that repo installs third-party coding-agent CLIs (cline by default; the others are unpinned npm packages) and runs them unattended, with auto-approve / skip-permissions, repo write access and your provider secrets. They push real branches and open real PRs, and your task text and repository code go to that runner's model vendor. LLM cost belongs to the runner: cline spends Cline Credits or a BYOK key, opencode free models need its zen relay, kilo needs an active Kilo Go subscription (keyless CI runs fail), and claude-code / codex / dsh need their own keys."
maintainer: raaaas
tier: community
category: automation
docs_url: https://github.com/raaaas/hermes-agent-batch
capabilities:
provides_tools:
- agent_batch_plan
- agent_batch_phase
provides_hooks: []
provides_middleware: []
requires_env:
- GITHUB_TOKEN
requires_hermes: ">=0.21.3"
version: 1.0.0
+3 -9
View File
@@ -1,14 +1,8 @@
name: blender
repo: https://github.com/NousResearch/hermes-plugin-blender
sha: d1929b593bddd2241a8939fb355e0a69b8e077af
version: "0.1.0"
description: >-
Official Blender Lab MCP server over stdio, pinned to upstream v1.0.3,
with a Blender workflow skill. Requires Blender 5.1+, its separately
installed MCP add-on, Git and uv. First startup downloads Python dependencies;
prepare the uv environment before connecting. Disclosure: tools execute
arbitrary Python in Blender with the user's privileges. No automatic
application/version/liveness gate; server discovery does not prove scene readiness.
sha: 8aab816ce6577eb792a256b51777cbb4cd6523f0
version: "0.2.0"
description: "Official Blender Lab MCP server over stdio, pinned to upstream v1.0.3, with a Blender workflow skill. Requires Blender 5.1+, its separately installed MCP add-on, Git and uv. First startup downloads Python dependencies; prepare the uv environment before connecting. Disclosure — tools execute arbitrary Python in Blender with your privileges; first start downloads the pinned Blender Lab MCP server and its Python dependencies with uv; at install Hermes looks for Blender in the standard install locations (Windows uninstall registry and Program Files, /Applications and the Steam library on macOS, PATH, flatpak, snap and Steam on Linux) and refuses the install when it is missing. Server discovery does not prove scene readiness."
title: Blender
maintainer: NousResearch
tier: official
+3 -3
View File
@@ -1,13 +1,13 @@
name: bunny_space-platform
title: Bunny Space
repo: https://github.com/jasm001/hermes-plugin-bunny-space
sha: 8c647a53b7d7514e9163e51e5aedc0179e4934c5
description: "Bunny Space gateway adapter for Hermes Agent. Connects a Hermes profile (the bot brain) to the Bunny Space relay so the bot joins private rooms, answers its owner, and handles @bot:<slug> mentions in posts and comments. Outbound pull transport; no external dependencies. Disclosure — replies are prompted to be in Spanish; @bot mention tasks and assistance tasks are dispatched to Hermes under the fixed sender id \"owner\" (BUNNY_SPACE_ALLOWED_USERS must contain \"owner\"), and the plugin refuses to dispatch third-party content unless platform_toolsets.bunny_space is restricted to read-only tools (the platform's effective toolset, including MCP and plugin toolsets; add no_mcp when MCP servers are enabled); the private assistance worker is opt-in via BUNNY_SPACE_ASSISTANCE=1 (the relay cannot enable it); setting BUNNY_SPACE_ALLOW_ALL_USERS makes the connector refuse to connect."
sha: fcea5421ed9418b6bfc4f292933310e43aa076c5
description: "Bunny Space gateway adapter for Hermes Agent. Connects a Hermes profile (the bot brain) to the Bunny Space relay so the bot joins private rooms, answers its owner, and handles @bot:<slug> mentions in posts and comments. Outbound pull transport; no external dependencies. Disclosure — replies are prompted to be in Spanish; @bot mention tasks and assistance tasks are dispatched to Hermes under the fixed sender id \"owner\" (BUNNY_SPACE_ALLOWED_USERS must contain \"owner\"), and the plugin refuses to dispatch third-party content unless platform_toolsets.bunny_space is restricted to read-only tools (the platform's effective toolset, including MCP and plugin toolsets; add no_mcp when MCP servers are enabled); the private assistance worker is opt-in via BUNNY_SPACE_ASSISTANCE=1 (the relay cannot enable it); setting BUNNY_SPACE_ALLOW_ALL_USERS makes the connector refuse to connect; the relay sets the poll interval and may hand the connector a WebSocket \"wake\" URL it then connects to (wake signals only, no message content)."
maintainer: jasm001
tier: community
category: platform
docs_url: https://github.com/jasm001/hermes-plugin-bunny-space#readme
version: "1.0.5"
version: "1.0.6"
capabilities:
provides_tools: []
provides_hooks: []
+18
View File
@@ -0,0 +1,18 @@
name: codex-account-switch
repo: https://github.com/SmelterLabs/hermes-account-switcher
sha: 1d42d4cb7f2e4f27e244574c2e9c27548b96fca6
subdir: ""
description: "Windows desktop plugin that switches which Codex login and which Claude subscription account every local Hermes profile uses, with one idle-only, verified restart of Hermes Desktop and the gateway. Refuses while any profile is busy, restores the previous selection if a step fails, and writes a receipt that says whether the switch took effect. Disclosure — Windows only. A switch rewrites, through Hermes's own commands, the Codex credential order in every profile's auth.json and the CLAUDE_SUBSCRIPTION_DIRECTSDK_CONFIG_DIR line in every profile's .env, so every profile that has the plugin enabled spends the same selected Codex login and the same Claude login (one Hermes-owned claude-auth folder; no token is copied between profiles). It reads Codex tokens from each profile's auth.json and the Desktop backends' dashboard session token from their process environment, then closes Hermes Desktop and stops and restarts the gateway once both report idle (work started in the seconds after that check is cut off). It sends the target Codex token to OpenAI's usage endpoint and Claude login tokens to Anthropic's OAuth profile endpoint (also when the setup screen opens); if the target Claude login has expired it first runs the claude CLI once so the CLI renews its own token, which rotates that login's refresh token. No telemetry and no automatic account rotation."
maintainer: SmelterLabs
tier: community
category: desktop
requires_hermes: ">=0.21.6"
docs_url: https://github.com/SmelterLabs/hermes-account-switcher#readme
version: "1.2.2"
platforms: [windows]
readme: true
capabilities:
provides_tools: []
provides_hooks: []
provides_middleware: []
requires_env: []
+19
View File
@@ -0,0 +1,19 @@
name: command-ledger
repo: https://github.com/olric3015/hermes-plugins
sha: f2aa337e6c730d6efd93331940c6d3d6a0fd481b
subdir: command-ledger
description: "Local ledger of the slash commands you run in Hermes (built-in commands in the CLI, built-in and plugin commands on messaging platforms): /command-log shows how often each command was used, its share, in how many sessions and when last, per command, platform or surface, for the last 24 hours, any number of hours or days, the latest session or everything recorded; /command-log recent lists the newest commands with the alias typed. Disclosure — writes command metadata (command name, alias typed, surface, platform, a 12-digit hash of the session key; no arguments, session key or user id) to plugin-data/command-ledger/commands.jsonl, trimmed to 5,000 rows past 2 MB; observer only, cannot block or change a command; no network"
maintainer: olric3015
tier: community
category: tools
requires_hermes: ">=0.21.5"
docs_url: https://github.com/olric3015/hermes-plugins/tree/main/command-ledger
version: "1.0.0"
image: https://raw.githubusercontent.com/olric3015/hermes-plugins/f2aa337e6c730d6efd93331940c6d3d6a0fd481b/docs/command-ledger-card.png
platforms: []
capabilities:
provides_tools: []
provides_hooks:
- pre_command
provides_middleware: []
requires_env: []
+2 -2
View File
@@ -1,6 +1,6 @@
name: cursor-provider
repo: https://github.com/anpicasso/hermes-cursor-provider
sha: 36eed8c2e37b19fd64b647b3f6611f0e017ad59e
sha: 833122c23a8e1d1570446a358cdb8e120d7ce42c
subdir: provider
description: "Cursor model provider using the official proprietary Cursor SDK. Disclosure — uses Cursor's proprietary cursor-sdk (PyPI, bundled binary bridge on loopback) with the user's CURSOR_API_KEY; sends the full conversation, tool schemas/results and images (HTTPS URLs or base64) to Cursor; keeps private per-session SDK workspaces under HERMES_HOME/cache/cursor-sdk; Cursor built-in tools and MCP are requested off and Hermes keeps tool execution and approvals."
maintainer: anpicasso
@@ -8,7 +8,7 @@ tier: community
category: models
requires_hermes: ">=0.21.4"
docs_url: https://github.com/anpicasso/hermes-cursor-provider#readme
version: "0.3.5"
version: "0.3.6"
platforms: []
capabilities:
provides_tools: []
+18
View File
@@ -0,0 +1,18 @@
name: dashboard-auth-feishu
repo: https://github.com/xielevi/hermes-dashboard-auth-feishu
sha: 8d69b43dcbe63d9925eb5724c046a72ac6e1260a
description: "Feishu / Lark OAuth sign-in for the web dashboard, admitted by tenant + open_id allow-list. Registers the `feishu` dashboard auth provider once plugins.entries.dashboard-auth-feishu.settings (app_id, tenant_key, owner_open_ids) and an app secret are configured. Disclosure — calls only the fixed Feishu or Lark authorize, token and user_info endpoints; reads HERMES_DASHBOARD_FEISHU_APP_SECRET or, as a fallback, the Feishu gateway's FEISHU_APP_SECRET, plus HERMES_DASHBOARD_FEISHU_SESSION_KEY; stores tenant, open_id and display name per sign-in in a 0600 SQLite file under plugin-data (no upstream tokens); every admitted identity gets full dashboard access; upstream PKCE is not used (confidential client with single-use cookie-bound state); pending logins are in memory, so one dashboard worker only; WebSockets opened before logout may stay open until the session key is rotated and the dashboard restarted."
maintainer: xielevi
tier: community
category: general
title: Feishu / Lark Dashboard Login
requires_hermes: ">=0.21.5"
docs_url: https://github.com/xielevi/hermes-dashboard-auth-feishu#readme
version: "1.0.0"
known_issues:
- "Lark endpoints are implemented but not live-tested; Feishu is tested end to end."
capabilities:
provides_tools: []
provides_hooks: []
provides_middleware: []
requires_env: []
+21
View File
@@ -0,0 +1,21 @@
name: discord-tps
repo: https://github.com/tuancookiez-hub/hermes-discord-tokenpersec
sha: 6f84f9eb5d3d8cae01bf95ea02f628a818ad39ad
subdir: discord-tps
version: "1.0.2"
requires_hermes: ">=0.21"
description: "Show live tokens-per-second and the active model on your Discord bot's presence. Disclosure — Replaces the Discord bot's public presence with the live tok/s figure and the model name of the most recent API call in this gateway (any platform or chat), visible to anyone who can see the bot; updates at most every interval seconds (minimum 10) over the gateway's existing Discord connection. Reads no credentials and makes no network calls of its own."
maintainer: TunaCookie
tier: community
category: platform
docs_url: https://github.com/tuancookiez-hub/hermes-discord-tokenpersec/blob/6f84f9eb5d3d8cae01bf95ea02f628a818ad39ad/discord-tps/README.md
readme: true
image: https://raw.githubusercontent.com/tuancookiez-hub/hermes-discord-tokenpersec/6f84f9eb5d3d8cae01bf95ea02f628a818ad39ad/assets/banner.png
screenshots:
- https://raw.githubusercontent.com/tuancookiez-hub/hermes-discord-tokenpersec/6f84f9eb5d3d8cae01bf95ea02f628a818ad39ad/assets/presence-screenshot.png
capabilities:
provides_tools: []
provides_hooks:
- post_api_request
provides_middleware: []
requires_env: []
+26
View File
@@ -0,0 +1,26 @@
name: enchanted-composer
repo: https://github.com/noxius-studium/enchanted-composer
sha: 63cb51633a68eb1c2739b6ab8d17a383361d20c4
title: Enchanted Composer
description: "Draft enhancement, reusable prompt libraries, undo/redo, and owner-bound full-duplex voice for Hermes Desktop. Disclosure — Derived from hermes-live-voice by Synero (itself derived from hermes-talk) and prompt-enhance by apoapostolov (both MIT; notices preserved). Starting voice captures microphone audio. The Codex-subscription lane spawns the official codex app-server CLI under its own login with a minimal environment; through it the captured profile's SOUL.md (up to 4,000 chars), the list of installed skills and enabled tools, and Hermes task results go to OpenAI. The API lane runs only when COMPOSER_REALTIME_OFFER_URL is set: it posts the SDP offer to that https URL with COMPOSER_OPENAI_API_KEY, or the profile's OPENAI_API_KEY, as a Bearer token (redirects refused). A user-configured wss:// bridge receives microphone audio, chat/profile identifiers and task results. Spoken requests the voice model delegates run as Hermes agent turns with tools in the chat where voice started; runs that need approval are stopped, not approved. Settings and usage totals are stored under HERMES_HOME/plugin-data/enchanted-composer/. No telemetry, no self-updater, no Codex OAuth-file reads."
maintainer: Deadtrix21
tier: community
category: voice
requires_hermes: ">=0.21.5"
docs_url: https://github.com/noxius-studium/enchanted-composer/blob/63cb51633a68eb1c2739b6ab8d17a383361d20c4/README.md
version: "0.2.0"
image: https://raw.githubusercontent.com/noxius-studium/enchanted-composer/63cb51633a68eb1c2739b6ab8d17a383361d20c4/docs/media/banner.png
screenshots:
- https://raw.githubusercontent.com/noxius-studium/enchanted-composer/63cb51633a68eb1c2739b6ab8d17a383361d20c4/docs/media/enhancement-desktop.png
- https://raw.githubusercontent.com/noxius-studium/enchanted-composer/63cb51633a68eb1c2739b6ab8d17a383361d20c4/docs/media/voice-desktop.png
- https://raw.githubusercontent.com/noxius-studium/enchanted-composer/63cb51633a68eb1c2739b6ab8d17a383361d20c4/docs/media/prompts-mobile.png
readme: true
platforms: []
known_issues:
- Codex subscription authentication is managed with `codex login` outside Hermes; the plugin does not read or write Codex OAuth files.
- Full two-sided persistent voice history and a phone voice client are not included in version 0.2.0.
capabilities:
provides_tools: []
provides_hooks: []
provides_middleware: []
requires_env: []
+4 -4
View File
@@ -1,15 +1,15 @@
name: error-ledger
repo: https://github.com/olric3015/hermes-plugins
sha: d8031eacf95a014334e719afbf1eb9c0c28e57c1
sha: 732cf2268b43b8d99c385912f9a50af149cad0ee
subdir: error-ledger
description: "Local ledger of failed provider calls (rate limits, overloads, timeouts, context overflows): /errors shows how many attempts failed per model, provider or cause, how many Hermes could retry, and the most common HTTP status, for the last 24 hours, any number of hours or days, the latest session or everything recorded; /errors recent lists the newest failures and how long ago each happened. Disclosure — writes failed-attempt metadata (ids, provider, model, HTTP status, Hermes's failure class, exception class name, timings; no error message, request or prompt text) to plugin-data/error-ledger/errors.jsonl, trimmed to 5,000 rows past 2 MB; no network"
description: "Local ledger of failed provider calls (rate limits, overloads, timeouts, context overflows): /errors shows how many attempts failed per model, provider or cause, how many Hermes could retry, and the most common HTTP status, for the last 24 hours, any number of hours or days, the latest session or everything recorded; /errors days puts one day on each line, newest first; /errors recent lists the newest failures and how long ago each happened. Disclosure — writes failed-attempt metadata (ids, provider, model, HTTP status, Hermes's failure class, exception class name, timings; no error message, request or prompt text) to plugin-data/error-ledger/errors.jsonl, trimmed to 5,000 rows past 2 MB; no network"
maintainer: olric3015
tier: community
category: tools
requires_hermes: ">=0.21.5"
docs_url: https://github.com/olric3015/hermes-plugins/tree/main/error-ledger
version: "1.1.0"
image: https://raw.githubusercontent.com/olric3015/hermes-plugins/d8031eacf95a014334e719afbf1eb9c0c28e57c1/docs/error-ledger-card.png
version: "1.2.0"
image: https://raw.githubusercontent.com/olric3015/hermes-plugins/732cf2268b43b8d99c385912f9a50af149cad0ee/docs/error-ledger-card.png
platforms: []
capabilities:
provides_tools: []
+1 -1
View File
@@ -1,6 +1,6 @@
name: excel_line
repo: https://github.com/kalice-vi/hermes-excel-line
sha: 45dcf84f59545e83e7e4545c2802f5dfac05390d
sha: a48cc5a9d1763e7bbc21b28fb12ed1289870150c
description: Excel-backed hierarchical long-term memory tree (10-row cap per workbook, dynamic branching, leaf pointers). Classifier defaults to the host model; third-party keyless rotation is opt-in.
maintainer: kalice-vi
tier: community
+17 -5
View File
@@ -14,12 +14,14 @@
name: frihet
repo: https://github.com/Frihet-io/hermes-frihet
# Pinned SHA — Frihet-io/hermes-frihet @ v0.1.0
# (fix(security): canonical Frihet externalSideEffects drives classification).
# (fix(deps): declare PyYAML as runtime dep with <7 cap, merged via PR #1.)
#
# This SHA supersedes ``f331208`` (the older doctor that read the OAuth
# token cache), ``24a9612`` (first privacy fix), ``3c8325a`` (CI rename
# + first default_enabled), AND ``b9a8067`` (slash-command + redact
# per-line + first connector safety net). It pins:
# + first default_enabled), ``b9a8067`` (slash-command + redact
# per-line + first connector safety net), ``b77f5df4`` (canonical Frihet
# externalSideEffects drives classification), AND ``e805bf2`` (source
# head of the PyYAML branch before squash). It pins all of the above AND:
# * Hook dispatcher that passes the FULL tool name to the gate (no
# more silent bypass of the approval gate).
# * Doctor that derives auth evidence from ``ctx.call_mcp`` — the
@@ -45,10 +47,20 @@ repo: https://github.com/Frihet-io/hermes-frihet
# for tools the contract has not yet named.
# * CI renamed to manifest-validate (schema-only) plus a manual
# live-hermes-validate (workflow_dispatch) for maintainers.
# * Runtime PyYAML dependency declared in pyproject.toml at >=6.0,<7
# (catalog security recommendation). Closes the silent "unknown"
# report from `frihet.py` mcp_server_block_present() in clean venvs
# without PyYAML preinstalled (regression: 7 tests in tests/test_frihet.py).
# * New tests/test_pyyaml_dependency.py asserts that yaml is importable
# in the test environment, gating the runtime-dependency contract.
# * No code-path changes: frihet.py, hook dispatcher, doctor and
# /frihet slash command remain byte-identical to the b77f5df4 SHA;
# this bump only makes the existing tests pass on a clean venv.
#
# Tests: 84 pass + 3 pre-existing skips. hermes plugins validate
# Tests: 87 pass + 3 pre-existing skips. hermes plugins validate
# --install-deps 15/15 with security scan "safe".
sha: b77f5df4b269ada5296ee07fc9c03d118933cbc0
sha: 4f092d58398ab252ea1fa6b07bfefacec717abd9
description: "Official Hermes Agent integration for Frihet — discoverability, setup, diagnostics, and operating guidance for the Frihet MCP. Zero duplicated tools; the canonical 158-operation Frihet surface stays in @frihet/mcp-server and the remote endpoint https://mcp.frihet.io/mcp. Disclosure — /frihet setup and /frihet doctor send an unauthenticated MCP initialize to mcp.frihet.io (or FRIHET_MCP_URL); /frihet doctor also makes one read-only list_invoices call through Hermes's MCP client once you grant plugins.entries.frihet.mcp_allowlist; reads ~/.hermes/config.yaml only to check that an mcp_servers.frihet block exists; the pre_tool_call hook prompts before Frihet MCP calls it classifies as irreversible or unknown, but reads and draft-first creates pass without a prompt unless the server sets trust: untrusted."
maintainer: Frihet-io
tier: community
+8 -7
View File
@@ -1,20 +1,21 @@
name: hermes-cron
repo: https://github.com/itpartypattaya/hermes-cron
sha: b57e15fe990ba0be14c8eb4ec04baa7e86cd725e
description: "Operating skill for Hermes cron: lifecycle rules (pause vs disable vs remove, `in 30m` vs `30m`), four ways to avoid needless LLM runs (no_agent, wakeAgent pre-check, monitor, [SILENT]), verify-after-change, a layered runbook and a read-only doctor that complements `hermes cron doctor` and reports one error across several jobs as one scheduler fault. Portable Agent Plugins v1 package (plugin.json + skills/); no tools, hooks, middleware or env vars. Disclosure — skill only, no network; the bundled doctor reads ~/.hermes/cron/jobs.json, ticker stamps and executions.db read-only and runs the local hermes cron doctor (argument list, no shell), whose job loader may repair malformed jobs.json entries (--no-builtin skips it)."
sha: 350ec6edb06c50b88b867d877ede367bf89a3f95
description: "Operating skill for Hermes cron: lifecycle rules (pause vs disable vs remove, `in 30m` vs `30m`), four ways to avoid needless LLM runs (no_agent, wakeAgent pre-check, monitor, [SILENT]), verify-after-change, a layered runbook and a read-only doctor that complements `hermes cron doctor` and reports one error across several jobs as one scheduler fault and a cron store that cannot be written. Portable Agent Plugins v1 package (plugin.json + skills/); no tools, hooks, middleware or env vars. Disclosure — skill only, no network; the bundled doctor reads ~/.hermes/cron/jobs.json, ticker stamps and executions.db read-only, checks the cron directory's permissions and free space without writing, and runs the local hermes cron doctor (argument list, no shell), whose job loader may repair malformed jobs.json entries (--no-builtin skips it)."
maintainer: itpartypattaya
tier: community
category: automation
requires_hermes: ">=0.21"
docs_url: https://github.com/itpartypattaya/hermes-cron/blob/b57e15fe990ba0be14c8eb4ec04baa7e86cd725e/README.md
version: "1.2.0"
image: "https://raw.githubusercontent.com/itpartypattaya/hermes-cron/b57e15fe990ba0be14c8eb4ec04baa7e86cd725e/docs/banner.png"
docs_url: https://github.com/itpartypattaya/hermes-cron/blob/350ec6edb06c50b88b867d877ede367bf89a3f95/README.md
version: "1.3.0"
image: "https://raw.githubusercontent.com/itpartypattaya/hermes-cron/350ec6edb06c50b88b867d877ede367bf89a3f95/docs/banner.png"
screenshots:
- "https://raw.githubusercontent.com/itpartypattaya/hermes-cron/b57e15fe990ba0be14c8eb4ec04baa7e86cd725e/docs/modes.png"
- "https://raw.githubusercontent.com/itpartypattaya/hermes-cron/b57e15fe990ba0be14c8eb4ec04baa7e86cd725e/docs/doctor.png"
- "https://raw.githubusercontent.com/itpartypattaya/hermes-cron/350ec6edb06c50b88b867d877ede367bf89a3f95/docs/modes.png"
- "https://raw.githubusercontent.com/itpartypattaya/hermes-cron/350ec6edb06c50b88b867d877ede367bf89a3f95/docs/doctor.png"
platforms: []
capabilities:
provides_tools: []
provides_hooks: []
provides_middleware: []
requires_env: []
+8 -8
View File
@@ -1,18 +1,18 @@
name: hermes-dreaming
repo: https://github.com/itpartypattaya/hermes-dreaming
sha: dd43dfb0fa5ff80a82c4b5ec340bfbcd5a0f3a71
description: "Nightly deterministic memory consolidation (\"dreaming\"): corroborates candidate facts with what humans actually said on distinct days, proposes the few worth keeping with a verbatim edit anchor, asks whether stale memory entries still hold, and wakes the agent only when there is work — including a memory file close to its char limit. Fact sources: the holographic store, a SQLite or JSONL export, or none. Portable Agent Plugins v1 package (plugin.json + skills/); no tools, hooks, middleware or env vars. Disclosure — scripts make no network or LLM calls and read state.db and the fact store read-only; the user-run installer copies two gate scripts into $HERMES_HOME/scripts, seeds $HERMES_HOME/dreaming.json and creates (or with --rebind re-points) two Hermes cron jobs bound to this plugin's own skill, whose agent runs send recent human messages (up to 200 / 40k chars) and fact snippets to your configured model; state goes to $HERMES_HOME/cache and a diary to $HERMES_HOME/memories/DREAMS.md; memory is changed by the agent through the regular memory tool."
sha: e2e4da34f86fa83c7a5f119afded3666315478f9
description: "Nightly deterministic memory consolidation (\"dreaming\"): corroborates candidate facts with what humans actually said on distinct days, proposes the few worth keeping with a verbatim edit anchor, asks whether stale memory entries still hold, and wakes the agent only when there is work — including a memory file close to its char limit. Fact sources: the holographic store, a SQLite or JSONL export, or none. Portable Agent Plugins v1 package (plugin.json + skills/); no tools, hooks, middleware or env vars. Disclosure — scripts make no network or LLM calls and read state.db and the fact store read-only (plus Hermes config.yaml's memory/timezone keys and the pending/memory approval queue); the user-run installer copies two gate scripts into $HERMES_HOME/scripts, seeds $HERMES_HOME/dreaming.json and creates (or with --rebind re-points) two Hermes cron jobs, through Hermes' cron API, bound to this plugin's own skill with only the memory toolset; with a chat --deliver target the report is attached as a session you can reply to. Those agent runs send recent human messages (up to 200 / 40k chars) and fact snippets to your configured model; state goes to $HERMES_HOME/cache and a diary to $HERMES_HOME/memories/DREAMS.md (configurable); memory files are changed by the agent through the regular memory tool, and with the holographic provider it also adds candidate facts via fact_store and lowers trust on human-rejected facts via fact_feedback."
maintainer: itpartypattaya
tier: community
category: memory
requires_hermes: ">=0.21"
docs_url: https://github.com/itpartypattaya/hermes-dreaming/blob/dd43dfb0fa5ff80a82c4b5ec340bfbcd5a0f3a71/README.md
version: "2.1.1"
image: "https://raw.githubusercontent.com/itpartypattaya/hermes-dreaming/dd43dfb0fa5ff80a82c4b5ec340bfbcd5a0f3a71/docs/banner.png"
docs_url: https://github.com/itpartypattaya/hermes-dreaming/blob/e2e4da34f86fa83c7a5f119afded3666315478f9/README.md
version: "2.2.1"
image: "https://raw.githubusercontent.com/itpartypattaya/hermes-dreaming/e2e4da34f86fa83c7a5f119afded3666315478f9/docs/banner.png"
screenshots:
- "https://raw.githubusercontent.com/itpartypattaya/hermes-dreaming/dd43dfb0fa5ff80a82c4b5ec340bfbcd5a0f3a71/docs/overview.png"
- "https://raw.githubusercontent.com/itpartypattaya/hermes-dreaming/dd43dfb0fa5ff80a82c4b5ec340bfbcd5a0f3a71/docs/lifecycle.png"
- "https://raw.githubusercontent.com/itpartypattaya/hermes-dreaming/dd43dfb0fa5ff80a82c4b5ec340bfbcd5a0f3a71/docs/files.png"
- "https://raw.githubusercontent.com/itpartypattaya/hermes-dreaming/e2e4da34f86fa83c7a5f119afded3666315478f9/docs/overview.png"
- "https://raw.githubusercontent.com/itpartypattaya/hermes-dreaming/e2e4da34f86fa83c7a5f119afded3666315478f9/docs/lifecycle.png"
- "https://raw.githubusercontent.com/itpartypattaya/hermes-dreaming/e2e4da34f86fa83c7a5f119afded3666315478f9/docs/files.png"
platforms: []
capabilities:
provides_tools: []
+7 -7
View File
@@ -1,17 +1,17 @@
name: hermes-field-notes
repo: https://github.com/itpartypattaya/hermes-field-notes
sha: 36017775f9b2c1a37579c7da3e7878480179d4df
description: "Field notes for a Hermes install: search known pitfalls before debugging and record new ones with root cause; register every local core edit as a patch note with a declarative checks file, then verify read-only after each hermes update (OK / MISSING / UPSTREAMED / N/A / UNKNOWN with reason) and list core files changed outside the registry. Ships 41 pitfalls and 10 patch recipes verified against 0.21.5. Optional pinned Telegram dashboard edited in place by an hourly no_agent cron job. Portable Agent Plugins v1 package (plugin.json + skills/); no tools, hooks, middleware or env vars; never edits the core or runs patch scripts. Disclosure — its scripts only read the Hermes core (pyproject.toml, read-only git status/diff) and never edit it or run patch scripts; the skill does walk the agent through hand-editing core files from its patch recipes and re-running the owner's re-apply scripts, each only after the user says yes. Network: Telegram Bot API only when the dashboard is enabled (reuses the agent's TELEGRAM_BOT_TOKEN from env or ~/.hermes/.env), and the public GitHub API without credentials (latest release plus compare against the installed commit SHA, cached 12 h; upstream.check=false disables). Writes: the notes store (~/.hermes/field-notes), ~/.hermes/field-notes.json, state and lock files in ~/.hermes/cache, a copy of the cron script in ~/.hermes/scripts when install.py runs (the skill runs it on first use), and one hourly no_agent cron job only when the user runs install_cron.py."
sha: 69a76ae9861a3de4fcf92777c1ab593e70cfe650
description: "Field notes for a Hermes install: search known pitfalls before debugging and record new ones with root cause; register every local core edit as a patch note with a declarative checks file, then verify read-only after each hermes update (OK / MISSING / UPSTREAMED / N/A / UNKNOWN with reason) and list core files changed outside the registry. Ships 42 pitfalls and 10 patch recipes checked against 0.21.6. Optional pinned Telegram dashboard edited in place by an hourly no_agent cron job. Portable Agent Plugins v1 package (plugin.json + skills/); no tools, hooks, middleware or env vars; never edits the core or runs patch scripts. Disclosure — its scripts only read the Hermes core (pyproject.toml, read-only git status/diff) and never edit it or run patch scripts; the skill does walk the agent through hand-editing core files from its patch recipes and re-running the owner's re-apply scripts, each only after the user says yes. Network: Telegram Bot API only when the dashboard is enabled (reuses the agent's TELEGRAM_BOT_TOKEN from env or ~/.hermes/.env), and the public GitHub API without credentials (latest release plus compare against the installed commit SHA, cached 12 h; upstream.check=false disables). Writes: the notes store (~/.hermes/field-notes), ~/.hermes/field-notes.json, state and lock files in ~/.hermes/cache, a copy of the cron script in ~/.hermes/scripts when install.py runs (the skill runs it on first use), and one hourly no_agent cron job only when the user runs install_cron.py."
maintainer: itpartypattaya
tier: community
category: tools
requires_hermes: ">=0.21"
docs_url: https://github.com/itpartypattaya/hermes-field-notes/blob/36017775f9b2c1a37579c7da3e7878480179d4df/README.md
version: "1.2.1"
image: "https://raw.githubusercontent.com/itpartypattaya/hermes-field-notes/36017775f9b2c1a37579c7da3e7878480179d4df/docs/banner.png"
docs_url: https://github.com/itpartypattaya/hermes-field-notes/blob/69a76ae9861a3de4fcf92777c1ab593e70cfe650/README.md
version: "1.2.3"
image: "https://raw.githubusercontent.com/itpartypattaya/hermes-field-notes/69a76ae9861a3de4fcf92777c1ab593e70cfe650/docs/banner.png"
screenshots:
- "https://raw.githubusercontent.com/itpartypattaya/hermes-field-notes/36017775f9b2c1a37579c7da3e7878480179d4df/docs/how-it-works.png"
- "https://raw.githubusercontent.com/itpartypattaya/hermes-field-notes/36017775f9b2c1a37579c7da3e7878480179d4df/docs/patch-lifecycle.png"
- "https://raw.githubusercontent.com/itpartypattaya/hermes-field-notes/69a76ae9861a3de4fcf92777c1ab593e70cfe650/docs/how-it-works.png"
- "https://raw.githubusercontent.com/itpartypattaya/hermes-field-notes/69a76ae9861a3de4fcf92777c1ab593e70cfe650/docs/patch-lifecycle.png"
platforms: []
capabilities:
provides_tools: []
+9 -9
View File
@@ -1,22 +1,22 @@
name: hermes-honcho-plugin
title: Honcho Memory
repo: https://github.com/outpoints/hermes-honcho-plugin
sha: 4fb2a175981534f38e9356957212350087546d6e
sha: 0323f5d6d0e1790aa801a0801cab8896f719b72b
description: "Desktop workbench for the Honcho memory behind the focused chat: read the newest conclusions, trace their premises, search messages, ask scoped questions, and add confirmed corrections or source files. Uses each profile's existing Honcho provider configuration. Disclosure — talks only to the Honcho server each profile is already configured for, through Hermes's own Honcho client and credentials, and polls it about every 10 seconds for the focused chat while Desktop is open. Ask runs a Honcho reasoning call that may use credits. Add fact/correction and Add to session write to Honcho only after you confirm the target in a dialog; Add to session uploads the text or PDF/JSON/text file you choose. A chat from another local profile is read with that profile's own Honcho configuration, only where this plugin is enabled. It reads the focused chat's title and folder from the profile's session database, read-only. No telemetry."
maintainer: outpoints
tier: community
category: desktop
version: "0.4.0"
version: "0.4.1"
requires_hermes: ">=0.21.4"
docs_url: https://github.com/outpoints/hermes-honcho-plugin
image: https://raw.githubusercontent.com/outpoints/hermes-honcho-plugin/4fb2a175981534f38e9356957212350087546d6e/docs/catalog/banner.png
image: https://raw.githubusercontent.com/outpoints/hermes-honcho-plugin/0323f5d6d0e1790aa801a0801cab8896f719b72b/docs/catalog/banner.png
screenshots:
- https://raw.githubusercontent.com/outpoints/hermes-honcho-plugin/4fb2a175981534f38e9356957212350087546d6e/docs/catalog/gallery-memory.png
- https://raw.githubusercontent.com/outpoints/hermes-honcho-plugin/4fb2a175981534f38e9356957212350087546d6e/docs/catalog/gallery-ask.png
- https://raw.githubusercontent.com/outpoints/hermes-honcho-plugin/4fb2a175981534f38e9356957212350087546d6e/docs/catalog/gallery-correct.png
- https://raw.githubusercontent.com/outpoints/hermes-honcho-plugin/4fb2a175981534f38e9356957212350087546d6e/docs/catalog/gallery-pane.png
- https://raw.githubusercontent.com/outpoints/hermes-honcho-plugin/4fb2a175981534f38e9356957212350087546d6e/docs/catalog/gallery-messages.png
- https://raw.githubusercontent.com/outpoints/hermes-honcho-plugin/4fb2a175981534f38e9356957212350087546d6e/docs/catalog/gallery-status.png
- https://raw.githubusercontent.com/outpoints/hermes-honcho-plugin/0323f5d6d0e1790aa801a0801cab8896f719b72b/docs/catalog/gallery-memory.png
- https://raw.githubusercontent.com/outpoints/hermes-honcho-plugin/0323f5d6d0e1790aa801a0801cab8896f719b72b/docs/catalog/gallery-ask.png
- https://raw.githubusercontent.com/outpoints/hermes-honcho-plugin/0323f5d6d0e1790aa801a0801cab8896f719b72b/docs/catalog/gallery-correct.png
- https://raw.githubusercontent.com/outpoints/hermes-honcho-plugin/0323f5d6d0e1790aa801a0801cab8896f719b72b/docs/catalog/gallery-pane.png
- https://raw.githubusercontent.com/outpoints/hermes-honcho-plugin/0323f5d6d0e1790aa801a0801cab8896f719b72b/docs/catalog/gallery-messages.png
- https://raw.githubusercontent.com/outpoints/hermes-honcho-plugin/0323f5d6d0e1790aa801a0801cab8896f719b72b/docs/catalog/gallery-status.png
readme: true
platforms: [linux, macos, windows]
capabilities:
+15
View File
@@ -0,0 +1,15 @@
name: hermes-openwhispr
repo: https://github.com/mooserini/hermes-openwhispr
sha: 636314f33376616d3a39815eb63b8a0f06900f3b
description: "Local-first OpenWhispr dictation history and shared notes for Hermes agents: find what the user said, search notes, transcribe local audio. Free local desktop bridge by default, paid cloud opt-in only. Skill-carrier plugin, no tools or hooks registered. Disclosure — skill only: the agent runs the external OpenWhispr CLI (installed with an unpinned `npm install -g @openwhispr/cli`) through the terminal tool to read your dictation history and notes over the OpenWhispr desktop app's authenticated loopback bridge, and, with your consent each time, to create, update or delete notes, transcriptions and dictionary entries and to transcribe local audio files; OpenWhispr Cloud (api.openwhispr.com, including an email-code sign-in that mints an API key kept in the CLI's own config) is used only when you opt in; the agent never opens credential files itself."
maintainer: mooserini
tier: community
category: voice
docs_url: https://github.com/mooserini/hermes-openwhispr#readme
version: "0.1.0"
platforms: []
capabilities:
provides_tools: []
provides_hooks: []
provides_middleware: []
requires_env: []
+4 -4
View File
@@ -1,15 +1,15 @@
name: hermes-pickup
repo: https://github.com/murenovich/hermes-pickup
sha: cb3d49383b0121fc7c66f6d79387e2ea0a05a7dd
description: "Pick up where you left off: up to 3 cards of unfinished work from your recent Hermes chats, project folders and files, each with where you stopped and a one-click next step. Adapted from Herald OS by Luke (iamlukethedev), MIT. Disclosure — after explicit in-app consent (stored per profile), each Refresh reads recent chats from ALL profiles' session stores (read-only) and sends chat excerpts and metadata, project/file names and paths, git branch/changed-file names and recent commit subjects to the current profile's Hermes LLM route (the auxiliary title-generation model, main provider unless configured); runs read-only git and, on macOS, Spotlight `mdfind`; never reads document contents; writes only $HERMES_HOME/pickup/. The \"Next step\" button can submit a model-written prompt straight into the card's chat in one click. The optional install.sh, when asked, adds the plugin to every profile's plugins.enabled (undone exactly by uninstall.sh)."
sha: dc9b2b9b24b194f6d9b2f17f8bd4c7fa73105a5d
description: "Pick up where you left off: up to 10 cards (5 by default) of unfinished work from your recent Hermes chats, project folders and files, each with where you stopped and a one-click next step. Adapted from Herald OS by Luke (iamlukethedev), MIT. Disclosure — after explicit in-app consent (stored per profile), each Refresh reads recent chats from every profile's session store (read-only; profiles the user skips in Settings are never opened) and sends chat excerpts and metadata, project/file names and paths, git branch/changed-file names and recent commit subjects to the current profile's Hermes LLM route (the auxiliary title-generation model, main provider unless configured); runs read-only git and, on macOS, Spotlight `mdfind`; never reads document contents; writes only $HERMES_HOME/pickup/. The \"Next step\" button can submit a model-written prompt straight into the card's chat in one click. The optional install.sh, when asked, adds the plugin to every profile's plugins.enabled (undone exactly by uninstall.sh)."
maintainer: murenovich
tier: community
category: desktop
requires_hermes: ">=0.20.0"
docs_url: https://github.com/murenovich/hermes-pickup#readme
version: "0.1.1"
version: "0.2.0"
screenshots:
- https://raw.githubusercontent.com/murenovich/hermes-pickup/cb3d49383b0121fc7c66f6d79387e2ea0a05a7dd/docs/screenshot.png
- https://raw.githubusercontent.com/murenovich/hermes-pickup/dc9b2b9b24b194f6d9b2f17f8bd4c7fa73105a5d/docs/screenshot.png
known_issues:
- "On installs with several profiles, Hermes issue #134712 can make the Desktop backend answer Plugin not found; the page explains the fix (enable hermes-pickup in every profile, then restart Desktop)."
platforms: []
+9 -9
View File
@@ -1,21 +1,21 @@
name: hermes-pokemon
repo: https://github.com/sxuff/hermes-pokemon
sha: 5c461d9e87ed06a41c2f1694108426767e785c2d
version: "0.6.2"
sha: a3d9f57f345a744825b59432c343d07d4f508a24
version: "0.7.0"
title: Hermes Pokémon
description: "A cozy pixel-art companion garden for Hermes Desktop with three starters, nicknames, fetch, berries, XP, and optional evolution through all nine starter forms. The companion keeps you company on long turns, reacts quietly to failures and to what Hermes is doing, and lives through seasons, offline rainy days, wild visitors, keepsakes, and milestones. Independent fan project bundling CHUNSOFT Pokémon sprites and SpriteCollab community contributions; artwork is excluded from the source-code MIT license. Uses local plugin storage and read-only focused-session activity, with no runtime network requests, credentials, backend, or model calls. Disclosure — independent fan project bundling CHUNSOFT Pokémon sprites and SpriteCollab contributions that are excluded from the MIT code license; reads focused-session busy state and tool-start events read-only and keeps companion state in plugin storage; no network or model calls."
description: "A cozy pixel-art companion garden for Hermes Desktop with three starters, nicknames, fetch, berries, XP, and optional evolution through all nine starter forms. The companion keeps you company on long turns, reacts quietly to failures, to what Hermes is doing and to turns finishing in other chats, and lives through seasons with real day length and moon phases, offline showers, wild visitors it greets in character, old-friend cameos, keepsakes, milestones that leave a bench, a lantern and bunting in the garden, and a sapling that grows with the days. Reachable from the keyboard, the status bar and the command palette. Independent fan project bundling CHUNSOFT Pokémon sprites and SpriteCollab community contributions; artwork is excluded from the source-code MIT license. Uses local plugin storage and read-only session activity, with no runtime network requests, credentials, backend, or model calls. Disclosure — independent fan project bundling CHUNSOFT Pokémon sprites and SpriteCollab contributions that are excluded from the MIT code license; reads session busy state and message-complete/tool-start/tool-complete events read-only (other chats contribute only \"a turn finished\"), focuses the composer (caret only, no text) or reveals its own pane only on a user click or command, saves a PNG snapshot to your downloads only when you click Save a snapshot, and keeps companion state in plugin storage; no network or model calls."
maintainer: sxuff
tier: community
category: desktop
requires_hermes: ">=0.21.5"
docs_url: https://github.com/sxuff/hermes-pokemon#readme
screenshots:
- https://raw.githubusercontent.com/sxuff/hermes-pokemon/5c461d9e87ed06a41c2f1694108426767e785c2d/docs/images/habitat.png
- https://raw.githubusercontent.com/sxuff/hermes-pokemon/5c461d9e87ed06a41c2f1694108426767e785c2d/docs/images/starters.png
- https://raw.githubusercontent.com/sxuff/hermes-pokemon/5c461d9e87ed06a41c2f1694108426767e785c2d/docs/images/narrow.png
- https://raw.githubusercontent.com/sxuff/hermes-pokemon/5c461d9e87ed06a41c2f1694108426767e785c2d/docs/images/evolution.png
- https://raw.githubusercontent.com/sxuff/hermes-pokemon/5c461d9e87ed06a41c2f1694108426767e785c2d/docs/images/rain.png
- https://raw.githubusercontent.com/sxuff/hermes-pokemon/5c461d9e87ed06a41c2f1694108426767e785c2d/docs/images/visitor.png
- https://raw.githubusercontent.com/sxuff/hermes-pokemon/a3d9f57f345a744825b59432c343d07d4f508a24/docs/images/habitat.png
- https://raw.githubusercontent.com/sxuff/hermes-pokemon/a3d9f57f345a744825b59432c343d07d4f508a24/docs/images/starters.png
- https://raw.githubusercontent.com/sxuff/hermes-pokemon/a3d9f57f345a744825b59432c343d07d4f508a24/docs/images/rewards-night.png
- https://raw.githubusercontent.com/sxuff/hermes-pokemon/a3d9f57f345a744825b59432c343d07d4f508a24/docs/images/cameo.png
- https://raw.githubusercontent.com/sxuff/hermes-pokemon/a3d9f57f345a744825b59432c343d07d4f508a24/docs/images/evolution.png
- https://raw.githubusercontent.com/sxuff/hermes-pokemon/a3d9f57f345a744825b59432c343d07d4f508a24/docs/images/visitor.png
readme: true
platforms: []
capabilities:
+3 -3
View File
@@ -1,8 +1,7 @@
name: hermes-telemetry
repo: https://github.com/nujovich/hermes-telemetry
sha: 146f6744fc4117ade784eefd7aa231c9b3f36281
description: Observability + budget guardrails for Hermes Agent — tokens, cost, latency, tool calls per
session and cron job. Persists to local SQLite. Exposes /stats and /budget slash commands.
sha: 11166c45cd3f636578ecf6c8caeb8780dae4c304
description: "Observability + budget guardrails for Hermes Agent — tokens, cost, latency, tool calls per session and cron job. Persists to local SQLite. Exposes /stats and /budget slash commands. Disclosure — records per-session tokens, cost, latency, tool names and sender/profile ids (no message text) in ~/.hermes/telemetry/telemetry.db; fetches public model pricing from openrouter.ai/api/v1/models on first load and at most once a day; in the default enforce mode first load writes a $5/day, $100/month global budget to telemetry/budget.yaml, and a hard breach blocks every tool call and pauses the over-budget cron job until the window resets (set plugins.entries.hermes-telemetry.settings.mode: observe to record without enforcing); the optional standalone dashboard server binds 127.0.0.1 with no auth."
maintainer: nujovich
tier: community
category: general
@@ -24,3 +23,4 @@ capabilities:
- subagent_stop
provides_middleware: []
requires_env: []
version: "0.9.0"
+71
View File
@@ -0,0 +1,71 @@
name: hermes-video-editor
title: Video Editor (local FFmpeg)
repo: https://github.com/oliverhees/hermes-video-editor
sha: 66035165c71e09b7f77a08aecc36e35c0b2dce14
version: "0.3.0"
description: "Local video editor for your own footage: 45 FFmpeg tools for the agent (cut, silence removal, 9:16 reframe, captions, music with ducking, loudness, platform export and checks) plus a visual editor in Hermes Desktop with canvas, any number of tracks for text, shapes, video overlays and music, scenes and an English/German help. 100% local, no API key. Licence: PolyForm Noncommercial 1.0.0. Disclosure — the Desktop page starts a local web server on 127.0.0.1 (random port, per-run token) that can read and write media and image files anywhere under your home folder (and any VE_EDITOR_ROOTS); preview caches go to the system temp folder. The agent tools run ffmpeg/ffprobe on the input paths the model passes and write media, image or .srt files at the output paths it passes (replacing an existing file only with overwrite: true)."
maintainer: oliverhees
category: tools
tier: community
requires_hermes: ">=0.21.5"
docs_url: https://github.com/oliverhees/hermes-video-editor/blob/66035165c71e09b7f77a08aecc36e35c0b2dce14/README.md
image: https://raw.githubusercontent.com/oliverhees/hermes-video-editor/66035165c71e09b7f77a08aecc36e35c0b2dce14/docs/banner.png
screenshots:
- https://raw.githubusercontent.com/oliverhees/hermes-video-editor/66035165c71e09b7f77a08aecc36e35c0b2dce14/docs/editor.png
readme: true
capabilities:
provides_tools:
- lk_media_doctor
- lk_media_probe
- lk_detect_silence
- lk_detect_scenes
- lk_extract_frame
- lk_loudness_report
- lk_trim
- lk_split
- lk_join
- lk_remove_silence
- lk_remove_segments
- lk_change_speed
- lk_reverse
- lk_loop
- lk_crossfade_join
- lk_crop
- lk_crop_to_aspect
- lk_resize
- lk_rotate_flip
- lk_pad_blur_background
- lk_color_adjust
- lk_denoise_video
- lk_fade_video
- lk_stabilize
- lk_add_text
- lk_burn_captions
- lk_add_image_overlay
- lk_picture_in_picture
- lk_stack_videos
- lk_blur_region
- lk_extract_audio
- lk_replace_audio
- lk_mix_music
- lk_normalize_loudness
- lk_sync_audio_offset
- lk_adjust_volume
- lk_fade_audio
- lk_denoise_audio
- lk_mute_video
- lk_export_preset
- lk_platform_check
- lk_compress_to_size
- lk_to_gif
- lk_contact_sheet
- lk_transcribe_captions
provides_hooks: []
provides_middleware: []
requires_env: []
known_issues:
- Requires FFmpeg and ffprobe on PATH (apt install ffmpeg / brew install ffmpeg / winget install Gyan.FFmpeg).
- lk_transcribe_captions is optional and needs 'pip install faster-whisper' plus a model already on disk.
- The visual editor has one main video track plus any number of tracks for text, shapes, video overlays, audio and scenes; these items sit at fixed times and do not move when the main clips are cut (bundle them in a scene to move them together).
- Licence is PolyForm Noncommercial 1.0.0 (source-available): free for non-commercial use, commercial use needs a separate licence from Lokyy.de.
- The editor preview needs H.264 or VP8 playback in the app's browser engine; editing and export work without it.
+16
View File
@@ -0,0 +1,16 @@
name: huddo
repo: https://github.com/puffo-ai/huddo-mcp
sha: a86d3aae9da2dd921cee27c8f220fbe2c4f491c6
subdir: integrations/hermes
description: "Huddo — group chat where people and AI agents talk in the same room. Join by invite link, read and reply, whisper to one member, pair with your human, wait for new messages. Portable Agent Plugins v1 package (mcp.json + skills/). Disclosure — runs the Huddo MCP server locally over stdio (npx -y huddoai@0.7.1, pinned, npm provenance to this commit; Node 20+); sends and receives room messages via api.huddo.ai, readable by every room member except one-to-one encrypted whispers; huddo_send can attach any local file the agent names and upload it to the room, and Hermes does not ask before huddo_send/huddo_whisper/huddo_download run (the package does not set trust: untrusted); the server tells the agent to keep calling huddo_wait (up to 50 s per call) for as long as it is in a room, which keeps spending model tokens until you stop it; downloads go to ~/.huddo/downloads without overwriting; stores a guest identity (signing keys) in ~/.huddo; no background process (HUDDO_DAEMON=0), no self-update, no telemetry. Room messages from others are untrusted chat content; Huddo marks the paired human with a header tag that message text cannot imitate, and the bundled skill tells the agent to get confirmation in your own Hermes chat before anything sensitive."
maintainer: puffo-ai
tier: community
category: tools
version: "0.7.1"
docs_url: https://github.com/puffo-ai/huddo-mcp/tree/main/integrations/hermes#readme
platforms: []
capabilities:
provides_tools: []
provides_hooks: []
provides_middleware: []
requires_env: []
+8 -7
View File
@@ -1,19 +1,19 @@
name: hyatlas
repo: https://github.com/tuancookiez-hub/HyAtlas-Memory
sha: f92bfe0f461c1dda0c80e3d0a0edb9266902ac9f
sha: f61727a84971dcf3b5bdd2a9a2a12e74b47695ba
subdir: plugins/hyatlas
version: "4.3.2"
image: https://raw.githubusercontent.com/tuancookiez-hub/HyAtlas-Memory/f92bfe0f461c1dda0c80e3d0a0edb9266902ac9f/docs/images/banner.png
version: "4.5.0"
image: https://raw.githubusercontent.com/tuancookiez-hub/HyAtlas-Memory/f61727a84971dcf3b5bdd2a9a2a12e74b47695ba/docs/images/banner.png
screenshots:
- https://raw.githubusercontent.com/tuancookiez-hub/HyAtlas-Memory/f92bfe0f461c1dda0c80e3d0a0edb9266902ac9f/docs/images/desktop-overview.png
- https://raw.githubusercontent.com/tuancookiez-hub/HyAtlas-Memory/f92bfe0f461c1dda0c80e3d0a0edb9266902ac9f/docs/images/desktop-graph.png
- https://raw.githubusercontent.com/tuancookiez-hub/HyAtlas-Memory/f61727a84971dcf3b5bdd2a9a2a12e74b47695ba/docs/images/desktop-overview.png
- https://raw.githubusercontent.com/tuancookiez-hub/HyAtlas-Memory/f61727a84971dcf3b5bdd2a9a2a12e74b47695ba/docs/images/desktop-graph.png
title: HyAtlas Memory
description: "HyAtlas v4 — self-hosted 7-layer memory provider backed by a pure-Go single-binary server you run yourself (chromem-go vectors, in-process BGE-small embeddings, bitemporal knowledge graph with source citations). Embeddings, the vector index and stored memories stay local. Ships agent tools (hyatlas_status/search/recent/add), a `hermes hyatlas` CLI, a Desktop pane with a starmap graph view, and a web dashboard. Disclosure — requires the separate hyatlas-go server, installed by the user. The plugin never downloads or bundles it, and it talks only to the configured server (default 127.0.0.1:19528). Embeddings and stored memories stay local. Extraction is opt-in: the server has no default LLM endpoint and makes no LLM call until HYATLAS_LLM_BASE, HYATLAS_LLM_MODEL and HYATLAS_LLM_KEY are all set. The repo installer pre-fills a free Nous Portal endpoint that Enter accepts, but nothing is sent until you also enter a key. Once configured, each turn, including that turn's tool output, goes to that endpoint. In the default ultra mode the server also sends batches of up to 200 stored facts to it every 6 hours and deletes the facts the model marks merged or stale. HYATLAS_MODE=lite makes no LLM call. The spawned server gets an allowlisted environment (OS essentials plus HYATLAS_*), not the agent's keys. auto_start is off by default. On Windows, if a hyatlas-go.ps1 launcher is configured or sits beside the binary, `hermes hyatlas start/stop` runs it; the repo's sample launcher reads your Nous token from Hermes auth.json and points extraction at the Nous Portal. No telemetry."
description: "HyAtlas v4 — self-hosted 7-layer memory provider backed by a pure-Go single-binary server you run yourself (chromem-go vectors, in-process BGE-small embeddings, hybrid vector + keyword search, bitemporal knowledge graph with source citations). Embeddings, the vector index and stored memories stay local. Ships agent tools (hyatlas_status/search/recent/add), a `hermes hyatlas` CLI, a Desktop pane with a starmap graph view, and a web dashboard. Disclosure — requires the separate hyatlas-go server, installed by the user. The plugin never downloads or bundles it, and it talks only to the configured server (default 127.0.0.1:19528). Embeddings and stored memories stay local. Extraction is opt-in: the server has no default LLM endpoint and makes no LLM call until HYATLAS_LLM_BASE, HYATLAS_LLM_MODEL and HYATLAS_LLM_KEY are all set. The repo installer pre-fills a free Nous Portal endpoint that Enter accepts, but nothing is sent until you also enter a key. Once configured, each turn's user and assistant text goes to that endpoint (not tool output or context summaries; capped at 12,000 characters). In the default ultra mode the server also sends batches of up to 50 stored facts to it every 6 hours and supersedes (hides but keeps) the facts the model marks merged or stale, recording a reason for each drop. It also records relations corroborated across turns in the local knowledge graph (HYATLAS_CONSOLIDATE_GRAPH=off disables that). HYATLAS_MODE=lite makes no LLM call. Server endpoints that rewrite stored data are off unless the server runs with HYATLAS_ADMIN=on. The spawned server gets an allowlisted environment (OS essentials plus HYATLAS_*), not the agent's keys. auto_start is off by default; a server started by auto_start or `hermes hyatlas start` keeps running after Hermes exits until `hermes hyatlas stop`. On Windows, if a hyatlas-go.ps1 launcher is configured, `hermes hyatlas start/stop` runs it; the repo's sample launcher reads your Nous token from Hermes auth.json and points extraction at the Nous Portal. No telemetry."
maintainer: tuancookiez-hub
tier: community
category: memory
requires_hermes: ">=0.21.4"
docs_url: https://github.com/tuancookiez-hub/HyAtlas-Memory/tree/f92bfe0f461c1dda0c80e3d0a0edb9266902ac9f/plugins/hyatlas
docs_url: https://github.com/tuancookiez-hub/HyAtlas-Memory/tree/f61727a84971dcf3b5bdd2a9a2a12e74b47695ba/plugins/hyatlas
platforms:
- linux
- macos
@@ -27,3 +27,4 @@ known_issues:
- "After enabling, select the provider with `hermes memory setup` (memory.provider: hyatlas) — installing and enabling alone does not activate memory."
- "The agent tools and hooks arrive through the MemoryProvider interface, not ctx.register_tool, so they do not appear in the plugin tool list."
- "Requires the separate hyatlas-go server binary (installed by the user); without a reachable server the provider reports unavailable and memory features degrade rather than fail."
- "Updating the plugin does not update the server: install hyatlas-go v4.5.0 too. A store written by an earlier version can be cleaned with the server's maintenance endpoints (see after-install.md, 'Upgrading a store written before 4.5.0')."
+5 -3
View File
@@ -1,17 +1,19 @@
name: invinoveritas-receipts
repo: https://github.com/babyblueviper1/hermes-invinoveritas
sha: 4def742e3c991539769c5f7328399a6542b7f147
description: "Signed, independently checkable receipts for Hermes approval verdicts. Observe-only post_approval_response hook: for each Guardian LLM verdict (or every approve/deny with scope=all) it requests an invinoveritas decision receipt, a signed set of salted commitments that anyone can check for free at /verify-proof, and that the holder can later open with the salt kept locally. Never approves, denies or delays an approval. Disclosure — one HTTPS POST per recorded approval decision to api.babyblueviper.com/decision-receipt (or the configured api_url) under INVINOVERITAS_API_KEY, paid automatically at 100 sats per receipt from your invinoveritas balance (default: Guardian LLM verdicts only; scope: all adds every human approve/deny); sends a fixed question, the options, the choice, a nonce-salted SHA-256 of the decision context and the tool_call_id/turn_id, never the command text; stores receipts, salts and the full decision context (including the command as Hermes passed it) in plugin-data/invinoveritas-receipts/receipts.jsonl."
sha: 6a4f9c7b49beac69899f18f3bcf1d0b340297624
description: "Signed, independently checkable receipts for Hermes approval verdicts and, since 0.2.0, for turns that stop on iteration exhaustion. Observe-only: a post_approval_response hook requests an invinoveritas decision receipt for each Guardian LLM verdict (or every approve/deny with scope=all); post_llm_call + on_session_end record, when a turn ends with max_iterations_reached, whether the turn's todo ledger (the last todo result or the list re-injected after compaction) was complete (turn_scope: exhausted by default, all, or off). Each receipt is a signed set of salted commitments that anyone can check for free at /verify-proof and that the holder can later open with the salt kept locally. Never approves, denies, delays, continues or blocks anything. Disclosure — one HTTPS POST per recorded approval decision or recorded turn to api.babyblueviper.com/decision-receipt (or the configured api_url) under INVINOVERITAS_API_KEY, paid automatically at 100 sats per receipt from your invinoveritas balance with no per-call confirmation (defaults: Guardian LLM verdicts, and only turns that stop on max_iterations_reached; scope: all adds every human approve/deny, turn_scope: all pays on every turn); sends a fixed question, the options, the choice, a nonce-salted SHA-256 of the context and the tool_call_id/turn_id, never the command text, todo contents or reply text; stores receipts, salts and the full contexts (including the command as Hermes passed it, and the todo ledger) in plugin-data/invinoveritas-receipts/receipts.jsonl."
maintainer: babyblueviper1
tier: community
category: general
docs_url: https://github.com/babyblueviper1/hermes-invinoveritas#readme
version: "0.1.0"
version: "0.2.1"
platforms: []
capabilities:
provides_tools: []
provides_hooks:
- post_approval_response
- post_llm_call
- on_session_end
provides_middleware: []
requires_env:
- INVINOVERITAS_API_KEY
+4 -4
View File
@@ -1,14 +1,14 @@
name: memtomem
repo: https://github.com/memtomem/memtomem
sha: 5b7034126574a2956b6a328622006a46b2a0e367
sha: 592865456cf7670311648fbc619bbb876d002a1e
subdir: packages/memtomem-hermes-plugin
version: "0.1.2"
description: "Markdown-first long-term memory shared across your coding agents: a local memtomem MCP server plus six memory skills (index, recall, remember, search, setup, status). Notes live in ~/.memtomem and are searchable from Hermes, Claude Code, Codex CLI and Kimi Code. Runs at user scope. Disclosure — the server is launched with `uvx --python 3.12` and downloaded from PyPI (memtomem[onnx]==0.6.7) on first start, with Python 3.12 if uv cannot find one; it runs as a stdio MCP server for the session and reads the directories you ask it to index. With a fresh BM25-only configuration nothing else leaves the machine; an existing memtomem configuration may enable Hugging Face model downloads, a remote embedding or LLM provider, Cohere reranking or search webhooks."
version: "0.1.4"
description: "Markdown-first long-term memory shared across your coding agents: a local memtomem MCP server plus six memory skills (index, recall, remember, search, setup, status). Notes live in ~/.memtomem and are searchable from Hermes, Claude Code, Codex CLI and Kimi Code. Runs at user scope. Disclosure — the server is launched with `uvx --python 3.12` and downloaded from PyPI (memtomem[onnx]==0.6.8) on first start, with Python 3.12 if uv cannot find one; it runs as a stdio MCP server for the session and reads the directories you ask it to index. With a fresh BM25-only configuration nothing else leaves the machine; an existing memtomem configuration may enable Hugging Face model downloads, a remote embedding or LLM provider, Cohere reranking or search webhooks. At startup the server also reads the nearest .env found upward from its installed location inside uv's cache (normally ~/.env), setting variables not already present, outside Hermes's MCP environment filtering."
maintainer: memtomem
tier: community
category: general
requires_hermes: ">=0.21.5"
docs_url: https://github.com/memtomem/memtomem/tree/5b7034126574a2956b6a328622006a46b2a0e367/packages/memtomem-hermes-plugin
docs_url: https://github.com/memtomem/memtomem/tree/592865456cf7670311648fbc619bbb876d002a1e/packages/memtomem-hermes-plugin
capabilities:
provides_tools: []
provides_hooks: []
+7 -11
View File
@@ -1,21 +1,17 @@
name: openviking
title: OpenViking
repo: https://github.com/volcengine/OpenViking
sha: 3b65218eef43aba7a680cf143278ad4c153b0e9f
sha: 9f150ece247ad683494ab172d738023bd6dc6170
subdir: examples/hermes-plugin
version: "2.0.1"
description: OpenViking context-database memory provider — session-managed memory with automatic
extraction, tiered retrieval and filesystem-style knowledge browsing. Maintained by Volcengine; the
same provider that shipped in-tree. Disclosure — conversation turns, tool results and MEMORY.md /
USER.md edits are sent to the configured OpenViking server (local 127.0.0.1 by default; setup offers
a Volcengine cloud endpoint); when the local server is down it starts `openviking-server` from PATH
in the background, and at this pin that child inherits the full Hermes environment.
version: "2.1.1"
image: https://raw.githubusercontent.com/volcengine/OpenViking/9f150ece247ad683494ab172d738023bd6dc6170/docs/images/openviking-social.jpg
description: "Long-term memory for Hermes. Capture conversations, recall useful facts across sessions and search your knowledge. Choose Quick Local, OpenViking Service (managed cloud) or your own server. Disclosure — conversation turns, tool results and MEMORY.md / USER.md edits are sent to the configured OpenViking server. Quick Local setup installs the newest OpenViking 0.4.x server release from PyPI at setup time (checked against PyPI's hash, not the version reviewed here; re-running setup upgrades it) plus a hash-pinned llama-cpp-python wheel and a local embedding model. It copies the active Hermes static LLM API key into a private file under the profile for extraction, and its localhost server keeps running after Hermes exits and after plugin removal (stop it with `hermes openviking local stop`). Extraction can use a remote LLM. Custom localhost connections can start openviking-server from PATH with the profile environment minus Hermes bot, gateway, GitHub and dashboard secrets; model and tool API keys still pass."
maintainer: volcengine
tier: community
category: memory
docs_url: https://github.com/volcengine/OpenViking/tree/main/examples/hermes-plugin#readme
docs_url: https://github.com/volcengine/OpenViking/tree/9f150ece247ad683494ab172d738023bd6dc6170/examples/hermes-plugin#readme
capabilities:
provides_tools: []
provides_hooks:
- on_session_end
provides_hooks: []
provides_middleware: []
requires_env: []
+21
View File
@@ -0,0 +1,21 @@
name: print-job-watch
repo: https://github.com/TakeshiTGAL/hermes-plugin-print-job-watch
sha: d5c8ec85ecfd48e970a274368ff32ec75c7575df
description: "Reads one OctoPrint printer. A cron watch reports printer_disconnected (not a pause); error; complete after Printing, Printing from SD, Pausing, Paused, Resuming, or Finishing; paused after Printing, Printing from SD, Pausing, or Resuming; cancelled; and stalled. This plugin can be used with OctoPrint. OctoPrint is a registered trademark owned by Gina Häußge (https://octoprint.org). Disclosure — Talks to the one OctoPrint origin you set in OCTOPRINT_URL with your OCTOPRINT_API_KEY, plus an optional webcam still from the same host, fetched without the key when it is on another port. Over plain http, that key is sent unencrypted. Pause, resume, and cancel ask for Hermes approval every time and are refused under cron, yolo, approvals off, single-query, unattended sessions and plugins.isolation: host, and when those checks cannot run. The plugin has no user list of its own: anyone you allow on your Hermes gateway can ask for a move and approve it. Running hermes tools does not remove the slash command. Answering \"always\" adds one unused line to config.yaml, and that line does not approve the next move. The optional watch is a cron job you create from the CLI (a model turn every 5 minutes by default) that stays after uninstall until you run unschedule. At that default it is 288 runs a day with no daily cap. The watch needs plugins.isolation: in_process; under host it only posts a notice that it cannot run, on every run. Not tested on a physical printer, with access control off, or with the stock webcam."
maintainer: TakeshiTGAL
tier: community
category: tools
requires_hermes: ">=0.21.4"
docs_url: https://github.com/TakeshiTGAL/hermes-plugin-print-job-watch#readme
version: "0.1.0"
platforms: []
capabilities:
provides_tools:
- print_job_status
- print_job_control
- print_job_watch
provides_hooks: []
provides_middleware: []
requires_env:
- OCTOPRINT_URL
- OCTOPRINT_API_KEY
+4 -4
View File
@@ -1,9 +1,9 @@
name: qdrant
repo: https://github.com/kuehnberger/hermes-qdrant-memory
sha: b2475f7ca020bd6f847a3e74a1e611b1b99a16a1
version: "0.1.7"
image: https://raw.githubusercontent.com/kuehnberger/hermes-qdrant-memory/b2475f7ca020bd6f847a3e74a1e611b1b99a16a1/docs/banner.jpg
description: "Dense semantic memory for Hermes, stored in a Qdrant collection you host. Each turn becomes one point — user and assistant text verbatim, no LLM summarization step — embedded on-device at 384 dimensions with fastembed (ONNX; the default install is 206 MB of runtime dependencies with no torch, versus ~1.2 GB when sentence-transformers was a hard dependency) and recalled by cosine search with an optional session_id payload filter, so a result quotes what was actually said. Registers 7 tools: qdrant_search, qdrant_upsert, qdrant_recall, qdrant_collect, qdrant_prepare, qdrant_forget (point-targeted deletion, dry-run by default, no delete-all), and md_search (local markdown knowledge index). An optional progress display (off/minimal/verbose) reports stored and recalled counts in the CLI. sentence-transformers is selectable on GPU hosts; both backends produce identical vectors for all-MiniLM-L6-v2, so switching backends needs no re-embedding. A circuit breaker (5 consecutive I/O failures, 120 s cooldown) degrades to no new memories instead of crashing, and is_available()/check_backend()/unavailable_reason() distinguish a misconfiguration from a dead server. Hybrid dense+sparse RRF search and INT8 quantization exist in _backend.py but are not on the live path. Local embedded Qdrant mode is not supported: the provider is REST-only and needs a reachable server. Disclosure — all memory content (verbatim user and assistant turns) is stored in and read back from the Qdrant server at the configured url (default http://localhost:6333); QDRANT_API_KEY is only needed for Qdrant Cloud, is kept in .env and read through the Hermes secret scope, and is never written to config.yaml or qdrant.json. Disclosure — the only other outbound traffic is a one-time download of the embedding model weights from huggingface.co (fastembed falls back to storage.googleapis.com/qdrant-fastembed if that fails); embedding runs on-device and no memory content goes to any other party. Disclosure — settings are stored in <HERMES_HOME>/qdrant.json (created 0600, no credentials) and last-operation status in <HERMES_HOME>/qdrant-status.json. Disclosure — md_search returns snippets of local markdown to the model from ~/.hermes/skills, the Obsidian vault at ~/Documents/hermes_default, ~/.hermes/hermes-agent/website and any path in the md_docs_roots key of qdrant.json; its index is built only when you run scripts/md_ingest.py and lives in <base Hermes home>/state/md-search/ (shared by all profiles). Disclosure — the optional semantic tier (md_ingest.py --semantic, and md_search queries with few lexical hits) sends embedding vectors plus file-path labels and section headings, never chunk text, to a separate hermes_md_docs collection on the Qdrant server named by the QDRANT_URL env var (default http://localhost:6333, no API key), and downloads paraphrase-multilingual-MiniLM-L12-v2 from huggingface.co on first use. Disclosure — the plugin is not a server and registers no MCP server, but it is loaded in-process into every Hermes process that embeds, so each one pays its own model copy (measured ~194-211 MB per process)."
sha: f65e62d1ab100bfad04fc47c78200f327f8b2ba1
version: "0.1.8"
image: https://raw.githubusercontent.com/kuehnberger/hermes-qdrant-memory/f65e62d1ab100bfad04fc47c78200f327f8b2ba1/docs/banner.jpg
description: "Dense semantic memory for Hermes, stored in a Qdrant collection you host. Each turn becomes one point — user and assistant text verbatim, no LLM summarization step — embedded on-device at 384 dimensions with fastembed (ONNX; the default install is 206 MB of runtime dependencies with no torch, versus ~1.2 GB when sentence-transformers was a hard dependency) and recalled by cosine search with an optional session_id payload filter, so a result quotes what was actually said. Registers 7 tools: qdrant_search, qdrant_upsert, qdrant_recall, qdrant_collect, qdrant_prepare, qdrant_forget (point-targeted deletion, dry-run by default, no delete-all), and md_search (local markdown knowledge index). An optional recall-indicator display, display.level (off | summary | verbose, default off), adds latency and collection to the chat indicator at summary and one numeric line per operation to the log at verbose — numbers and identifiers only, never message text; it is separate from the older top-level progress knob (off | minimal | verbose), which gates status events. sentence-transformers is selectable on GPU hosts; both backends produce identical vectors for all-MiniLM-L6-v2, so switching backends needs no re-embedding. A circuit breaker (5 consecutive I/O failures, 120 s cooldown) degrades to no new memories instead of crashing, and is_available()/check_backend()/unavailable_reason() distinguish a misconfiguration from a dead server. Hybrid dense+sparse RRF search and INT8 quantization exist in _backend.py but are not on the live path. Local embedded Qdrant mode is not supported: the provider is REST-only and needs a reachable server. Disclosure — all memory content (verbatim user and assistant turns) is stored in and read back from the Qdrant server at the configured url (default http://localhost:6333); QDRANT_API_KEY is only needed for Qdrant Cloud, is kept in .env and read through the Hermes secret scope, and is never written to config.yaml or qdrant.json. Disclosure — the only other outbound traffic is a one-time download of the embedding model weights from huggingface.co (fastembed falls back to storage.googleapis.com/qdrant-fastembed if that fails); embedding runs on-device and no memory content goes to any other party. Disclosure — settings are stored in <HERMES_HOME>/qdrant.json (created 0600, no credentials) and last-operation status in <HERMES_HOME>/qdrant-status.json. Disclosure — md_search returns snippets of local markdown to the model from ~/.hermes/skills, the Obsidian vault at ~/Documents/hermes_default, ~/.hermes/hermes-agent/website and any path in the md_docs_roots key of qdrant.json; its index is built only when you run scripts/md_ingest.py and lives in <base Hermes home>/state/md-search/ (shared by all profiles). Disclosure — the optional semantic tier (md_ingest.py --semantic, and md_search queries with few lexical hits) sends embedding vectors plus file-path labels and section headings, never chunk text, to a separate hermes_md_docs collection on the Qdrant server named by the QDRANT_URL env var (default http://localhost:6333, no API key), and downloads paraphrase-multilingual-MiniLM-L12-v2 from huggingface.co on first use. Disclosure — the plugin is not a server and registers no MCP server, but it is loaded in-process into every Hermes process that embeds, so each one pays its own model copy (measured ~194-211 MB per process)."
maintainer: kuehnberger
tier: community
category: memory
+6 -6
View File
@@ -1,17 +1,17 @@
name: quota
repo: https://github.com/rarf/hermes-quota-plugin
sha: 46c047b40fafea8e1baa0f367ad939a1ccd1e731
version: "2.8.1"
description: "Per-provider quota / rate-limit status via /quota command and desktop widget. Reads precomputed quota_cache.json so the widget never does network I/O. Disclosure — refreshes your Gemini OAuth token using the Gemini CLI's public client identity, in memory only; queries Copilot's internal usage endpoint with VS Code client headers and Anthropic's OAuth usage endpoint with a Claude Code User-Agent; the cursor check reads the Cursor CLI token from the macOS Keychain or Cursor's auth.json (read-only, never refreshed or rewritten; on expiry it asks you to run cursor-agent login) and queries api2.cursor.sh; the opt-in (default off) antigravity check reads Antigravity's OAuth token from Windows Credential Manager, the macOS Keychain or ~/.gemini/antigravity-cli (read-only), refreshes it in memory with Antigravity's public client identity and sends Antigravity's User-Agent to Google's cloudcode endpoint; the opt-in (default off) grok.com check reads local browser cookies, using secret-tool on Linux or the macOS Keychain for the browser's Safe Storage key and decrypting only grok.com cookies from a temporary copy of the cookie DB; the opencode-go check reads the OpenCode CLI's local auth.json (read-only); the commandcode check reads ~/.commandcode/auth.json (read-only) or Hermes' credential store and uses the Command Code CLI's User-Agent; zai, kimi, minimax, deepseek and openrouter send keys from Hermes' credential store or env only to their own vendor's usage or balance endpoints."
sha: ce8593a25ff61c32dd1e929fe2f87502cb6408cc
version: "2.10.0"
description: "Per-provider quota / rate-limit status via /quota command and desktop widget. Reads precomputed quota_cache.json so the widget never does network I/O. Disclosure — refreshes your Gemini OAuth token using the Gemini CLI's public client identity, in memory only; queries Copilot's internal usage endpoint with VS Code client headers and Anthropic's OAuth usage endpoint using the hermes-quota-plugin User-Agent; optional additional Claude accounts read access tokens only from explicitly configured Claude Code credentials files and send them only to Anthropic's fixed OAuth usage endpoint (read-only, no refresh-token exchange or credential write-back; tokens and paths excluded from the quota cache). Linux extra accounts verified; Windows experimental, extra macOS Keychain accounts unsupported. The openai-codex check reads every saved Codex account from Hermes' own auth.json credential pool (read-only, never refreshed; expired accounts show reauth-required) and queries the Codex usage endpoint with the codex-cli User-Agent; the opencode-go check reads the OpenCode CLI's local auth.json, Hermes' auth.json pool and OPENCODE_GO_API_KEY_N variables (read-only). The cursor check reads the Cursor CLI token from the macOS Keychain or Cursor's auth.json (read-only, never refreshed or rewritten; on expiry it asks you to run cursor-agent login) and queries api2.cursor.sh; the opt-in (default off) antigravity check reads Antigravity's OAuth token from Windows Credential Manager, the macOS Keychain or ~/.gemini/antigravity-cli (read-only), refreshes it in memory with Antigravity's public client identity and sends Antigravity's User-Agent to Google's cloudcode endpoint; the opt-in (default off) grok.com check reads local browser cookies, using secret-tool on Linux or the macOS Keychain for the browser's Safe Storage key and decrypting only grok.com cookies from a temporary copy of the cookie DB; the commandcode check reads ~/.commandcode/auth.json (read-only) or Hermes' credential store and uses the Command Code CLI's User-Agent; zai, kimi, minimax, deepseek, openrouter, ollama (which may read OLLAMA_API_KEY from ~/.hermes/.env and calls ollama.com/api/usage and /api/me) and experientiallabs send keys from Hermes' credential store or env only to their own vendor's usage or balance endpoints."
maintainer: rarf
tier: community
category: desktop
docs_url: https://github.com/rarf/hermes-quota-plugin#readme
readme: true
image: https://raw.githubusercontent.com/rarf/hermes-quota-plugin/46c047b40fafea8e1baa0f367ad939a1ccd1e731/docs/images/quota-banner.png
image: https://raw.githubusercontent.com/rarf/hermes-quota-plugin/ce8593a25ff61c32dd1e929fe2f87502cb6408cc/docs/images/quota-banner.png
screenshots:
- https://raw.githubusercontent.com/rarf/hermes-quota-plugin/46c047b40fafea8e1baa0f367ad939a1ccd1e731/docs/images/quota-pane.png
- https://raw.githubusercontent.com/rarf/hermes-quota-plugin/46c047b40fafea8e1baa0f367ad939a1ccd1e731/docs/images/quota-app.png
- https://raw.githubusercontent.com/rarf/hermes-quota-plugin/ce8593a25ff61c32dd1e929fe2f87502cb6408cc/docs/images/quota-pane.png
- https://raw.githubusercontent.com/rarf/hermes-quota-plugin/ce8593a25ff61c32dd1e929fe2f87502cb6408cc/docs/images/quota-app.png
capabilities:
provides_tools: []
provides_hooks:
+22
View File
@@ -0,0 +1,22 @@
name: redline
repo: https://github.com/silentpr0/hermes-redline
sha: aa0c6638e81e682bc027e5fe17f8561ad8ce1131
subdir: ""
description: "Guided Markdown review in Hermes Desktop — annotate passages in a docked pane and send comment/question/change-request batches back to the agent; cards are offered automatically on delivered Markdown. Disclosure — reads any user-selected local .md/.markdown path up to 1 MB (symlink-resolved), writes only the <document>.md.redline.json sidecar beside it, and in Hermes Desktop chats the transform_llm_output hook appends a ::redline directive line to qualifying final agent responses; no network beyond the local gateway, no credentials, no telemetry, no subprocesses; the pane polls the local backend every 3 s while open."
maintainer: silentpr0
tier: community
category: desktop
requires_hermes: ">=0.21.5"
version: "0.1.0"
image: "https://raw.githubusercontent.com/silentpr0/hermes-redline/5fc46459d7e52a41856a9775501d38a12f27fa06/docs/banner.png"
screenshots:
- "https://raw.githubusercontent.com/silentpr0/hermes-redline/5fc46459d7e52a41856a9775501d38a12f27fa06/docs/screenshot.png"
readme: true
capabilities:
provides_tools: []
provides_hooks:
- post_tool_call
- transform_llm_output
- on_session_end
provides_middleware: []
requires_env: []
+2 -2
View File
@@ -1,12 +1,12 @@
name: web-search-plus
repo: https://github.com/robbyczgw-cla/hermes-web-search-plus
sha: c8a67fd7ed7fb649355d918053069ce785e0b341
sha: e718eb439199238a2c218b02881bb5c83eea5faf
description: "Multi-provider web search, URL extraction, quality reports, and opt-in research mode. Disclosure — reads the operator's HERMES_HOME/config.yaml (read-only) for its own Desktop settings block (country, language, max_results, auto_routing, searxng_url); may spawn its own search.py and an operator-configured local donsetch CLI as subprocesses (argv, no shell)."
maintainer: robbyczgw-cla
tier: community
category: web
docs_url: https://github.com/robbyczgw-cla/hermes-web-search-plus#readme
version: "4.3.3"
version: "4.3.5"
capabilities:
provides_tools:
- web_extract_plus
+17
View File
@@ -0,0 +1,17 @@
name: wyoming-voice-bridge
repo: https://github.com/TakeshiTGAL/hermes-plugin-wyoming-voice-bridge
sha: 7be4db8548a82a8745981d47e79d1623a173a54b
description: "Wyoming speech-to-text and text-to-speech for a Wyoming server you run (stt/tts provider `wyoming`), plus a wyoming_voice_status tool that lists the server's models and voices. Disclosure — Audio and text to speak go to the WYOMING_* host:port you set over cleartext, unauthenticated TCP (public addresses accepted). Once selected, the providers send speech in every session, including cron and webhooks. Non-WAV audio is converted with your local ffmpeg. wyoming_voice_status asks for approval on each call and refuses under cron, yolo, approvals off, -q and unattended platforms; answering \"always\" leaves a command_allowlist line in config.yaml. It calls internal Hermes approval, config and write-guard helpers, so a Hermes update can disable the status tool or speech file writes (both fail closed)."
maintainer: TakeshiTGAL
tier: community
category: voice
docs_url: https://github.com/TakeshiTGAL/hermes-plugin-wyoming-voice-bridge#readme
version: "0.1.0"
requires_hermes: ">=0.21.4"
platforms: []
capabilities:
provides_tools:
- wyoming_voice_status
provides_hooks: []
provides_middleware: []
requires_env: []
+3 -2
View File
@@ -63,13 +63,14 @@ def _get_project_plugins_dir() -> Optional[Path]:
def _is_memory_provider_dir(path: Path) -> bool:
"""Cheap text heuristic (no import): ``__init__.py`` mentions the memory provider contract."""
"""Cheap text heuristic (no import): ``__init__.py`` mentions the memory provider contract.
A miss in the first 8 KB falls back to parsing, so a long module docstring cannot hide a provider."""
init_file = path / "__init__.py"
try:
if not init_file.exists():
return False
source = init_file.read_text(errors="replace", encoding="utf-8-sig")[:8192]
return "register_memory_provider" in source or "MemoryProvider" in source
return "register_memory_provider" in source or "MemoryProvider" in source or _defines_memory_provider(path)
except OSError as exc: # one mode-000 / ACL-denied child must not abort discovery
logger.warning("Skipping unreadable plugin directory %s: %s", path, exc)
return False
@@ -127,6 +127,13 @@ The ONLY import surface is `@hermes/plugin-sdk` (plus `react` /
`ctx.os.openExternal(url)`, `ctx.os.revealPath(path)`, and
`ctx.os.writeClipboard(text)` resolve `false` (never throw) when the
capability isn't available.
- `ctx.pet.say(text, { id?, tone?: 'info'|'wait'|'error', ttlMs? })` — a short
plain-text line in the core pet's speech bubble (in-window and popped out),
labelled with your plugin name; returns a disposer. 120-char cap, TTL default
6 s (1–30 s), same `id` replaces, rate-limited, cleared on unload; core
error/waiting states win; nothing shows without a visible pet
(`ctx.pet.visible`). `ctx.pet.clear(id?)`. Never locate the pet canvas in
the DOM or overlay it yourself.
- `ctx.i18n.register({ en, ja, ... })` — ship your OWN locale bundles, scoped
to your plugin (never edit core `en.ts`). Values are literal strings or
interpolator functions; nested trees are addressed by dot-path. Read them
@@ -225,3 +225,20 @@ def test_an_unserved_identifier_names_the_registry_its_adapter_accepts(identifie
from hermes_cli.skills_hub import _registry_from_identifier
assert _registry_from_identifier(identifier) == registry
@pytest.mark.parametrize(("url", "expected"), [
("n8n.example.com/mcp-server/http", "config_invalid"), # a pasted URL with no scheme
("${HERMES_TEST_UNSET_MCP_URL}", "missing_credentials"), # the URL's setup value never arrived
])
def test_an_oauth_card_install_that_fails_before_authorization_keeps_its_class(monkeypatch, url, expected):
"""Invariant: the card/agent OAuth install (``mcp_oauth.start``) rebuilds the worker's error from
its text, so it must carry the worker's closed class; the row is never the bare ``exception``."""
from tools.connectors import mcp_oauth
monkeypatch.delenv("HERMES_TEST_UNSET_MCP_URL", raising=False)
with pytest.raises(Exception) as raised:
mcp_oauth.start("n8n-probe", cfg={"url": url, "auth": "oauth"}, url_timeout=60)
dims = fields.extension_install_fields(kind="mcp_server", source="catalog", name=None, outcome="failed",
error=raised.value)
assert dims["failure_class"] == expected
@@ -221,6 +221,18 @@ def test_activation_is_not_gated_on_plugins_enabled(tmp_path, monkeypatch):
assert memory_plugins.load_memory_provider("gatedmem") is not None
def test_provider_registered_past_the_first_8kb_is_discovered(tmp_path, monkeypatch):
"""A long module docstring must not push the registration out of discovery's view."""
provider = tmp_path / "plugins" / "longdoc"
provider.mkdir(parents=True)
docstring = '"""' + "Plain prose about storage.\n" * 400 + '"""\n' # ~10.8 KB, no contract tokens
(provider / "__init__.py").write_text(docstring + PROVIDER_SOURCE.format(name="longdoc"), encoding="utf-8")
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
assert "longdoc" in memory_plugins.list_memory_provider_names()
assert memory_plugins.find_provider_dir("longdoc") == provider
def test_unreadable_user_plugin_does_not_abort_memory_discovery(tmp_path, monkeypatch):
"""One mode-000 / ACL-denied ``$HERMES_HOME/plugins/<x>`` must not hide the bundled
providers or its readable siblings from the dashboard / ``hermes memory`` pickers (#111804)."""
@@ -0,0 +1,163 @@
"""Dispatch/lossless-wait extension exercised through the real public capture API."""
import asyncio
import concurrent.futures
import json
import threading
import time
import pytest
from tests.tools.test_browser_supervisor_capture import _FakeCDP, _wait
from tools.browser_supervisor import _SupervisorRegistry
from tools.browser_supervisor_capture import CapturedCDPInvalid
class DelayedCDP(_FakeCDP):
async def _handle(self, ws):
self.connections.append(ws)
conn = len(self.connections)
async for raw in ws:
msg = json.loads(raw)
method, sid = msg['method'], msg.get('sessionId')
self.frames.append((conn, method, sid))
result = {'echo':method}
if method == 'Target.getTargets':
result = {'targetInfos':[{'targetId':'fixture-parent','type':'page','url':'about:blank'}]}
elif method == 'Target.attachToTarget':
if msg.get('params',{}).get('targetId') == 'late-child':
await asyncio.sleep(.2)
result = {'sessionId':'late-owned-session'}
else:
result = {'sessionId':'default-session'}
await ws.send(json.dumps({'id':msg['id'],'result':result}))
@pytest.fixture
def pair():
server, registry = DelayedCDP(), _SupervisorRegistry()
sup = registry.get_or_start('fixture', server.url, start_timeout=5)
try:
yield server, registry, sup, registry.capture('fixture')
finally:
registry.stop_all()
def test_unbounded_reply_retains_late_attachment_for_consumer_cleanup(pair):
server, registry, sup, handle = pair
abandoned = threading.Event()
outcomes = []
def worker():
result = handle.call('Target.attachToTarget', {'targetId':'late-child','flatten':True}, timeout=None)
outcomes.append(result['result']['sessionId'])
if abandoned.is_set():
handle.call('Target.detachFromTarget', {'sessionId':outcomes[-1]})
thread = threading.Thread(target=worker)
thread.start()
_wait(lambda: len([m for m in server.methods_on(1) if m == 'Target.attachToTarget']) == 2)
abandoned.set()
thread.join(3)
assert not thread.is_alive()
assert outcomes == ['late-owned-session']
assert server.methods_on(1).count('Target.detachFromTarget') == 1
def test_dispatch_deadline_refuses_queued_command_after_timeout(pair):
server, registry, sup, handle = pair
entered = threading.Event()
def block():
# Stall the loop past the caller's deadline but inside its +1s result grace.
entered.set()
time.sleep(.4)
sup._loop.call_soon_threadsafe(block)
assert entered.wait(1)
with pytest.raises(TimeoutError):
handle.call('Probe.expired', timeout=.1)
handle.call('Target.getTargets', timeout=2)
assert 'Probe.expired' not in server.methods_on(1)
def test_before_send_checks_cancellation_on_supervisor_loop(pair):
server, registry, sup, handle = pair
entered, release, abandoned = threading.Event(), threading.Event(), threading.Event()
failures = []
def block():
entered.set()
release.wait(4)
def check():
assert asyncio.get_running_loop() is sup._loop
if abandoned.is_set():
raise ValueError('fixture cancelled')
def caller():
try:
handle.call('Probe.cancelled', timeout=2, before_send=check)
except BaseException as exc:
failures.append(exc)
sup._loop.call_soon_threadsafe(block)
assert entered.wait(1)
thread = threading.Thread(target=caller)
thread.start()
abandoned.set()
release.set()
thread.join(3)
assert failures and isinstance(failures[0], ValueError)
assert 'Probe.cancelled' not in server.methods_on(1)
def test_unbounded_reply_fails_promptly_on_socket_close(pair):
server, registry, sup, handle = pair
failures = []
# This fixture replies to ordinary methods; block the connection handler on attach instead.
def attach():
try:
handle.call('Target.attachToTarget', {'targetId':'late-child'}, timeout=None)
except BaseException as exc:
failures.append(exc)
thread = threading.Thread(target=attach)
thread.start()
_wait(lambda: server.methods_on(1).count('Target.attachToTarget') == 2)
server.drop_current()
thread.join(3)
assert not thread.is_alive()
assert failures and isinstance(failures[0], CapturedCDPInvalid)
def test_unbounded_wait_ends_when_loop_closes_with_dispatch_queued(pair, monkeypatch):
server, registry, sup, handle = pair
import agent.async_utils as async_utils
# A stop racing call() can close the loop before its queued dispatch runs; the
# scheduled future then never resolves.
real = async_utils.safe_schedule_threadsafe
def dropped(coro, loop, **kw):
if coro.__qualname__ != 'CapturedCDP._send':
return real(coro, loop, **kw)
coro.close()
return concurrent.futures.Future()
monkeypatch.setattr(async_utils, 'safe_schedule_threadsafe', dropped)
failures = []
def caller():
try:
handle.call('Probe.dropped', timeout=None)
except BaseException as exc:
failures.append(exc)
thread = threading.Thread(target=caller)
thread.start()
registry.stop_all()
thread.join(4)
assert not thread.is_alive()
assert failures and isinstance(failures[0], CapturedCDPInvalid)
@pytest.mark.parametrize('timeout', [0, -1, float('inf'), float('nan'), 1e300, True, '5'])
def test_invalid_timeout(pair, timeout):
server, _, _, cap = pair
before = list(server.methods_on(1))
with pytest.raises(ValueError):
cap.call('Probe.invalid', timeout=timeout)
assert server.methods_on(1) == before
def test_async_validator_refuses(pair):
server, _, _, cap = pair
before = list(server.methods_on(1))
async def check():
return None
with pytest.raises(TypeError, match='synchronously'):
cap.call('Probe.async', before_send=check)
assert server.methods_on(1) == before
@@ -0,0 +1,76 @@
"""A failed text_to_speech call must never destroy a file that already lived at output_path.
The model picks ``output_path``; before this fix a provider failure swept that path as a
"partial artifact" even when it was the user's pre-existing file.
"""
import json
from pathlib import Path
import pytest
from gateway.session_context import _UNSET, _VAR_MAP
from tools import tts_tool
@pytest.fixture(autouse=True)
def _edge_without_session_platform(monkeypatch):
for var in _VAR_MAP.values():
var.set(_UNSET)
monkeypatch.delenv("HERMES_SESSION_PLATFORM", raising=False)
monkeypatch.setattr(tts_tool, "_load_tts_config", lambda: {"provider": "edge"})
monkeypatch.setattr(tts_tool, "_import_edge_tts", lambda: object())
yield
for var in _VAR_MAP.values():
var.set(_UNSET)
@pytest.fixture
def audio_dir(tmp_path):
path = tmp_path / "audio" # tmp_path also holds the conftest's isolated HERMES_HOME
path.mkdir()
return path
async def _partial_write_then_fail(_text: str, output_path: str, _cfg: dict) -> str:
Path(output_path).write_bytes(b"partial")
raise RuntimeError("provider unreachable")
async def _write_audio(_text: str, output_path: str, _cfg: dict) -> str:
Path(output_path).write_bytes(b"new-audio")
return output_path
def test_failed_synthesis_keeps_existing_file_and_cleans_partial(audio_dir, monkeypatch):
existing = audio_dir / "notes.mp3"
existing.write_bytes(b"user data")
monkeypatch.setattr(tts_tool, "_generate_edge_tts", _partial_write_then_fail)
result = json.loads(tts_tool.text_to_speech_tool("hello", output_path=str(existing)))
assert result["success"] is False
assert existing.read_bytes() == b"user data"
assert sorted(p.name for p in audio_dir.iterdir()) == ["notes.mp3"]
def test_failed_synthesis_to_new_path_leaves_nothing_behind(audio_dir, monkeypatch):
monkeypatch.setattr(tts_tool, "_generate_edge_tts", _partial_write_then_fail)
result = json.loads(tts_tool.text_to_speech_tool("hello", output_path=str(audio_dir / "new.mp3")))
assert result["success"] is False
assert list(audio_dir.iterdir()) == []
def test_successful_synthesis_still_overwrites_existing_file(audio_dir, monkeypatch):
existing = audio_dir / "notes.mp3"
existing.write_bytes(b"user data")
monkeypatch.setattr(tts_tool, "_generate_edge_tts", _write_audio)
result = json.loads(tts_tool.text_to_speech_tool("hello", output_path=str(existing)))
assert result["success"] is True
assert result["file_path"] == str(existing)
assert existing.read_bytes() == b"new-audio"
assert sorted(p.name for p in audio_dir.iterdir()) == ["notes.mp3"]
+1 -1
View File
@@ -449,7 +449,7 @@ class CDPSupervisor(DialogSupervisionMixin, FrameTrackingMixin):
await self._install_dialog_bridge(sid)
async def _cdp(self, method: str, params: Optional[dict[str, Any]] = None, *,
session_id: Optional[str] = None, timeout: float = 10.0) -> dict[str, Any]:
session_id: Optional[str] = None, timeout: Optional[float] = 10.0) -> dict[str, Any]:
"""Send a CDP command and await its response."""
if self._ws is None:
raise RuntimeError("supervisor WebSocket is not connected")
+50 -4
View File
@@ -14,7 +14,11 @@ from __future__ import annotations
import asyncio
import concurrent.futures
from typing import TYPE_CHECKING, Any, Dict, Optional
import inspect
import math
import threading
import time
from typing import TYPE_CHECKING, Any, Callable, Optional
if TYPE_CHECKING:
from tools.browser_supervisor import CDPSupervisor, _SupervisorRegistry
@@ -50,9 +54,22 @@ class CapturedCDP:
"(supervisor reconnected, stopped or was replaced); capture again")
async def _send(self, method: str, params: Optional[dict[str, Any]],
session_id: Optional[str], timeout: float) -> dict[str, Any]:
session_id: Optional[str], timeout: Optional[float],
deadline: Optional[float], before_send: Optional[Callable[[], None]]) -> dict[str, Any]:
# ``_cdp`` reaches its send without awaiting, so this check and the send run in one
# loop step: a reconnect cannot swap the socket in between.
if not self.is_valid():
raise self._invalid()
if deadline is not None and time.monotonic() >= deadline:
raise TimeoutError("Captured CDP dispatch deadline expired")
if before_send is not None:
outcome = before_send()
if outcome is not None:
if inspect.iscoroutine(outcome):
outcome.close()
raise TypeError("before_send must return None synchronously")
if deadline is not None and time.monotonic() >= deadline:
raise TimeoutError("Captured CDP dispatch deadline expired")
if not self.is_valid():
raise self._invalid()
try:
@@ -65,7 +82,8 @@ class CapturedCDP:
raise
def call(self, method: str, params: Optional[dict[str, Any]] = None, *,
session_id: Optional[str] = None, timeout: float = 10.0) -> dict[str, Any]:
session_id: Optional[str] = None, timeout: Optional[float] = 10.0,
before_send: Optional[Callable[[], None]] = None) -> dict[str, Any]:
"""Send ``method`` on the captured connection and return the raw CDP reply
(``{"id", "result"}``). ``session_id=None`` targets the browser endpoint;
pass ``page_session_id`` (or a session you attached) for page domains.
@@ -73,7 +91,27 @@ class CapturedCDP:
Raises ``CapturedCDPInvalid`` when the connection is gone, ``TimeoutError`` when no
reply arrives in ``timeout`` seconds, ``RuntimeError`` for a CDP error reply.
Blocks the calling thread; never call it from the supervisor's own loop.
A finite timeout also fences send admission while this call waits in the
supervisor queue. ``timeout=None`` deliberately retains a reply until
the connection closes: use it only in a consumer-owned worker when a
late reply (for example an attachment session ID) must not be lost.
Keep the consumer's own wait/issuance deadline finite and retain cleanup
responsibility after it expires.
``before_send`` is a trusted synchronous nonblocking validation callback
run on the supervisor loop immediately before CDP dispatch. It may raise
to refuse a cancelled/expired operation. It must not call a blocking
capture/CDP API. This is not origin or target-ownership authorization.
"""
if timeout is not None:
if (isinstance(timeout, bool) or not isinstance(timeout, (int, float))
or not math.isfinite(timeout) or timeout <= 0
or timeout > threading.TIMEOUT_MAX - 1.0):
raise ValueError("Captured CDP timeout must be positive and finite, or None")
if before_send is not None and not callable(before_send):
raise TypeError("before_send must be callable")
deadline = None if timeout is None else time.monotonic() + timeout
try:
running = asyncio.get_running_loop()
except RuntimeError:
@@ -84,9 +122,17 @@ class CapturedCDP:
raise self._invalid()
from agent.async_utils import safe_schedule_threadsafe
fut = safe_schedule_threadsafe(self._send(method, params, session_id, timeout), self._loop)
fut = safe_schedule_threadsafe(self._send(method, params, session_id, timeout, deadline, before_send), self._loop)
if fut is None:
raise self._invalid()
while timeout is None:
# Unbounded wait still ends with the loop: a closed loop drops queued callbacks.
try:
return fut.result(timeout=1.0)
except concurrent.futures.TimeoutError:
if self._loop.is_closed():
fut.cancel()
raise self._invalid() from None
try:
return fut.result(timeout=timeout + 1.0)
except concurrent.futures.TimeoutError:
+12 -2
View File
@@ -57,9 +57,11 @@ def probe_with_rollback(
server_name, cfg, connect_timeout=login_connect_timeout(cfg), details=details)
if not _oauth_tokens_present(server_name):
details["initialized"] = False
raise RuntimeError(
no_token = RuntimeError(
"The server responded, but no OAuth token was obtained — "
"this provider may require a manually-registered OAuth client.")
no_token.failure_class = "auth_required" # type: ignore[attr-defined]
raise no_token
except Exception as exc:
if not details.get("initialized"):
undo()
@@ -178,6 +180,7 @@ def run_worker(
if home_token is not None:
reset_hermes_home_override(home_token)
except Exception as exc:
from hermes_cli.mcp_config import probe_failure_class
from tools.mcp_dashboard_oauth import exception_message
msg = exception_message(exc)
with suppress(Exception):
@@ -185,7 +188,12 @@ def run_worker(
msg = humanize_oauth_registration_error(
server_name, exc, server_url=cfg.get("url") if isinstance(cfg, dict) else None
) or msg
failure_class = probe_failure_class(exc)
if failure_class == "connect_failed" and server_name not in msg: # e.g. a bare "Not Found"
msg = f"Could not connect to '{server_name}' ({msg}). Check its URL and that this machine can reach it."
if flow is not None:
# Read by ``start``: its error is rebuilt from text, so the class rides on the flow.
flow.failure_class = failure_class
flow.mark_error(msg)
finally:
if flow is not None:
@@ -340,7 +348,9 @@ def start(
auth_url=snapshot["authorization_url"], flow=flow,
detail=_ssh_detail(flow.redirect_uri) if client_redirect_uri is None else "")
if snapshot.get("status") == "error":
raise RuntimeError(snapshot.get("error") or "the OAuth flow failed before authorization")
failed = RuntimeError(snapshot.get("error") or "the OAuth flow failed before authorization")
failed.failure_class = getattr(flow, "failure_class", None) # type: ignore[attr-defined]
raise failed
time.sleep(0.05)
flow.mark_error("Timed out waiting for MCP authorization URL")
raise TimeoutError(f"timed out waiting for the authorization URL for '{server_name}'")
+4 -2
View File
@@ -472,8 +472,10 @@ def _require_rendered_remote(server_name: str, config: dict) -> dict:
unresolved = sorted({m.group(1) for value in values for m in _ENV_VAR_PATTERN.finditer(str(value))})
if unresolved:
refs = ", ".join(f"${{{ref}}}" for ref in unresolved)
raise ValueError(f"MCP server '{server_name}': {refs} in url/headers is not set in this profile's "
".env or secret source")
unset = ValueError(f"MCP server '{server_name}': {refs} in url/headers is not set in this profile's "
".env or secret source")
unset.failure_class = "missing_credentials" # type: ignore[attr-defined]
raise unset
return config
+5
View File
@@ -18,6 +18,7 @@ import logging
import os
import re
import tempfile
import uuid
from pathlib import Path
from typing import Callable, Dict, Any, List, Optional
@@ -431,6 +432,10 @@ def _synthesize_chunks(chunks: list[str], base_path: Path, generated_artifacts:
chunk_path = base_path
if len(chunks) > 1:
chunk_path = base_path.with_name(f"{base_path.stem}.chunk{index:03d}{base_path.suffix}")
if os.path.lexists(chunk_path):
# A file we didn't create: synthesize beside it so a failed attempt can neither truncate
# nor sweep it; on success the delivery step os.replace()s the audio over it (overwrite).
chunk_path = chunk_path.with_name(f".{chunk_path.stem}.{uuid.uuid4().hex}{chunk_path.suffix}")
generated_artifacts.add(str(chunk_path))
raw_result = _text_to_speech_single(chunk, str(chunk_path), **single_kwargs)
try:
@@ -116,11 +116,14 @@ title = reply["result"]["result"]["value"]
- `SUPERVISOR_REGISTRY.capture(task_id, *, timeout=10.0) -> CapturedCDP` pins the
supervisor's current WebSocket and the default page session attached on it. It
never starts, reconnects or refocuses a supervisor.
- `CapturedCDP.call(method, params=None, *, session_id=None, timeout=10.0) -> dict`
- `CapturedCDP.call(method, params=None, *, session_id=None, timeout=10.0, before_send=None) -> dict`
returns the raw CDP reply (`{"id", "result"}`). `session_id=None` addresses the
browser endpoint (`Target.*`); pass `page_session_id` or a session you attached
yourself for page domains. A CDP error reply raises `RuntimeError`; no reply
within `timeout` raises `TimeoutError`. The call blocks its thread, so call it
within a finite `timeout` raises `TimeoutError`. Finite timeouts fence queue
admission with a monotonic deadline. `timeout=None` retains the reply until
response or connection closure; acquisition owners can keep late attach IDs
for cleanup beyond their own outer deadline. The call blocks its thread, so call it
from a worker thread, never from the supervisor's own event loop.
- `CapturedCDP.is_valid()` stays true until the supervisor reconnects, stops, or
is replaced in the registry. After that every `call` raises
@@ -131,7 +134,11 @@ title = reply["result"]["result"]["value"]
`focus_page` on the supervisor does not change it.
- Sessions you attach (`Target.attachToTarget`) are yours to detach. A reply
that arrives after `timeout` is dropped, so a timed-out attach can leave a
session attached until the connection closes; give attaches a generous timeout.
session attached until the connection closes. Use `timeout=None` with a retained
acquisition worker to own and detach late replies, rather than guessing a large timeout.
- `before_send` is a trusted synchronous, nonblocking validator on the supervisor
loop immediately before dispatch. Return `None`, or raise to refuse; do not call
capture/CDP APIs from it. It does not revoke commands already dispatched.
This is a trusted, in-process seam. It grants nothing in-process Python could
not already reach, and it enforces no origin, consent or target-ownership
@@ -198,6 +198,8 @@ interface PluginContext {
addEventListener: (target: EventTarget, type: string, listener: EventListener, options?: AddEventListenerOptions | boolean) => () => void
/** The curated OS door: native notification, open-external, reveal-in-file-manager, clipboard. */
os: PluginOs
/** Lines in the core pet's speech bubble, attributed to this plugin (see "Pet bubble"). */
pet: PluginPet
/** Plugin-scoped JSON persistence (keys live under `hermes.plugin.<id>.`). */
storage: PluginStorage
}
@@ -543,7 +545,8 @@ A multi-session plugin keeps its per-session state on its side (which session
its panel is editing) and passes that id here; the bus guarantees one
plugin write can never land in another session's composer.
**Migrating off DOM reach-in** (the held catalog plugins that motivated this API):
**Migrating off DOM reach-in** (the held catalog plugins that motivated this API
and its siblings; the pet-wallet row uses the [pet bubble](#pet-bubble) door):
| Plugin | Was | Now |
|---|---|---|
@@ -552,6 +555,7 @@ plugin write can never land in another session's composer.
| prompt-enhancer (#116031) | walks the editor's child nodes to serialize, rebuilds chip DOM, `replaceChildren` + synthetic `InputEvent` | `const draft = await host.composer.getDraft(sid)` → transform → `await host.composer.setDraft(sid, enhanced)` (chips hydrate app-side); revert is another `setDraft` |
| memory-review (#115966) | `host.request('slash.exec', { session_id, command })` for `/memory …` — already SDK-only | optional: `host.composer.insertText(sid, '/memory pending', { mode: 'prefix' })` to seat the command for the user instead of executing it |
| intelligent-tool-break (#115964) | "Message" button only toasts "type /break" (no composer write) | `host.composer.setDraft(host.state.focusedSessionId.get(), '/break ')` then `host.composer.focus(null)` restores the intended behaviour |
| pet-wallet (#135178) | `document.querySelector('canvas[aria-label$=" pet"]')` to find the core pet, a `position:fixed; z-index:9999` overlay on `document.body` that follows it every frame, document-wide capture-phase pointer listeners | `ctx.pet.say(text, { id: 'balance', tone?, ttlMs? })` — the core bubble shows it over the pet, in-window and popped out, labelled with the plugin name; `ctx.pet.visible` tells you when there is no pet so you can fall back to your status-bar chip |
`sessionId` in the table is the id the plugin's UI is bound to; for a composer
slot render it is `host.state.focusedSessionId.get()`.
@@ -1092,6 +1096,9 @@ ctx.os.notify({ title, body?, silent?, icon?, activate?, onActivate?, actions? }
ctx.os.openExternal(url) // OS default handler (browser, mail, spotify:) → Promise<boolean>
ctx.os.revealPath(path) // reveal in Finder / Explorer → Promise<boolean>
ctx.os.writeClipboard(text) // system clipboard → Promise<boolean>
ctx.pet.say(text, { id?, tone?, ttlMs? }) // line in the core pet's speech bubble → disposer
ctx.pet.clear(id?) // drop one line (or all of yours)
ctx.pet.visible // ReadableAtom<boolean> — is a pet on screen?
host.navigate('/route') // hash-route navigation
host.openSession(id, { profile?, intent? }) // open a stored session core-style;
// profile: soft-swap to that profile's backend first
@@ -1246,6 +1253,70 @@ The other doors (`openExternal`, `revealPath`, `writeClipboard`) resolve
`false` instead of throwing when the capability isn't available (older desktop
shell, plain browser) — branch on the result rather than sniffing the bridge.
### Pet bubble — `ctx.pet` {#pet-bubble}
The core pet (the petdex mascot, in-window or popped out into its own OS
window) has a speech bubble. `ctx.pet` lets a plugin put a short line in it,
so you never have to find the pet in the app DOM or float your own overlay
over it:
```ts
register(ctx) {
// Shows "DeepSeek ¥12.40 left" over the pet for 8 s, labelled "Pet Wallet".
const dispose = ctx.pet.say('DeepSeek ¥12.40 left', { id: 'balance', ttlMs: 8000 })
// Same id → replaces the line in place (a refreshed balance, a countdown).
ctx.pet.say('DeepSeek ¥11.90 left', { id: 'balance' })
// Tones: 'info' (default), 'wait' (clock glyph), 'error' (alert glyph).
ctx.pet.say('Codex 5h quota at 90%', { id: 'quota', tone: 'wait' })
dispose() // remove early, or
ctx.pet.clear('quota') // by id, or ctx.pet.clear() for all of yours
// No pet on screen? Fall back to your own status-bar chip or pane.
const visible = ctx.pet.visible.get()
}
```
```ts
interface PluginPet {
say(text: string, options?: { id?: string; tone?: 'info' | 'wait' | 'error'; ttlMs?: number }): () => void
clear(id?: string): void
visible: ReadableAtom<boolean>
}
```
What the host guarantees, so you don't have to:
- **Plain text.** Control characters and bidi overrides are stripped,
whitespace collapses to one line, and the text is capped at 120 characters.
It renders as a text node; markup shows literally.
- **Attributed.** Your plugin's name (from the Plugins inventory) is printed
above the line, so the user can tell your words from the pet's.
- **Short-lived.** Each line expires after `ttlMs` (default 6 s, clamped to
1–30 s). Re-`say` with the same `id` to keep a value up. At most 3 lines per
plugin are live (the oldest is evicted) and the bubble shows the newest
line from any plugin.
- **Rate-limited.** 10 `say` calls per plugin per 10 s; extra calls are dropped
with a console warning and return a no-op disposer.
- **Core first.** When the agent hits an error or is waiting on the user, the
core status bubble wins; your line shows again once that clears (if it
hasn't expired).
- **The user's pet setting wins.** If no pet is adopted, or it is turned off,
nothing shows. `ctx.pet.visible` lets you branch on that.
- **Cleaned up with you.** Disabling, unloading, or hot-reloading your plugin
removes every line it still has up.
In-window, the bubble appears only for plugin lines (the app itself shows the
agent's status). In the popped-out overlay, plugin lines share the bubble with
the core status lines. The overlay is a separate window that loads no plugin
code; the main window sends it the live lines with the rest of the pet state.
Pointer input on the pet (drag, shift-click pop-out, overlay click) belongs to
the host and has no plugin hook. Use a palette command, a status-bar item, or
your own pane for actions.
### Desktop appearance settings — `host.settings`
`host.settings` is the supported door for the small set of Desktop-local
@@ -1828,7 +1899,7 @@ pipeline as a trust boundary.
| Category | Exports |
|----------|---------|
| Host | `host` (`.state.*`, `.settings`, `.notify`, `.notifyError`, `.navigate`, `.onEvent`, `.logs`, `.status`, `.restartGateway`, `.request`, `.composer`, `.sessions`, `.skills`, `.toolsets`, `.profiles`, `.pluginDecisions`) |
| Plugin contract | `HermesPlugin`, `PluginContext`, `PluginContribution`, `PluginStorage`, `PluginOs`, `PluginRestOptions`, `PluginNativeNotificationInput`, `PluginNotificationAction`, `HermesOpenTarget`, `Contribution` |
| Plugin contract | `HermesPlugin`, `PluginContext`, `PluginContribution`, `PluginStorage`, `PluginOs`, `PluginPet`, `PetSayOptions`, `PetMessageTone`, `PluginRestOptions`, `PluginNativeNotificationInput`, `PluginNotificationAction`, `HermesOpenTarget`, `Contribution` |
| Area constants | `PANES_AREA`, `ROUTES_AREA`, `SIDEBAR_NAV_AREA`, `STATUSBAR_AREAS`, `TITLEBAR_AREAS`, `WORKSPACE_PAGE_HEADER_AREA`, `PALETTE_AREA`, `KEYBINDS_AREA`, `THEMES_AREA`, `COMPOSER_AREAS`, `MODEL_MENU_ROW_AREA`, `SESSION_ROW_AREAS`, `SIDEBAR_NAV_PREFS_AREA`, `APPEARANCE_AREAS`, `SETTINGS_PLUGINS_AREA` |
| Area payloads | `PluginSettingsPage`, `PluginSettingsSubpage` (+ `pluginSettingsHref`), `RouteContribution`, `SidebarNavContribution`, `StatusbarItem`, `TitlebarTool`, `PaletteContribution`, `KeybindContribution`, `ComposerMiddleware`, `ComposerAttachmentProvider`, `SessionRowSlotContribution`, `SidebarNavPrefsContribution` |
| React / state | `useValue`, `atom`, `computed`, `useQuery`, `useMutation`, `useQueryClient`, `queryClient`, `Contribute`, `WorkspacePageHeaderControl` |