Les plugins desktop peuvent enfin appuyer sur les propres boutons de l'application : ctx.runAction et ctx.pet.say


Vous avez écrit un plugin desktop qui ajoute un bouton dans la barre de titre, lequel ouvre le panneau navigateur intégré. L’implémentation tenait en une ligne : émettre un événement clavier synthétique Ctrl+Shift+L sur document.body. Deux semaines plus tard, un utilisateur signale que le bouton ne fait rien — il a rebindé ce raccourci, ou l’a carrément supprimé. Votre bouton est toujours là, le monde a bougé, et il ne rapporte aucune erreur.

C’est un type de fragilité très courant dans un écosystème de plugins : pour appeler les fonctionnalités de l’hôte, il fallait faire comme si l’utilisateur avait appuyé sur une touche. La même fenêtre a produit un cas jumeau : un plugin affichant des soldes de quota peignait des bulles par-dessus le pet du core en le trouvant avec document.querySelector('canvas[aria-label$=" pet"]'), ajoutait une couche en position fixe et suivait le pet à chaque image. Un seul changement de balisage dans le core et tout casse.

Deux changements fusionnés dans cette fenêtre transforment ces deux bricolages en API prises en charge.

ctx.runAction : la porte d’entrée des actions de l’application

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

Le point important est quel handler s’exécute. useKeybinds détient déjà l’unique table de handlers par laquelle les raccourcis sont dispatched. Cette table est désormais aussi enregistrée auprès du runner d’actions intégré (lib/keybinds/plugin-actions.ts), et ctx.runAction y cherche le handler au moment de l’appel — ainsi un appel de plugin et une pression de touche partagent un même chemin de code, et la liaison de touche n’est jamais consultée. Rebindex ce que vous voulez ; le bouton continue de fonctionner.

Un appel renvoie l’une de deux formes et ne lève jamais d’exception :

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 a trois valeurs : unknown (id d’action invalide), denied (valide, mais non disponible pour les plugins), unavailable (trop tôt au démarrage — le shell n’est pas encore monté). Chaque refus consigne aussi un avertissement [plugin:<id>] runAction: …, de sorte que vous voyez exactement quel plugin a été refusé.

L’API vit sur le ctx propre au plugin (comme ctx.pet et ctx.os) plutôt que sur le host global, car un refus doit être attribué à un plugin. Détecter un hôte plus ancien se fait en une vérification :

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

Les types du SDK l’accompagnent : @hermes/plugin-sdk exporte PluginAppActionId (une union des ids autorisés), PluginAppActionInfo, PluginRunActionResult, et la liste à l’exécution PLUGIN_APP_ACTIONS. Pour une liste énumérable à l’exécution, demandez à l’hôte :

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

L’allowlist se limite aux vues et à la navigation

Les actions autorisées sont volontairement étroites — « regarder des choses et se déplacer » :

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.

Refusées : archiver une session, l’épinglage, le changement de profile, le sélecteur de modèle, le niveau de raisonnement, la voix et la dictée, les terminaux, une nouvelle fenêtre, l’ouverture de dossier, le worktree, le HUD, la fermeture ou la réouverture d’un onglet. Les opérations destructrices et privilégiées ne sont pas exposées du tout : supprimer une session, se déconnecter, les approbations, les changements de modèle ou de provider, la mise à jour, quitter.

Le raisonnement est simple : un plugin peut déplacer l’interface là où vous le souhaitez, mais il ne lui appartient pas de prendre des décisions irréversibles à votre place. Un plugin tiers qui rate un clic et supprime une session, voilà qui ne s’explique pas à un utilisateur.

ctx.pet.say : laisser la bulle pet du core parler pour le 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?

C’est l’hôte qui dessine la bulle, et le plugin ne touche jamais au DOM de l’application — c’est précisément l’avantage par rapport à « trouver le pet et peindre sa propre couche » : les lignes fonctionnent encore quand le pet est détaché dans sa propre fenêtre.

L’hôte impose des limites pour que ce coin de l’écran ne soit pas détourné :

  • Texte brut uniquement. Les caractères de contrôle et les inversions bidi sont retirés, le texte est ramené à une seule ligne et plafonné à 120 caractères, et il est rendu comme un nœud texte React.
  • Étiquetée. Le nom du plugin, issu de l’inventaire des plugins, apparaît comme une petite étiquette au-dessus de la ligne.
  • Éphémère et bornée. Les lignes expirent au bout de 6 secondes par défaut (bornées à 1–30 s). Chaque plugin peut avoir jusqu’à 3 lignes vivantes et 10 appels say par tranche de 10 secondes ; les appels excédentaires sont abandonnés avec un avertissement dans la console.
  • Le statut du core gagne. Quand l’agent a une erreur ou attend votre réponse, la bulle du core prend la main ; la ligne du plugin revient ensuite si elle n’a pas expiré.
  • Suit le réglage de pet de l’utilisateur. Pas de pet, ou pet désactivé, et rien ne s’affiche.
  • Nettoyée. Désactiver, décharger ou recharger à chaud un plugin retire toutes ses lignes.
  • Pas de nouveau modèle de permission. ctx.pet est limité et attribué par plugin, exactement comme ctx.os.

À quoi ressemble un plugin à bouton complet

// 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 })
        }
      },
    },
  ],
}

L’ancienne implémentation était un unique événement clavier synthétique. Celle-ci indique pourquoi elle a échoué — et peut le dire à l’utilisateur sans modale.

Quand pouvez-vous l’utiliser

Les deux changements sont arrivés récemment, et le corps de la release v0.21.7 les liste parmi les changements de cette fenêtre (il ne les y détaille pas, et renvoie les notes éditorialisées à v0.22.0). L’application desktop suit sa propre cadence de packaging, donc :

  • Installations depuis les sources et configurations desktop suivant main : disponibles dès maintenant.
  • Desktop Store / MSIX / fourni : attendez la prochaine mise à jour de ce build. Prévoyez le cas avec ctx.runAction?.(…) pour qu’un même plugin fonctionne sur les deux hôtes.

La doc a été mise à jour aussi : website/docs/developer-guide/desktop-plugin-sdk.md a gagné une section Run app actions — ctx.runAction avec l’API, un exemple et la table d’allowlist, et la référence desktop-plugins.md du skill hermes-agent fourni porte la même note.

Réserves

  • Cessez d’émettre des événements clavier synthétiques pour déclencher les fonctionnalités de l’application. C’est exactement le schéma que l’on abandonne : il meurt quand un utilisateur rebinde, et il échoue en silence.
  • Gérez les refus. ok: false n’est pas une exception — l’ignorer ne fait que refiler le bug à vos utilisateurs.
  • La bulle pet n’est pas un panneau de logs. 120 caractères, au plus 3 lignes, 6 secondes par défaut : c’est un statut d’un coup d’œil, pas un canal de sortie.
  • L’allowlist change d’une version à l’autre. Lisez-la à l’exécution avec ctx.listActions() au lieu de la coder en dur dans la doc ou des assertions de type.

Pour construire un plugin desktop de zéro, voir le guide du SDK de plugins desktop ; pour savoir d’où viennent les plugins et comment ils sont examinés, voir le guide du catalogue de plugins ; le modèle de sécurité est dans isolation des plugins et frontière de l’hôte.