Desktop-Plugins können endlich die eigenen Buttons der App drücken: ctx.runAction und ctx.pet.say

Sie haben ein Desktop-Plugin geschrieben, das einen Titlebar-Button hinzufügt, der das eingebaute Browser-Panel öffnet. Die Implementierung war eine Zeile: ein synthetisches Ctrl+Shift+L-Tastaturereignis auf document.body auslösen. Zwei Wochen später meldet ein Nutzer, der Button tue nichts — er hat das Kürzel neu belegt oder ganz entfernt. Ihr Button ist noch da, die Welt hat sich weiterbewegt, und er meldet überhaupt keinen Fehler.
Das ist eine sehr typische Art von Fragilität in einem Plugin-Ökosystem: um die eigenen Funktionen des Hosts aufzurufen, musste man so tun, als hätte der Nutzer eine Taste gedrückt. Dasselbe Zeitfenster lieferte ein verwandtes Beispiel: Ein Plugin, das Quota-Stände anzeigt, malte Bubbles über das Core-Pet, indem es dieses mit document.querySelector('canvas[aria-label$=" pet"]') fand, eine fix positionierte Ebene hinzufügte und dem Pet in jedem Frame folgte. Eine Änderung am Markup im Core und die Sache bricht.
Zwei in diesem Zeitfenster gemergte Änderungen machen aus beidem unterstützte APIs.
ctx.runAction: die Haustür für App-Aktionen
const result = ctx.runAction('view.showBrowser')
// → { ok: true }
Wichtig ist, welcher Handler läuft. useKeybinds hält bereits die eine Handler-Tabelle, über die Tastenkürzel dispatchen. Diese Tabelle ist jetzt auch beim eingebauten Action-Runner registriert (lib/keybinds/plugin-actions.ts), und ctx.runAction schlägt den Handler beim Aufruf dort nach — ein Plugin-Aufruf und ein Tastendruck teilen sich also einen Codepfad, und die Tastenbelegung wird nie konsultiert. Belegen Sie um, was Sie wollen; der Button funktioniert weiter.
Ein Aufruf gibt eine von zwei Formen zurück und wirft nie:
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 noch nicht gemountet
reason hat drei Werte: unknown (keine gültige Action-ID), denied (gültig, aber für Plugins nicht verfügbar), unavailable (zu früh im Startvorgang — die Shell ist noch nicht gemountet). Jede Ablehnung protokolliert außerdem eine Warnung [plugin:<id>] runAction: …, sodass Sie genau sehen können, welches Plugin abgelehnt wurde.
Die API liegt auf dem ctx pro Plugin (wie ctx.pet und ctx.os) statt auf dem globalen host, weil eine Ablehnung einem Plugin zugeschrieben werden muss. Einen älteren Host erkennt man mit einer einzigen Prüfung:
if (ctx.runAction) { /* neuerer Host */ }
Die SDK-Typen kommen mit: @hermes/plugin-sdk exportiert PluginAppActionId (eine Union der erlaubten IDs), PluginAppActionInfo, PluginRunActionResult und die Runtime-Liste PLUGIN_APP_ACTIONS. Für eine aufzählbare Liste zur Laufzeit fragen Sie den Host:
ctx.listActions()
// → [{ id: 'view.showBrowser', label: 'Toggle browser', category: 'view' }, …]
Die Allowlist umfasst nur Views und Navigation
Die erlaubten Aktionen sind bewusst eng gefasst — „Dinge ansehen und Orte aufsuchen“:
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.
Abgelehnt: eine Session archivieren, Pinnen, Profilwechsel, die Modellauswahl, Reasoning-Level, Sprache und Diktat, Terminals, neues Fenster, Ordner öffnen, Worktree, das HUD, einen Tab schließen oder neu öffnen. Destruktive und privilegierte Vorgänge werden überhaupt nicht offengelegt: eine Session löschen, sich abmelden, Approvals, Modell- oder Provider-Wechsel, Update, Beenden.
Die Begründung ist einfach: ein Plugin darf die Oberfläche dorthin verschieben, wo es Ihnen gefällt, aber es darf keine unumkehrbaren Entscheidungen für Sie treffen. Ein Drittanbieter-Plugin, das versehentlich eine Session-Löschung anklickt, ist nichts, was sich einem Nutzer erklären lässt.
ctx.pet.say: die Core-Pet-Bubble für das Plugin sprechen lassen
const dispose = ctx.pet.say('DeepSeek ¥12.40 left', { id: 'balance', tone: 'info', ttlMs: 8000 })
ctx.pet.say('DeepSeek ¥11.90 left', { id: 'balance' }) // dieselbe ID ersetzt die Zeile an Ort und Stelle
ctx.pet.clear('balance') // eine Zeile entfernen; ctx.pet.clear() entfernt alle Ihre
ctx.pet.visible // ReadableAtom<boolean>: ist ein Pet auf dem Bildschirm?
Der Host zeichnet die Bubble, und das Plugin berührt nie das App-DOM — genau der Vorteil gegenüber „das Pet finden und eine eigene Ebene malen“: die Zeilen funktionieren weiterhin, wenn das Pet in ein eigenes Fenster ausgelagert wurde.
Der Host setzt Grenzen durch, damit diese Ecke des Bildschirms nicht missbraucht werden kann:
- Nur Klartext. Steuerzeichen und Bidi-Overrides werden entfernt, der Text wird auf eine Zeile reduziert und auf 120 Zeichen begrenzt, und er wird als React-Textknoten gerendert.
- Beschriftet. Der Name des Plugins aus dem Plugins-Inventar erscheint als kleine Beschriftung über der Zeile.
- Kurzlebig und begrenzt. Zeilen laufen standardmäßig nach 6 Sekunden ab (auf 1–30 s begrenzt). Jedes Plugin darf bis zu 3 lebende Zeilen und 10
say-Aufrufe pro 10 Sekunden haben; zusätzliche Aufrufe werden mit einer Konsolenwarnung verworfen. - Der Core-Status gewinnt. Wenn der Agent einen Fehler hat oder auf Sie wartet, übernimmt die Core-Bubble; die Plugin-Zeile kommt danach zurück, falls sie noch nicht abgelaufen ist.
- Folgt der Pet-Einstellung des Nutzers. Kein Pet oder das Pet abgeschaltet — und nichts wird angezeigt.
- Wird aufgeräumt. Das Deaktivieren, Entladen oder Hot-Reloading eines Plugins entfernt alle seine Zeilen.
- Kein neues Berechtigungsmodell.
ctx.petist pro Plugin gescopt und zugeschrieben, genau wiectx.os.
Wie ein vollständiges Button-Plugin aussieht
// 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 })
}
},
},
],
}
Die alte Implementierung war ein synthetisches Tastenereignis. Diese hier meldet, warum es fehlgeschlagen ist — und kann es dem Nutzer ohne Modal mitteilen.
Wann Sie es nutzen können
Beide Änderungen sind kürzlich gelandet, und der Release-Text von v0.21.7 führt sie unter den Änderungen dieses Zeitfensters auf (er detailliert sie dort nicht und verweist die kuratierten Notes auf v0.22.0). Die Desktop-App erscheint in ihrem eigenen Packaging-Rhythmus, daher:
- Source-Installationen und main-folgende Desktop-Dev-Setups: jetzt verfügbar.
- Store / MSIX / gebündeltes Desktop: auf das nächste Update dieses Builds warten. Prüfen Sie es mit
ctx.runAction?.(…), damit ein Plugin auf beiden Hosts funktioniert.
Die Docs wurden ebenfalls aktualisiert: website/docs/developer-guide/desktop-plugin-sdk.md bekam einen Abschnitt Run app actions — ctx.runAction mit der API, einem Beispiel und der Allowlist-Tabelle, und die Referenz desktop-plugins.md des gebündelten hermes-agent-Skills trägt dieselbe Notiz.
Einschränkungen
- Hören Sie auf, synthetische Tastenereignisse auszulösen, um App-Funktionen zu triggern. Genau dieses Muster wird hier ausgemustert: es stirbt, wenn ein Nutzer umbelegt, und es scheitert still.
- Behandeln Sie Ablehnungen.
ok: falseist keine Ausnahme — es zu ignorieren reicht den Bug nur an Ihre Nutzer weiter. - Die Pet-Bubble ist kein Log-Panel. 120 Zeichen, höchstens 3 Zeilen, standardmäßig 6 Sekunden: sie ist ein Status auf einen Blick, kein Ausgabekanal.
- Die Allowlist ändert sich zwischen Versionen. Lesen Sie sie zur Laufzeit mit
ctx.listActions(), statt sie in Docs oder Typ-Assertions fest zu verdrahten.
Um ein Desktop-Plugin von Grund auf zu bauen, siehe den Leitfaden zum Desktop-Plugin-SDK; für die Herkunft von Plugins und wie sie geprüft werden, siehe den Leitfaden zum Plugin-Katalog; das Sicherheitsmodell steht in Plugin-Isolation und die Host-Grenze.