Los plugins de escritorio por fin pueden pulsar los botones de la propia app: ctx.runAction y ctx.pet.say


Escribiste un plugin de escritorio que añade un botón en la barra de título para abrir el panel de navegador integrado. La implementación era una línea: despachar un evento de teclado sintético Ctrl+Shift+L sobre document.body. Dos semanas después, un usuario informa de que el botón no hace nada: reasignó ese atajo, o lo desvinculó del todo. Tu botón sigue ahí, el mundo ha cambiado y no reporta ningún error.

Ese es un tipo de fragilidad muy típico en un ecosistema de plugins: para llamar a funciones propias del host tenías que fingir que el usuario había pulsado una tecla. La misma ventana produjo un ejemplo hermano: un plugin que mostraba saldos de cuota pintaba burbujas sobre la mascota del núcleo encontrándola con document.querySelector('canvas[aria-label$=" pet"]'), añadía una capa de posición fija y seguía a la mascota fotograma a fotograma. Basta un cambio de marcado en el núcleo y se rompe.

Dos cambios fusionados en esta ventana convierten ambos casos en APIs soportadas.

ctx.runAction: la puerta principal para las acciones de la app

const result = ctx.runAction('view.showBrowser')
// → { ok: true }

Lo importante es qué handler se ejecuta. useKeybinds ya mantiene la única tabla de handlers por la que despachan los atajos. Esa tabla ahora también se registra en el ejecutor de acciones integradas (lib/keybinds/plugin-actions.ts), y ctx.runAction busca el handler ahí cuando se le llama, así que una llamada de plugin y una pulsación de tecla comparten un mismo camino de código, y la vinculación de teclas nunca se consulta. Reasigna lo que quieras; el botón sigue funcionando.

Una llamada devuelve una de dos formas y nunca lanza excepción:

ctx.runAction('view.showBrowser')   // → { ok: true }
ctx.runAction('session.archive')    // → { ok: false, reason: 'denied',   error: "'session.archive' is not available to plugins. …" }
ctx.runAction('app.quit')           // → { ok: false, reason: 'unknown',  error: "'app.quit' is not a Hermes app action. …" }
ctx.runAction('view.showBrowser')   // → { ok: false, reason: 'unavailable' }  // app shell not mounted yet

reason tiene tres valores: unknown (no es un id de acción válido), denied (válida, pero no disponible para plugins), unavailable (demasiado pronto en el arranque: el shell no está montado). Cada rechazo además registra un aviso [plugin:<id>] runAction: …, así que puedes ver exactamente qué plugin fue rechazado.

La API vive en el ctx por plugin (como ctx.pet y ctx.os) y no en el host global, porque un rechazo tiene que atribuirse a un plugin. Detectar un host de versión antigua es una sola comprobación:

if (ctx.runAction) { /* newer host */ }

Los tipos del SDK vienen con esto: @hermes/plugin-sdk exporta PluginAppActionId (una unión de los ids permitidos), PluginAppActionInfo, PluginRunActionResult y la lista en runtime PLUGIN_APP_ACTIONS. Para una lista enumerable en runtime, pregúntale al host:

ctx.listActions()
// → [{ id: 'view.showBrowser', label: 'Toggle browser', category: 'view' }, …]

La allowlist es solo vistas y navegación

Las acciones permitidas son deliberadamente estrechas: «mira cosas y ve a sitios»:

view.showBrowser, view.showFiles, view.toggleSidebar, view.toggleRightSidebar, view.toggleReview, view.toggleStatusbar, view.findInPage, composer.focus, session.new, session.focusSearch, conversation.scrollPageUp, conversation.scrollPageDown, nav.commandPalette, nav.settings, nav.profiles, nav.capabilities, nav.messaging, nav.artifacts, nav.cron, nav.agents, keybinds.openPanel.

Rechazadas: archivar una sesión, fijar, cambiar de perfil, el selector de modelo, el nivel de razonamiento, voz y dictado, terminales, ventana nueva, abrir carpeta, worktree, el HUD, cerrar o reabrir una pestaña. Las operaciones destructivas y privilegiadas no se exponen en absoluto: eliminar una sesión, cerrar sesión, aprobaciones, cambios de modelo o provider, actualizar, salir.

El razonamiento es simple: un plugin puede mover la interfaz a donde te guste, pero no puede tomar decisiones irreversibles por ti. Un plugin de terceros que haga clic por error en la eliminación de una sesión no es algo que puedas explicarle a un usuario.

ctx.pet.say: deja que la burbuja pet del núcleo hable por el plugin

const dispose = ctx.pet.say('DeepSeek ¥12.40 left', { id: 'balance', tone: 'info', ttlMs: 8000 })
ctx.pet.say('DeepSeek ¥11.90 left', { id: 'balance' })  // same id replaces the line in place
ctx.pet.clear('balance')   // remove one line; ctx.pet.clear() removes all of yours
ctx.pet.visible            // ReadableAtom<boolean>: is a pet on screen?

El host dibuja la burbuja, y el plugin nunca toca el DOM de la app, que es precisamente la ventaja frente a «busca la mascota y pinta tu propia capa»: las líneas siguen funcionando cuando la mascota se abre en su propia ventana.

El host impone límites para que esta esquina de la pantalla no se pueda abusar:

  • Solo texto plano. Se eliminan caracteres de control y anulaciones bidi, el texto se colapsa a una línea y se limita a 120 caracteres, y se renderiza como un nodo de texto de React.
  • Con etiqueta. El nombre del plugin del inventario de Plugins aparece como una pequeña etiqueta encima de la línea.
  • Efímeras y acotadas. Las líneas caducan a los 6 segundos por defecto (ajustable a 1–30 s). Cada plugin puede tener hasta 3 líneas vivas y 10 llamadas a say por cada 10 segundos; las llamadas extra se descartan con un aviso en consola.
  • El estado del núcleo manda. Cuando el agente tiene un error o te está esperando, la burbuja del núcleo toma el control; la línea del plugin vuelve después si no ha caducado.
  • Sigue la configuración de mascota del usuario. Sin mascota, o con la mascota desactivada, no se muestra nada.
  • Limpieza. Desactivar, descargar o recargar en caliente un plugin elimina todas sus líneas.
  • Sin modelo de permisos nuevo. ctx.pet está acotado y atribuido por plugin, igual que ctx.os.

Cómo se ve un plugin de botón completo

// desktop/plugin.js
export default {
  id: 'browser-toggle',
  titlebarButtons: [
    {
      label: 'Browser',
      onClick(ctx) {
        const r = ctx.runAction('view.showBrowser')
        if (!r.ok) {
          ctx.pet.say(`browser: ${r.reason}`, { id: 'btn', tone: 'warn', ttlMs: 4000 })
        }
      },
    },
  ],
}

La implementación antigua era un evento de tecla sintético. Esta reporta por qué falló, y puede avisar al usuario sin un modal.

Cuándo puedes usarlo

Ambos cambios aterrizaron hace poco, y el cuerpo del release v0.21.7 los enumera entre los cambios de esta ventana (no los detalla ahí y pospone las notas curadas a v0.22.0). La app de escritorio se publica con su propia cadencia de empaquetado, así que:

  • Instalaciones desde fuente y entornos de desarrollo de escritorio que siguen main: disponibles ya.
  • Escritorio de Store / MSIX / empaquetado: espera la siguiente actualización de esa build. Detecta su presencia con ctx.runAction?.(…) para que un mismo plugin funcione en ambos hosts.

La documentación también se actualizó: website/docs/developer-guide/desktop-plugin-sdk.md ganó una sección Run app actions — ctx.runAction con la API, un ejemplo y la tabla de allowlist, y la referencia desktop-plugins.md de la skill hermes-agent incluida lleva la misma nota.

Advertencias

  • Deja de despachar eventos de teclado sintéticos para disparar funciones de la app. Ese es justo el patrón que se está retirando: muere cuando el usuario reasigna y falla en silencio.
  • Maneja los rechazos. ok: false no es una excepción: ignorarlo solo le entrega el bug a tus usuarios.
  • La burbuja pet no es un panel de log. 120 caracteres, como mucho 3 líneas, 6 segundos por defecto: es un estado de un vistazo, no un canal de salida.
  • La allowlist cambia entre versiones. Léela en runtime con ctx.listActions() en lugar de fijarla en la documentación o en aserciones de tipos.

Para construir un plugin de escritorio desde cero, consulta la guía del SDK de plugins de escritorio; para saber de dónde salen los plugins y cómo se revisan, mira la guía del catálogo de plugins; el modelo de seguridad está en aislamiento de plugins y la frontera del host.