Un archivo, un panel: guía práctica del SDK de plugins de Hermes Desktop


Te pasas el día en la ventana de Hermes Desktop y, tarde o temprano, aparece un pequeño deseo: un panelito que muestre si los trabajos de cron de hoy se ejecutaron de verdad, o un botón en la barra de estado que siga los registros cuando lo pulsas. Hasta hace poco ese deseo tenía dos respuestas: vivir sin él, o irte a leer el código fuente de la aplicación, que es una base de código grande y poco amable con terceros.

Ahora hay una tercera opción: escribir un archivo, dejarlo en un directorio y tenerlo funcionando unos segundos después. Cada superficie de la ventana de Hermes Desktop —paneles de diseño, navegación lateral, la barra de estado, herramientas de la barra de título, entradas de la paleta ⌘K, atajos de teclado, temas— se registra en un único registro, y la propia aplicación registra su interfaz exactamente igual que lo hace un plugin. Los plugins no son un añadido: son la forma en que está construida la aplicación.

Tres minutos para tu primer plugin

Aquí tienes el ejemplo completo más corto. Crea un archivo:

// $HERMES_HOME/desktop-plugins/hello/plugin.js  (that's ~/.hermes/desktop-plugins/hello/plugin.js by default)
import { host, haptic, useValue } from '@hermes/plugin-sdk'
import { jsx, jsxs } from 'react/jsx-runtime'

function HelloPane() {
  const gateway = useValue(host.state.gateway)

  return jsxs('div', {
    className: 'flex h-full flex-col gap-2 p-3 text-sm',
    children: [
      jsx('div', { className: 'font-medium', children: 'Hello, Hermes' }),
      jsx('div', {
        className: 'text-(--ui-text-tertiary)',
        children: `gateway: ${gateway}`
      })
    ]
  })
}

export default {
  id: 'hello', // must match the folder name
  name: 'Hello',
  register(ctx) {
    // a 260px pane docked to the right
    ctx.register({
      id: 'pane',
      area: 'panes',
      title: 'hello',
      data: { placement: 'right', width: '260px' },
      render: () => jsx(HelloPane, {})
    })
    // a small button in the right side of the status bar
    ctx.register({
      id: 'chip',
      area: 'statusBar.right',
      order: 130,
      render: () =>
        jsx('button', {
          type: 'button',
          className: 'px-1.5 text-[0.6875rem] text-(--ui-text-tertiary)',
          onClick: () => {
            haptic('tap')
            host.notify({ kind: 'info', message: 'Hello from my plugin!' })
          },
          children: 'hello'
        })
    })
  }
}

Guárdalo. La aplicación vigila desktop-plugins/, carga el archivo en unos segundos y recarga en caliente cada guardado posterior sin perder el estado. Si no aparece nada, pulsa ⌘K y elige Recargar plugins de escritorio. Si la carga falla, recibes un aviso que nombra el error: corrígelo y vuelve a guardar. Un plugin roto puede lanzar una excepción, pero no puede tumbar la aplicación.

El contrato: dos interfaces y ya está

Un plugin exporta por defecto un único objeto:

interface HermesPlugin {
  id: string            // stable slug — the `plugin:<id>` source tag and the id namespace
  name?: string         // shown in Settings; defaults to id
  defaultEnabled?: boolean  // defaults to true; false ships it opt-in (inventoried, switched off)
  register: (ctx: PluginContext) => void
}

register recibe un contexto con alcance. Nunca toca el registro directamente: el contexto marca la procedencia (source: 'plugin:<id>') y da un espacio de nombres a cada id de contribución (<id>:<localId>), así que dos plugins nunca pueden colisionar.

interface PluginContext {
  readonly source: string                                    // e.g. 'plugin:hello'
  register: (c: PluginContribution) => () => void            // register one, returns a disposer
  registerMany: (cs: PluginContribution[]) => () => void      // register many at once
  rest: <T>(path, opts?) => Promise<T>                       // REST to your own /api/plugins/<id>
  socket: (path, onMessage) => () => void                    // live WebSocket in the same namespace
  os: PluginOs                                                // native notification, open-external, reveal, clipboard
  storage: PluginStorage                                      // plugin-scoped persistence (hermes.plugin.<id>.*)
}

Una contribución es la primitiva que comparten todas las superficies:

interface Contribution {
  id: string            // your local id; the host namespaces it
  area: string          // WHERE it goes (see the area table below)
  title?: string
  order?: number         // sort within the area, lower = earlier
  when?: () => boolean   // dynamic visibility, re-evaluated by the area
  enabled?: boolean
  render?: () => ReactNode  // the component to mount
  data?: unknown            // area-specific payload
}

Tú aportas render, data o ambos, según la superficie.

Dónde puede ir cada cosa

Superficie area Qué aportas
Panel de diseño (acoplable, arrastrable) PANES_AREA ('panes') title + render + data (placement, dock, width, height)
Ruta de página completa ROUTES_AREA data (path) + render
Elemento de navegación lateral SIDEBAR_NAV_AREA data (path, label, codicon)
Barra de estado STATUSBAR_AREAS.left / .right render, o data como un StatusbarItem
Barra de título TITLEBAR_AREAS.left / .center / .right data como un TitlebarTool, o un <Contribute> con alcance
Paleta ⌘K PALETTE_AREA data como un PaletteContribution
Atajo de teclado KEYBINDS_AREA data como un KeybindContribution
Tema THEMES_AREA data como un DesktopTheme
Composer COMPOSER_AREAS.* slots de render, o middleware / proveedores de adjuntos

El placement de un panel es su rol semántico (main, left, right, top, bottom): se apila como pestañas con los paneles existentes de ese rol, y después el usuario puede arrastrarlo donde quiera. Para colocarlo en un borde concreto en lugar de apilarlo, añade un gesto dock, lo mismo que arrastrar un panel a mano sobre un chip de acoplamiento.

Tres cosas que conviene saber antes de escribir más

Uno: no escribas sintaxis JSX. El archivo en disco se carga sin compilar, así que <div> nunca llega a un parser. Escribe la interfaz con llamadas jsx() / jsxs() de react/jsx-runtime (o React.createElement), como en el ejemplo anterior. Es más verboso, y es lo que te da el «sin paso de compilación».

Dos: exactamente tres especificadores importables. @hermes/plugin-sdk, react y react/jsx-runtime: todo lo demás falla al resolverse, a propósito. Un lint lo impone: importar internals de la aplicación (@/…) desde un plugin es un error, y la corrección correcta pasa por añadir la capacidad al SDK.

Tres: los plugins de escritorio son de la aplicación, no tienen alcance por perfil. Hay un único directorio raíz y se aplica a todos los perfiles, gateways y máquinas remotas a las que se conecta la ventana. Si un plugin necesita comportarse de forma distinta según el perfil, que lea él mismo el perfil activo.

Usa el propio lenguaje de diseño de la aplicación

El ui.* del SDK te da los componentes reales de la aplicación, sus variables de tema, sus iconos y sus formateadores. Eso importa más de lo que parece: si te montas tu propio estilo a mano, el plugin parecerá ajeno en una ventana donde todo lo demás coincide al píxel. Usa ui y los colores y el espaciado seguirán el tema activo: text-(--ui-text-tertiary) en el ejemplo es una variable de tema que cambia cuando el usuario cambia de tema.

¿Quieres un backend? Tienes un espacio de nombres

Las llamadas rest y socket de un plugin van a su propio espacio de nombres, /api/plugins/<id>. Deja un plugin_api.py en el paquete del plugin y ese espacio de nombres cobra vida. (Solo esta capa de backend se comparte entre los SDK de escritorio y del panel web; sus API de front-end son totalmente independientes, así que no las mezcles.) Eso le da al plugin su propio estado en proceso, sus archivos y conexiones de gateway de larga duración sin meter lógica dentro de render. React Query también viene incluido, así que no hay que rehacer la obtención, el caché ni la invalidación de datos.

Tres modos de entrega

Modo Dónde Quién Paso de compilación
Disco (recomendado) $HERMES_HOME/desktop-plugins/<id>/plugin.js usuarios, agentes ninguno: ESM plano, cargado sin compilar
Paquete unificado $HERMES_HOME/plugins/<id>/desktop/plugin.js plugins que también distribuyen código del lado del agente ninguno: el mismo pipeline de disco
Integrado apps/desktop/src/plugins/<id>/plugin.tsx en el árbol del repositorio, se distribuye con la aplicación sí: el propio build de Vite de la aplicación

Los tres usan el mismo contrato HermesPlugin, los tres aparecen en Capacidades → Plugins y los tres se activan y desactivan en caliente. Para ver implementaciones de referencia, el repositorio compañero oficial es hermes-example-plugins. La propia Radio de la aplicación (streams en directo, búsqueda de emisoras, controles de reproducción en la barra de estado y una forma de onda reactiva al audio) se distribuye como plugin integrado que solo usa el SDK, desactivado por defecto: actívalo en Capacidades → Plugins para ver hasta dónde puede llegar un plugin.

La frontera de seguridad: esto no es un sandbox

La documentación es inusualmente directa al respecto, y vale la pena reproducirlo tal cual: un plugin cargado se evalúa como ESM en el realm del renderer con toda la autoridad de la aplicación: el singleton de React, todo el SDK (host.request con el JSON-RPC del gateway, ctx.rest, el almacenamiento, navigate). El aislamiento que hay aquí es aislamiento de errores (fronteras de contribución, listeners aislados), lo que significa que un plugin no puede tumbar la aplicación, pero puede hacer cualquier cosa que haga la aplicación.

Para fuentes locales eso es aceptable, porque un archivo de tu disco ya puede ejecutar código. Pero es justo la razón por la que una fuente remota (https + lista de permitidos) no debe reutilizar este pipeline tal cual: necesita una frontera real (iframe o worker, más CSP y control de capacidades) antes de poder publicarse. La opción { integrity } es la costura del transporte, no la de la confianza.

Así que instala solo plugins que estés dispuesto a ejecutar en tu máquina. Si quieres saber qué está haciendo el proyecto con la procedencia y la revisión de plugins, lee la entrada sobre el catálogo de plugins junto a este artículo: el catálogo responde a «de dónde salió esto, si se revisó y si estos son los bytes originales».

Por dónde seguir

Una nota honesta sobre los tiempos: este SDK es joven. Que Radio se distribuya como plugin integrado del SDK y la remodelación de la página de Plugins aterrizaron a pocos días de este artículo, lo que significa que la superficie todavía puede moverse, y también que un plugin pequeño que funcione hoy probablemente sea uno que a nadie más se le ha ocurrido escribir.