Wenn Hermes auf einen Menschen wartet, kann dein Plugin das jetzt wissen


Es ist zwei Uhr morgens und dein Hermes-Lauf auf einem Server stößt auf einen Befehl, der ein sudo-Passwort will, oder es poppt ein Freigabe-Prompt für einen gefährlichen Befehl auf. Niemand sitzt davor. Der Lauf stoppt einfach, bis er in einen Timeout läuft und scheitert, und um neun am nächsten Morgen findest du eine kalte Zeile Ausgabe im Terminal. Was du wolltest, war trivial: Wenn es blockiert, sag mir Bescheid.

Das war früher umständlich zu bauen. Hermes stoppt an mehreren unabhängigen Stellen für einen Menschen — der sudo-Passwort-Prompt, eine Frage des clarify-Tools, eine Freigabe für einen gefährlichen Befehl — und jede liegt auf ihrem eigenen Code-Pfad. Um eine einzige Benachrichtigung „jemand wird gebraucht“ zu bekommen, musstest du einen Hook pro Prompt-Typ registrieren und dann Terminal-Befehlstrings per Muster abgleichen, um zu erraten, welche davon sudo waren.

PR #133171, am 5. Oktober 2026 zusammengeführt, schließt diese Lücke mit einem generischen Hook-Paar, das auslöst, wann immer der Agent stoppt und auf einen Menschen wartet.

Das Hook-Paar

on_human_input_request   — fires right before the prompt is shown
on_human_input_resolved  — fires once per request when the wait ends

Der Request-Hook sitzt in dem gemeinsamen Code, den jede Oberfläche ohnehin durchläuft, sodass CLI, Ink TUI / Desktop, ACP und Gateway-Plattformen (Telegram, Discord, …) alle abgedeckt sind, ohne pro Oberfläche etwas zu verdrahten. Was er abdeckt:

kind Welcher Prompt Wo er erscheint
"sudo" Maskierter sudo-Passwort-Prompt, bevor ein sudo-Befehl läuft Interaktives CLI, Ink TUI / Desktop (sudo.request-Karte), /dev/tty-Fallback
"clarify" Eine Frage des clarify-Tools CLI, Ink TUI / Desktop, Gateway-Plattformen
"approval" Freigabe für gefährlichen Befehl / Schreibvorgang, MCP-Elicitation-Consent, Plugin-Approval-Transporte CLI, Ink TUI / Desktop, ACP, Gateway-Plattformen, Plugins mit ctx.register_approval_transport

Zwei Fälle lösen bewusst nicht aus: approvals.mode=smart-Entscheidungen des Hilfsmodells, weil gar kein Mensch gefragt wird; und Gateway-Freigaben, die sich einem bereits wartenden identischen Prompt anschließen, weil die Person nur einen Prompt sieht und das Hook-Paar dafür einmal auslöst.

Was dein Callback erhält

def my_callback(kind: str, request_id: str, session_id: str, session_key: str,
                platform: str, prompt: str, **kwargs):
    ...
Parameter Beschreibung
kind "sudo", "clarify" oder "approval"; neue Arten können hinzukommen, also ignoriere die, die du nicht behandelst
request_id Einmalig pro Prompt; das passende on_human_input_resolved trägt denselben Wert
session_id Hermes-Session-ID, sofern bekannt, sonst ""
session_key Routing-Key für Session/Chat, praktisch, um Benachrichtigungen pro Chat abzugrenzen
platform Gateway-Plattform ("telegram", "discord", …) oder lokale Quelle ("cli", "tui", "desktop", …)
prompt Wonach die Person gefragt wird: der Befehl bei sudo/approval, der Fragetext bei clarify

Ein Detail zu prompt verdient einen eigenen Absatz. Es läuft immer durch den Secret-Redactor (redact_sensitive_text(..., force=True)), auch wenn security.redact_secrets in deiner Konfiguration aus ist. Wenn die Redaktion selbst fehlschlägt, ist prompt ein leerer String — der Rohtext wird nie gesendet. Das sudo-Passwort, das du eintippst, und die Antworten auf clarify-Fragen werden nie an einen der beiden Hooks übergeben; nur das Ergebnis-Label.

Rückgabewerte werden ignoriert, und ein Callback, der eine Exception wirft, wird auf Debug-Level geloggt, während der Prompt weiterläuft — ein kaputter Beobachter kann einen menschlichen Prompt nicht kaputt machen. Callbacks laufen auf dem Thread, der gleich blockiert, also halte sie schnell: Übergib Push-Service- oder Webhook-Aufrufe an einen Hintergrund-Thread.

Ergebniswerte

on_human_input_resolved löst genau einmal pro Anfrage aus, nachdem das Warten endet, mit einem zusätzlichen Feld:

kind outcome-Werte
"sudo" "provided", "skipped" (leere Antwort), "timeout", "cancelled", "error"
"clarify" das eigene Ergebnis der Oberfläche: "submitted", "timed_out", "cancelled", "undelivered", "terminated" oder "error"
"approval" "once", "session", "always", "deny", "timeout", "cancelled", "notify_failed" oder "transport_<failure>" für einen fehlgeschlagenen Plugin-Transport

Ein wirklich nützliches Beispiel: Benachrichtige mich nur, wenn niemand antwortet

Der häufige Bedarf ist nicht „benachrichtige mich bei jedem Prompt“, sondern „stups mich an, wenn sich niemand darum kümmert“. Das Request/Resolve-Paar macht das ganz natürlich:

import threading

def push_notification(text):
    ...  # hier deinen Push-Service aufrufen

def register(ctx):
    pending = {}

    def on_request(kind, request_id, prompt, session_key, **kwargs):
        pending[request_id] = threading.Timer(
            30, push_notification, (f"Hermes needs you ({kind}): {prompt[:80]}",)
        )
        pending[request_id].start()  # nur benachrichtigen, wenn niemand innerhalb von 30s antwortet

    def on_resolved(request_id, **kwargs):
        timer = pending.pop(request_id, None)
        if timer:
            timer.cancel()

    ctx.register_hook("on_human_input_request", on_request)
    ctx.register_hook("on_human_input_resolved", on_resolved)

Das Schöne daran ist, dass Prompts, die ein Mensch beantwortet, dich nie erreichen, und nur wirklich festgefahrene Läufe benachrichtigen. Weil request_id gepaart ist, bekommst du keine doppelten Benachrichtigungen oder verwaisten Timer. Zur Form des Hook-Systems als Ganzes — Observer-Hooks versus Interceptor-Hooks, Registrierung und Lifecycle — siehe die Hooks-Befehlsreferenz und die frühere Anleitung zu Provider-Plugin-Hooks.

Release-Status

Das Hook-Paar ist auf main und noch in keinem veröffentlichten Tag (die neueste Version ist v0.21.5, Tag v2026.9.24). Git-Installationen bekommen es, indem sie das neueste main ziehen; Docker-Images und gehostete Deployments erhalten es mit dem nächsten Tag. Wie du das Plugin selbst schreibst, steht in der Plugins-Befehlsreferenz.

Warum das über Benachrichtigungen hinaus wichtig ist

Einen Alarm „Hermes braucht einen Menschen“ zu bauen, erforderte früher Kenntnis der internen Code-Pfade für jeden Prompt-Typ. Jetzt ist es ein Hook-Paar, ein kind-Feld und eine Reihe von Ergebnis-Labels. Die übertragbarere Idee ist die Design-Entscheidung dahinter: „gestoppt und auf einen Menschen wartend“ als First-Class-Event behandeln, statt jeden Aufrufer seine eigene Sicht zusammenbauen zu lassen. Für unbeaufsichtigte Automatisierung ist das meist der Unterschied zwischen „einen festgefahrenen Lauf am nächsten Morgen entdecken“ und „davon erfahren, im Moment, in dem er hängt“.