When Hermes is stuck waiting on a human, your plugin can now tell


It is two in the morning and your Hermes run on a server hits a command that wants a sudo password, or pops an approval prompt for a dangerous command. Nobody is sitting there. The run just stops until it times out and fails, and at nine the next morning you find one cold line of output in the terminal. What you wanted was trivial: when it blocks, tell me.

That used to be awkward to build. Hermes stops for a person in several unrelated places — the sudo password prompt, a clarify tool question, a dangerous-command approval — and each one lives on its own code path. To get a single “someone is needed” notification you had to register one hook per prompt type, then pattern-match terminal command strings to guess which ones were sudo.

PR #133171, merged on October 5, 2026, closes that gap with one generic hook pair that fires whenever the agent stops and waits for a person.

The hook pair

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

The request hook sits in the shared code that every surface already goes through, so CLI, Ink TUI / Desktop, ACP and gateway platforms (Telegram, Discord, …) are all covered without any per-surface wiring. What it covers:

kind Which prompt Where it appears
"sudo" Masked sudo password prompt before a sudo command runs Interactive CLI, Ink TUI / Desktop (sudo.request card), /dev/tty fallback
"clarify" A clarify tool question CLI, Ink TUI / Desktop, gateway platforms
"approval" Dangerous-command / write approval, MCP elicitation consent, plugin approval transports CLI, Ink TUI / Desktop, ACP, gateway platforms, plugins using ctx.register_approval_transport

Two cases deliberately do not fire: approvals.mode=smart decisions made by the auxiliary model, because no person is asked at all; and gateway approvals that join an identical prompt already pending, because the person sees one prompt and the hook pair fires once for it.

What your callback receives

def my_callback(kind: str, request_id: str, session_id: str, session_key: str,
                platform: str, prompt: str, **kwargs):
    ...
Parameter Description
kind "sudo", "clarify" or "approval"; new kinds may be added, so ignore ones you do not handle
request_id Unique per prompt; the matching on_human_input_resolved carries the same value
session_id Hermes session id when known, otherwise ""
session_key Session/chat routing key, handy for scoping notifications per chat
platform Gateway platform ("telegram", "discord", …) or local source ("cli", "tui", "desktop", …)
prompt What the person is being asked: the command for sudo/approval, the question text for clarify

One detail about prompt deserves its own paragraph. It is always run through the secret redactor (redact_sensitive_text(..., force=True)), even when security.redact_secrets is off in your config. If redaction itself fails, prompt is an empty string — the raw text is never sent. The sudo password you type and the answers to clarify questions are never passed to either hook; only the outcome label is.

Return values are ignored, and a raising callback is logged at debug level while the prompt proceeds — a broken observer cannot break a human prompt. Callbacks run on the thread that is about to block, so keep them fast: hand push-service or webhook calls to a background thread.

Outcome values

on_human_input_resolved fires exactly once per request, after the wait ends, with one extra field:

kind outcome values
"sudo" "provided", "skipped" (empty answer), "timeout", "cancelled", "error"
"clarify" the surface’s own outcome: "submitted", "timed_out", "cancelled", "undelivered", "terminated", or "error"
"approval" "once", "session", "always", "deny", "timeout", "cancelled", "notify_failed", or "transport_<failure>" for a failed plugin transport

A genuinely useful example: page me only if nobody answers

The common need is not “notify me on every prompt” but “nudge me if nobody deals with it”. The request/resolve pair makes that natural:

import threading

def push_notification(text):
    ...  # call your push service here

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()  # only page if nobody answers within 30s

    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)

The nice property here is that prompts a human answers never reach you, and only genuinely stuck runs page. Because request_id is paired, you do not get duplicate pages or orphaned timers. For the shape of the hook system as a whole — observer hooks versus interceptor hooks, registration and lifecycle — see the hooks command reference and the earlier provider plugin hooks guide.

Release status

The hook pair is on main and in no published tag yet (the newest release is v0.21.5, tag v2026.9.24). Git installs pick it up by pulling the latest main; Docker images and hosted deployments get it with the next tag. For how to write the plugin itself, see the plugins command reference.

Why this matters beyond notifications

Building a “Hermes needs a human” alert used to require knowledge of internal code paths for each prompt type. Now it is one hook pair, one kind field, and a set of outcome labels. The more transferable idea is the design decision behind it: treat “stopped and waiting for a human” as a first-class event instead of leaving every caller to assemble its own view. For unattended automation that is usually the difference between discovering a stuck run the next morning and knowing about it the moment it stalls.