;;; hermes-chat-prompts.el --- Prompt and approval responses for Hermes chat  -*- lexical-binding: t; -*-

;; Copyright (C) 2026  Thanos Apollo

;; Author: Thanos Apollo <public@thanosapollo.org>
;; Assisted-by: Hermes:MoA
;; Keywords: tools, convenience

;; This program is free software; you can redistribute it and/or modify
;; it under the terms of the GNU General Public License as published by
;; the Free Software Foundation, either version 3 of the License, or
;; (at your option) any later version.

;; This program is distributed in the hope that it will be useful,
;; but WITHOUT ANY WARRANTY; without even the implied warranty of
;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
;; GNU General Public License for more details.

;; You should have received a copy of the GNU General Public License
;; along with this program.  If not, see <https://www.gnu.org/licenses/>.

;;; Commentary:

;; Pending-prompt state and approval/clarify/sudo/secret response handling for
;; `hermes-chat'.  Backend prompt-request events are recorded here, optionally
;; auto-prompted in a visible chat buffer, and answered through the dashboard
;; transport.  This module preserves the existing `hermes-chat--*' symbols and
;; the public commands `hermes-chat-respond-to-prompt' and
;; `hermes-chat-cancel-prompt' while isolating prompt-specific code.  The chat
;; facade requires it after `hermes-chat-buffer' and before
;; `hermes-chat-dashboard'.

;;; Code:

(require 'cl-lib)
(require 'json)
(require 'seq)
(require 'subr-x)
(require 'hermes-transport)
(require 'hermes-dashboard-transport)
(require 'hermes-dashboard-rpc)
(require 'hermes-chat-format)
(require 'hermes-chat-buffer)


;; The one sanctioned upward reference among the chat siblings:
;; `hermes-chat-dashboard' requires this file (its event path records
;; prompt requests here), so the respond dispatcher below cannot require
;; it back and declares this client accessor instead.
(declare-function hermes-chat--dashboard-control-client "hermes-chat-dashboard" ())

(defcustom hermes-chat-auto-prompt-requests t
  "Whether visible chat buffers should prompt for backend input requests.
When non-nil, approvals, sudo passwords, secrets, and terminal reads
automatically open the usual minibuffer prompt in a visible interactive chat.
Clarifications wait for the chat input or `hermes-chat-respond-to-prompt'.
Invisible buffers and batch sessions record every prompt and show a message."
  :type 'boolean
  :group 'hermes)

(defvar hermes-chat--auto-prompting-p nil
  "Non-nil while an automatic minibuffer prompt is reading a response.")

(defvar-local hermes-chat--retained-clarify-owners nil
  "Oldest-first immutable owners of accepted clarification responses.")

(defvar hermes-chat--reset-clarify-owner-sink nil
  "Dynamically bound holder for clarifications accepted during reset.")

(defun hermes-chat--prompt-request-event-p (event)
  "Return non-nil when EVENT is a dashboard prompt request."
  (and (eq (plist-get event :type) 'status)
       (plist-get event :prompt-request-p)))

(defun hermes-chat--prompt-expire-event-p (event)
  "Return non-nil when EVENT expires a dashboard prompt request."
  (and (eq (plist-get event :type) 'status)
       (plist-get event :prompt-expire-p)))

(defun hermes-chat--ensure-pending-prompts ()
  "Return the current buffer's pending prompt table."
  (or hermes-chat--pending-prompts
      (setq hermes-chat--pending-prompts (make-hash-table :test #'equal))))

(defun hermes-chat--prompt-event-type (event)
  "Return EVENT's prompt type string, or nil."
  (hermes-chat--event-string event '(:prompt-type :prompt_type)))

(defun hermes-chat--prompt-event-key (event)
  "Return the stable pending-prompt key for EVENT."
  (or (hermes-chat--event-string event '(:request-id :request_id))
      (and-let* ((type (hermes-chat--prompt-event-type event)))
        (format "%s:%s" type
                (or (hermes-chat--event-string event '(:session-id :session_id))
                    "global")))))

(defun hermes-chat--approval-prompt-p (prompt)
  "Return non-nil when PROMPT is an approval request."
  (equal (hermes-chat--prompt-event-type prompt) "approval"))

(defun hermes-chat--prepare-prompt-request (event key assistant-id)
  "Return EVENT prepared for prompt state under KEY and ASSISTANT-ID."
  (let ((prompt (plist-put (copy-sequence event) :prompt-key key)))
    (setq prompt (plist-put prompt :prompt-content
                            (plist-get prompt :content)))
    (when assistant-id
      (setq prompt (plist-put prompt :assistant-id assistant-id)))
    prompt))

(defun hermes-chat--prompt-without-response-token (prompt)
  "Return PROMPT without its client-local response token."
  (cl-loop for (key value) on prompt by #'cddr
           unless (eq key :response-token)
           append (list key value)))

(defun hermes-chat--equivalent-prompt-replay-p (existing prompt)
  "Return non-nil when EXISTING and PROMPT differ only by response token."
  (equal (hermes-chat--prompt-without-response-token existing)
         (hermes-chat--prompt-without-response-token prompt)))

(defun hermes-chat--approval-prompt-with-queue (queue)
  "Return the oldest approval prompt in QUEUE with count metadata."
  (let* ((prompt (copy-sequence (car queue)))
         (count (length queue))
         (content (or (plist-get prompt :prompt-content)
                      (plist-get prompt :content))))
    (setq prompt (plist-put prompt :prompt-queue queue))
    (setq prompt (plist-put prompt :prompt-count count))
    (plist-put prompt :content
               (if (> count 1)
                   (format "%s (%d pending approvals)" content count)
                 content))))

(defun hermes-chat--record-prompt-request (event assistant-id)
  "Record prompt request EVENT for ASSISTANT-ID and return display event."
  (if-let* ((key (hermes-chat--prompt-event-key event)))
      (let* ((prompt (hermes-chat--prepare-prompt-request
                      event key assistant-id))
             (table (hermes-chat--ensure-pending-prompts))
             (existing (gethash key table))
             (stored (if (and existing (hermes-chat--approval-prompt-p prompt))
                         (hermes-chat--approval-prompt-with-queue
                          (append (plist-get existing :prompt-queue)
                                  (list prompt)))
                       (if (hermes-chat--approval-prompt-p prompt)
                           (hermes-chat--approval-prompt-with-queue
                            (list prompt))
                         prompt)))
             (token (and existing
                         (or (and (hermes-chat--approval-prompt-p existing)
                                  (hermes-chat--approval-prompt-p prompt))
                             (hermes-chat--equivalent-prompt-replay-p
                              existing stored))
                         (plist-get existing :response-token))))
        (when (and existing (hash-table-p hermes-chat--auto-prompt-keys))
          (remhash key hermes-chat--auto-prompt-keys))
        (when token
          (setq stored (plist-put stored :response-token token)))
        (puthash key stored table)
        stored)
    event))

(defun hermes-chat--prompt-expiry-matches-p (prompt event)
  "Return non-nil when expiry EVENT owns pending PROMPT exactly."
  (and prompt
       (equal (hermes-chat--prompt-event-type prompt)
              (hermes-chat--prompt-event-type event))
       (equal (hermes-chat--event-string prompt '(:request-id :request_id))
              (hermes-chat--event-string event '(:request-id :request_id)))
       (equal (hermes-chat--event-string prompt '(:session-id :session_id))
              (hermes-chat--event-string event '(:session-id :session_id)))))

(defun hermes-chat--expire-pending-prompt (event)
  "Remove the exact pending prompt owned by expiry EVENT."
  (when-let* ((key (hermes-chat--event-string event '(:request-id :request_id)))
              ((hermes-chat--event-string event '(:session-id :session_id)))
              (prompt (and (hash-table-p hermes-chat--pending-prompts)
                           (gethash key hermes-chat--pending-prompts)))
              ((hermes-chat--prompt-expiry-matches-p prompt event)))
    (remhash key hermes-chat--pending-prompts)
    (when (hash-table-p hermes-chat--auto-prompt-keys)
      (remhash key hermes-chat--auto-prompt-keys))
    (hermes-chat--notify-state-change)
    (unless (hermes-chat--show-pending-prompt-state)
      (hermes-chat--set-header-state
       :status (if (hermes-chat--active-turn-p) 'running 'ready)
       :activity (plist-get event :content)))
    t))

(defun hermes-chat--ensure-auto-prompt-keys ()
  "Return the current buffer's scheduled auto-prompt key table."
  (or hermes-chat--auto-prompt-keys
      (setq hermes-chat--auto-prompt-keys (make-hash-table :test #'equal))))

(defun hermes-chat--release-auto-prompt-claim (key &optional claim)
  "Release KEY's auto-prompt CLAIM when it remains current.
With nil CLAIM, release any claim for KEY."
  (when (and (hash-table-p hermes-chat--auto-prompt-keys)
             (or (null claim)
                 (eq (gethash key hermes-chat--auto-prompt-keys) claim)))
    (remhash key hermes-chat--auto-prompt-keys)))

(defun hermes-chat--prompt-owner-context (key &optional prompt claim)
  "Return current ownership context for KEY, PROMPT, and scheduling CLAIM."
  (list :client hermes-chat--dashboard-client
        :session-id hermes-chat--dashboard-active-session-id
        :generation hermes-chat--lifecycle-generation
        :prompts hermes-chat--pending-prompts
        :key key
        :prompt prompt
        :claim claim))

(defun hermes-chat--prompt-owner-current-p (context)
  "Return non-nil when prompt owner CONTEXT still owns this chat."
  (let ((client (plist-get context :client))
        (key (plist-get context :key))
        (expected (plist-get context :prompt))
        (claim (plist-get context :claim)))
    (and (eq hermes-chat--dashboard-client client)
         (hermes-dashboard-transport-client-p client)
         (hermes-dashboard-transport-client-websocket client)
         (equal hermes-chat--dashboard-active-session-id
                (plist-get context :session-id))
         (eql hermes-chat--lifecycle-generation (plist-get context :generation))
         (eq hermes-chat--pending-prompts (plist-get context :prompts))
         (when-let* ((prompt (gethash key hermes-chat--pending-prompts)))
           (and (or (null expected) (eq prompt expected))
                (or (null claim)
                    (and (hash-table-p hermes-chat--auto-prompt-keys)
                         (eq (gethash key hermes-chat--auto-prompt-keys)
                             claim))))))))

(defun hermes-chat--prompt-notice-text (prompt)
  "Return a safe one-line notice for PROMPT."
  (let ((summary (or (plist-get prompt :content)
                     (hermes-chat--prompt-display-name prompt))))
    (format "Hermes %s pending: %s"
            (downcase (hermes-chat--prompt-display-name prompt))
            (string-trim
             (truncate-string-to-width (or summary "") 96 nil nil "…")))))

(defun hermes-chat--auto-prompt-schedulable-p (buffer)
  "Return non-nil if BUFFER may schedule an automatic prompt."
  (and hermes-chat-auto-prompt-requests
       (not noninteractive)
       (get-buffer-window buffer t)))

(defun hermes-chat--run-auto-prompt (buffer key context)
  "Prompt for KEY in BUFFER while prompt owner CONTEXT remains current."
  (hermes-chat--in-buffer buffer
    (when (hermes-chat--prompt-owner-current-p context)
      (when-let* ((prompt (gethash key hermes-chat--pending-prompts)))
        (cond
         ((not (hermes-chat--auto-prompt-schedulable-p buffer))
          (hermes-chat--release-auto-prompt-claim
           key (plist-get context :claim)))
         ((or (hermes-chat--prompt-response-in-flight-p key)
              (not (zerop (minibuffer-depth))))
          (hermes-chat--release-auto-prompt-claim
           key (plist-get context :claim))
          (hermes-chat--schedule-auto-prompt prompt t 0.25))
         (t
          (hermes-chat--release-auto-prompt-claim
           key (plist-get context :claim))
          (condition-case err
              (let ((hermes-chat--auto-prompting-p t))
                (hermes-chat-respond-to-prompt key))
            (quit
             (message "Hermes prompt left pending: %s" key))
            (user-error
             (message "%s" (error-message-string err)))
            (error
             (message "Hermes auto prompt failed: %s"
                      (error-message-string err))))))))))

(defun hermes-chat--schedule-auto-prompt (prompt &optional quiet delay)
  "Announce PROMPT and schedule an automatic minibuffer response prompt.
When QUIET is non-nil, do not emit another echo-area notice.  DELAY is the
number of seconds to wait before trying to prompt."
  (when-let* ((key (plist-get prompt :prompt-key)))
    (unless quiet
      (message "%s (%s)"
               (hermes-chat--prompt-notice-text prompt)
               (if (and (equal (hermes-chat--prompt-event-type prompt) "clarify")
                        (not (hermes-chat--batch-clarify-p prompt)))
                   "answer in chat with RET or C-c C-a"
                 "respond with C-c C-a")))
    (when (and (not (equal (hermes-chat--prompt-event-type prompt) "clarify"))
               (hermes-chat--auto-prompt-schedulable-p (current-buffer)))
      (let ((scheduled (hermes-chat--ensure-auto-prompt-keys)))
        (unless (gethash key scheduled)
          (let ((claim (list key prompt)))
            (puthash key claim scheduled)
            (run-at-time (or delay 0) nil #'hermes-chat--run-auto-prompt
                         (current-buffer) key
                         (hermes-chat--prompt-owner-context
                          key prompt claim))))))))

(defun hermes-chat--prompt-session-match-p (prompt session-id)
  "Return non-nil if PROMPT belongs to SESSION-ID.
A nil SESSION-ID matches every prompt in the current buffer."
  (or (null session-id)
      (null (plist-get prompt :session-id))
      (equal (plist-get prompt :session-id) session-id)))

(defun hermes-chat--clear-pending-prompts (&optional session-id)
  "Remove pending prompt requests for SESSION-ID, or all when nil."
  (when (hash-table-p hermes-chat--pending-prompts)
    (let (keys)
      (maphash (lambda (key prompt)
                 (when (hermes-chat--prompt-session-match-p prompt session-id)
                   (push key keys)))
               hermes-chat--pending-prompts)
      (dolist (key keys)
        (remhash key hermes-chat--pending-prompts)
        (hermes-chat--release-auto-prompt-claim key))
      (when keys
        (hermes-chat--notify-state-change)))))

(defun hermes-chat--pending-prompt-p ()
  "Return non-nil when the current chat has pending prompt requests."
  (and hermes-chat--pending-prompts
       (> (hash-table-count hermes-chat--pending-prompts) 0)))

(defun hermes-chat--pending-prompt-count ()
  "Return the number of pending prompt requests in the current chat."
  (let ((count 0))
    (when (hash-table-p hermes-chat--pending-prompts)
      (maphash (lambda (_key prompt)
                 (setq count
                       (+ count
                          (or (plist-get prompt :prompt-count)
                              (and (plist-get prompt :prompt-queue)
                                   (length (plist-get prompt :prompt-queue)))
                              1))))
               hermes-chat--pending-prompts))
    count))

(defun hermes-chat--pending-prompt-keys ()
  "Return pending prompt keys in deterministic order."
  (let (keys)
    (when (hash-table-p hermes-chat--pending-prompts)
      (maphash (lambda (key _prompt) (push key keys))
               hermes-chat--pending-prompts))
    (sort keys #'string<)))

(defun hermes-chat--select-pending-prompt-key (key)
  "Return KEY or interactively select a pending prompt key."
  (or key
      (pcase (hermes-chat--pending-prompt-keys)
        ('() (user-error "No pending Hermes prompt requests"))
        (`(,only) only)
        (keys (completing-read "Hermes prompt: " keys nil t)))))

(defun hermes-chat--pending-prompt (key)
  "Return pending prompt for KEY, or signal a user error."
  (or (and hermes-chat--pending-prompts
           (gethash key hermes-chat--pending-prompts))
      (user-error "No pending Hermes prompt request %s" key)))

(defun hermes-chat--pending-clarify-key ()
  "Return the sole pending clarification key, or nil."
  (pcase (hermes-chat--pending-prompt-keys)
    (`(,key)
     (and (equal (hermes-chat--prompt-event-type
                  (gethash key hermes-chat--pending-prompts))
                 "clarify")
          key))))

(defun hermes-chat--prompt-display-name (prompt)
  "Return display name for PROMPT."
  (pcase (hermes-chat--prompt-event-type prompt)
    ("approval" "Approval")
    ("clarify" "Clarify")
    ("sudo" "Sudo")
    ("secret" "Secret")
    ("terminal" "Terminal read")
    (_ "Prompt")))

(defun hermes-chat--prompt-entry-node (prompt)
  "Return the transcript node that rendered PROMPT, or nil."
  (when-let* ((assistant-id (plist-get prompt :assistant-id))
              (event-id (hermes-chat--transport-entry-id prompt)))
    (gethash (format "%s:%s" assistant-id event-id) hermes-chat--nodes)))

(defun hermes-chat--insert-prompt-status (prompt content status)
  "Insert CONTENT with STATUS immediately after PROMPT's transcript entry."
  (let* ((prompt-node (hermes-chat--prompt-entry-node prompt))
         (next-node (and prompt-node
                         (ewoc-next hermes-chat--ewoc prompt-node))))
    (hermes-chat--insert-entry
     (hermes-chat--make-entry 'status content status)
     next-node)))

(defun hermes-chat--first-pending-prompt ()
  "Return the first pending prompt in deterministic key order."
  (and-let* ((key (car (hermes-chat--pending-prompt-keys))))
    (gethash key hermes-chat--pending-prompts)))

(defun hermes-chat--prompt-header-status (prompt)
  "Return header status symbol for pending PROMPT."
  (if (hermes-chat--approval-prompt-p prompt) 'approval-requested 'requested))

(defun hermes-chat--prompt-header-activity (prompt)
  "Return header activity for pending PROMPT."
  (or (hermes-chat--header-activity-for-event prompt)
      (format "%s requested" (hermes-chat--prompt-display-name prompt))))

(defun hermes-chat--show-pending-prompt-state (&optional prompt)
  "Show PROMPT or any pending prompt in the chat header."
  (when-let* ((pending (or prompt (hermes-chat--first-pending-prompt))))
    (hermes-chat--set-header-state
     :status (hermes-chat--prompt-header-status pending)
     :activity (hermes-chat--prompt-header-activity pending))
    pending))

(defun hermes-chat--clarify-prompt-p (prompt)
  "Return non-nil when PROMPT accepts recoverable clarification text."
  (equal (hermes-chat--prompt-event-type prompt) "clarify"))

(defun hermes-chat--retain-clarify-response (context response)
  "Return CONTEXT retaining a copied clarification RESPONSE owner."
  (let* ((active-sink hermes-chat--reset-clarify-owner-sink)
         (sink (and (eq (car active-sink) (current-buffer)) active-sink))
         (owner (list :buffer (current-buffer)
                      :generation hermes-chat--lifecycle-generation
                      :response-token (plist-get context :token)
                      :text (copy-sequence response))))
    (if sink
        (setf (cadr sink) (append (cadr sink) (list owner)))
      (setq hermes-chat--retained-clarify-owners
            (append hermes-chat--retained-clarify-owners (list owner))))
    (setq context (plist-put context :retained-owner owner))
    (when sink
      (setq context (plist-put context :retained-owner-sink sink)))
    context))

(defun hermes-chat--settle-retained-clarify (context &optional keep-sink)
  "Remove CONTEXT's exact retained owner unless KEEP-SINK preserves it."
  (when-let* ((owner (plist-get context :retained-owner)))
    (let ((sink (plist-get context :retained-owner-sink)))
      (unless (and keep-sink sink)
        (if sink
            (setf (cadr sink) (delq owner (cadr sink)))
          (setq hermes-chat--retained-clarify-owners
                (delq owner hermes-chat--retained-clarify-owners)))))))

(defun hermes-chat--drain-reset-clarify-owners (sink)
  "Drain SINK once into the fresh chat input in acceptance order."
  (let ((owners (cadr sink)))
    (setf (cadr sink) nil)
    (when owners
      (hermes-chat--replace-input-tail
       (mapconcat (lambda (owner) (plist-get owner :text)) owners "\n")))))

(defun hermes-chat--terminal-approval-session (prompt)
  "Return PROMPT's exact approval session, including nil."
  (hermes-chat--event-string prompt '(:session-id :session_id)))

(defun hermes-chat--terminal-prompt-entry (key prompt)
  "Return terminal authority for PROMPT under KEY."
  (let* ((approval-p (hermes-chat--approval-prompt-p prompt))
         (queue (and approval-p (or (plist-get prompt :prompt-queue)
                                    (list prompt)))))
    (list :key key
          :prompt prompt
          :response-token (plist-get prompt :response-token)
          :approval-p approval-p
          :approval-members (and approval-p (copy-sequence queue))
          :session-id (and approval-p
                           (hermes-chat--terminal-approval-session
                            (car queue))))))

(defconst hermes-chat--invalid-terminal-auto-claims
  'hermes-chat--invalid-terminal-auto-claims
  "Marker for malformed terminal auto-prompt claim state.")

(defun hermes-chat--terminal-auto-claims ()
  "Return exact sorted auto-prompt claims, or the private invalid marker."
  (cond
   ((null hermes-chat--auto-prompt-keys) nil)
   ((not (hash-table-p hermes-chat--auto-prompt-keys))
    hermes-chat--invalid-terminal-auto-claims)
   (t
    (catch 'invalid
      (let ((missing (make-symbol "missing-prompt")) entries keys)
        (maphash
         (lambda (key claim)
           (unless (and (stringp key) (not (member key keys)))
             (throw 'invalid hermes-chat--invalid-terminal-auto-claims))
           (push key keys)
           (push (cons key claim) entries))
         hermes-chat--auto-prompt-keys)
        (mapcar
         (lambda (entry)
           (let* ((key (car entry))
                  (claim (cdr entry))
                  (prompt (if (hash-table-p hermes-chat--pending-prompts)
                              (gethash key hermes-chat--pending-prompts missing)
                            missing)))
             (unless (and (eql (proper-list-p claim) 2)
                          (equal (car claim) key)
                          prompt
                          (not (eq prompt missing))
                          (eq (cadr claim) prompt))
               (throw 'invalid hermes-chat--invalid-terminal-auto-claims))
             (list :key key :claim claim :prompt prompt)))
         (sort entries (lambda (left right)
                         (string< (car left) (car right))))))))))

(defun hermes-chat--capture-terminal-prompts ()
  "Return immutable plain terminal prompt authority for the current chat."
  (list :buffer (current-buffer)
        :generation hermes-chat--lifecycle-generation
        :prompt-table hermes-chat--pending-prompts
        :auto-table hermes-chat--auto-prompt-keys
        :auto-claims (hermes-chat--terminal-auto-claims)
        :retained-owners (copy-sequence hermes-chat--retained-clarify-owners)
        :entries
        (mapcar (lambda (key)
                  (hermes-chat--terminal-prompt-entry
                   key (gethash key hermes-chat--pending-prompts)))
                (hermes-chat--pending-prompt-keys))))

(defun hermes-chat--terminal-snapshot-current-p (snapshot)
  "Return non-nil when SNAPSHOT owns a live current prompt lifecycle."
  (let ((buffer (plist-get snapshot :buffer)))
    (and (buffer-live-p buffer)
         (with-current-buffer buffer
           (and (derived-mode-p 'hermes-chat-mode)
                (eql hermes-chat--lifecycle-generation
                     (plist-get snapshot :generation))
                (eq hermes-chat--pending-prompts
                    (plist-get snapshot :prompt-table))
                (eq hermes-chat--auto-prompt-keys
                    (plist-get snapshot :auto-table)))))))

(defun hermes-chat--terminal-entry-matches-p (entry prompt)
  "Return non-nil when nonapproval PROMPT is owned by ENTRY."
  (and prompt
       (not (hermes-chat--approval-prompt-p prompt))
       (let ((token (plist-get entry :response-token)))
         (if token
             (eq (plist-get prompt :response-token) token)
           (eq prompt (plist-get entry :prompt))))))

(defun hermes-chat--terminal-auto-prompt (key)
  "Return KEY's current auto-prompt owner prompt, or nil."
  (and (hash-table-p hermes-chat--auto-prompt-keys)
       (cadr (gethash key hermes-chat--auto-prompt-keys))))

(defun hermes-chat--take-terminal-nonapproval (entry)
  "Take nonapproval ENTRY and return its response token, or nil."
  (let* ((key (plist-get entry :key))
         (prompt (gethash key hermes-chat--pending-prompts)))
    (when (hermes-chat--terminal-entry-matches-p entry prompt)
      (when (hermes-chat--terminal-entry-matches-p
             entry (hermes-chat--terminal-auto-prompt key))
        (remhash key hermes-chat--auto-prompt-keys))
      (remhash key hermes-chat--pending-prompts)
      (plist-get entry :response-token))))

(defun hermes-chat--take-terminal-approval (entry)
  "Remove ENTRY's captured-session approval aggregate."
  (let* ((key (plist-get entry :key))
         (session (plist-get entry :session-id))
         (prompt (gethash key hermes-chat--pending-prompts))
         (queue (and (hermes-chat--approval-prompt-p prompt)
                     (or (plist-get prompt :prompt-queue) (list prompt))))
         (represented
          (cl-some (lambda (item)
                     (and (equal (hermes-chat--terminal-approval-session item)
                                 session)
                          (memq item queue)))
                   (plist-get entry :approval-members)))
         (remaining
          (and represented
               (cl-remove-if
                (lambda (item)
                  (equal (hermes-chat--terminal-approval-session item) session))
                queue))))
    (when represented
      (let ((auto (hermes-chat--terminal-auto-prompt key)))
        (when (and (hermes-chat--approval-prompt-p auto)
                   (equal (hermes-chat--terminal-approval-session auto) session))
          (remhash key hermes-chat--auto-prompt-keys)))
      (if remaining
          (puthash key (hermes-chat--approval-prompt-with-queue remaining)
                   hermes-chat--pending-prompts)
        (remhash key hermes-chat--pending-prompts)))))

(defun hermes-chat--terminal-restore-effect (owner)
  "Return a dormant one-shot restoration thunk for OWNER."
  (let ((pending t)
        (buffer (plist-get owner :buffer))
        (generation (plist-get owner :generation))
        (text (plist-get owner :text)))
    (lambda ()
      (when pending
        (setq pending nil)
        (when (buffer-live-p buffer)
          (with-current-buffer buffer
            (when (and (derived-mode-p 'hermes-chat-mode)
                       (eql hermes-chat--lifecycle-generation generation))
              (hermes-chat--restore-prompt-response text))))))))

(defun hermes-chat--take-terminal-retained (snapshot tokens)
  "Take SNAPSHOT retained owners matching exact TOKENS and return effects."
  (delq nil
        (mapcar
         (lambda (owner)
           (when (and (memq owner hermes-chat--retained-clarify-owners)
                      (memq (plist-get owner :response-token) tokens))
             (setq hermes-chat--retained-clarify-owners
                   (delq owner hermes-chat--retained-clarify-owners))
             (hermes-chat--terminal-restore-effect owner)))
         (plist-get snapshot :retained-owners))))

(defun hermes-chat--take-terminal-prompts (snapshot)
  "Take current authority from terminal prompt SNAPSHOT and return effects."
  (when (hermes-chat--terminal-snapshot-current-p snapshot)
    (with-current-buffer (plist-get snapshot :buffer)
      (let ((tokens
             (delq nil
                   (mapcar
                    (lambda (entry)
                      (if (plist-get entry :approval-p)
                          (hermes-chat--take-terminal-approval entry)
                        (hermes-chat--take-terminal-nonapproval entry)))
                    (plist-get snapshot :entries)))))
        (hermes-chat--take-terminal-retained snapshot tokens)))))

(defun hermes-chat--response-redaction-variants (response)
  "Return string variants of RESPONSE that may appear in errors."
  (when (and (stringp response) (not (string-empty-p response)))
    (let* ((variants (list response))
           (encoded (json-encode-string response)))
      (push encoded variants)
      (when (and (> (length encoded) 1)
                 (eq (aref encoded 0) ?\")
                 (eq (aref encoded (1- (length encoded))) ?\"))
        (push (substring encoded 1 -1) variants))
      (sort (delete-dups variants)
            (lambda (left right)
              (> (length left) (length right)))))))

(defun hermes-chat--redact-response-value (text response)
  "Return TEXT with RESPONSE variants replaced by a redaction marker."
  (let ((message (or text "")))
    (dolist (variant (hermes-chat--response-redaction-variants response)
                     message)
      (setq message (string-replace variant "<redacted>" message)))))

(defun hermes-chat--prompt-safe-error (prompt response message)
  "Return safe error MESSAGE for PROMPT and RESPONSE."
  (if (hermes-chat--clarify-prompt-p prompt)
      message
    (hermes-chat--redact-response-value message response)))

(defun hermes-chat--prompt-choices (prompt)
  "Return PROMPT choices as strings, or nil."
  (and-let* ((choices (hermes-chat--event-value prompt '(:choices))))
    (delq nil (mapcar #'hermes-chat--scalar-string
                      (if (vectorp choices) (append choices nil) choices)))))

(defun hermes-chat--prompt-questions (prompt)
  "Return PROMPT's batch clarification questions as a list, or nil."
  (when-let* ((questions (hermes-chat--event-value prompt '(:questions)))
              ((or (vectorp questions) (listp questions))))
    (append questions nil)))

(defun hermes-chat--batch-clarify-p (prompt)
  "Return non-nil when PROMPT is a batched clarification."
  (and (hermes-chat--clarify-prompt-p prompt)
       (hermes-chat--prompt-questions prompt)))

(defun hermes-chat--batch-question-choices (question)
  "Return QUESTION's choices as strings, or nil."
  (when-let* ((choices (hermes-transport--get question 'choices))
              ((or (vectorp choices) (listp choices))))
    (delq nil
          (mapcar #'hermes-chat--scalar-string (append choices nil)))))

(defun hermes-chat--read-batch-question-response (question)
  "Read and return one batched clarification QUESTION response."
  (let ((text (or (hermes-transport--scalar-string
                   (hermes-transport--get question 'question))
                  "Clarify"))
        (choices (hermes-chat--batch-question-choices question)))
    (cond
     ((and choices (eq (hermes-transport--get question 'multi_select) t))
      (completing-read-multiple (format "%s: " text) choices))
     (choices (completing-read (format "%s: " text) choices))
     (t (read-string (format "%s: " text))))))

(defun hermes-chat--batch-clarify-responses (prompt response)
  "Return unanswered question responses for batch PROMPT.
RESPONSE is an optional alist keyed by question id; nil reads interactively."
  (when (and response (not (listp response)))
    (user-error "Batch clarification responses must be keyed by question id"))
  (let ((answers (hermes-chat--event-value prompt '(:answers))))
    (cl-loop for question in (hermes-chat--prompt-questions prompt)
             for qid = (hermes-transport--scalar-string
                        (hermes-transport--get question 'qid))
             unless (and qid (hermes-transport--field-present-p answers qid))
             collect
             (let ((provided (and response (assoc qid response))))
               (unless qid
                 (user-error "Hermes batch clarification has no question id"))
               (when (and response (null provided))
                 (user-error "No response supplied for Hermes question %s" qid))
               (cons qid (if response (cdr provided)
                           (hermes-chat--read-batch-question-response question)))))))

(defun hermes-chat--approval-choice-label (choice)
  "Return a minibuffer label for approval CHOICE."
  (pcase choice
    ("once" "Approve once")
    ("session" "Approve for session")
    ("always" "Always approve")
    ("deny" "Deny")
    (_ choice)))

(defun hermes-chat--approval-response-candidates (prompt)
  "Return completion candidates for responding to approval PROMPT.
The choice vocabulary comes from PROMPT's `:choices' when present and
otherwise defaults to the server's once/session/always/deny set; the
backend never gates \"always\", so no choice is filtered locally."
  (let ((choices (or (hermes-chat--prompt-choices prompt)
                     '("once" "session" "always" "deny"))))
    (append (mapcar (lambda (choice)
                      (cons (hermes-chat--approval-choice-label choice) choice))
                    choices)
            '(("Cancel / ignore" . nil)))))

(defun hermes-chat--read-approval-response (prompt)
  "Read an approval response for PROMPT."
  (let* ((candidates (hermes-chat--approval-response-candidates prompt))
         (default (if hermes-chat--auto-prompting-p
                      (or (car (rassoc nil candidates)) (caar candidates))
                    (caar candidates)))
         (choice (completing-read "Approval decision: "
                                  (mapcar #'car candidates) nil t nil nil
                                  default))
         (candidate (assoc choice candidates)))
    (unless candidate
      (user-error "Unknown approval decision: %s" choice))
    (or (cdr candidate) (keyboard-quit))))

(defun hermes-chat--terminal-read-text (entries &optional start count)
  "Return a JSON terminal-read snapshot for chat transcript ENTRIES.
START (0-indexed, default 0) and COUNT (default all) page over transcript
lines.  The desktop read-terminal tool returns the in-app terminal pane; in
Emacs the closest analog is the chat transcript, encoded with the same
`total_lines'/`start'/`end'/`viewport_rows'/`cursor_row'/`text' shape."
  (let* ((all-text (string-join
                    (delq nil
                          (mapcar (lambda (entry)
                                    (plist-get entry :content))
                                  entries))
                    "\n"))
         (lines (if (string-empty-p all-text)
                    nil
                  (split-string all-text "\n")))
         (total (length lines))
         (from (max 0 (or (and (integerp start) start) 0)))
         (limit (and (integerp count) (max 1 count)))
         (end (if limit (min total (+ from limit)) total))
         (rows (max 0 (- end from)))
         (page (seq-subseq lines (min from total) (min end total))))
    (json-encode
     `((total_lines . ,total)
       (start . ,(min from total))
       (end . ,end)
       (viewport_rows . ,rows)
       (cursor_row . ,(if (zerop rows) 0 (1- rows)))
       (text . ,(string-join page "\n"))))))

(defun hermes-chat--read-prompt-response (prompt)
  "Read a response for PROMPT using an Emacs-native minibuffer UI."
  (pcase (hermes-chat--prompt-event-type prompt)
    ("approval"
     (hermes-chat--read-approval-response prompt))
    ("clarify"
     (if-let* ((choices (hermes-chat--prompt-choices prompt)))
         ;; Choices are suggestions, not a closed set: the agent's clarify tool
         ;; always lets the user type their own answer, so do not require a match.
         (completing-read "Clarify: " choices)
       (read-string (or (hermes-chat--event-string prompt '(:question :content))
                        "Clarify: "))))
    ("sudo" (read-passwd "Sudo password: "))
    ("secret"
     (read-passwd (or (hermes-chat--event-string prompt '(:prompt :content))
                      "Secret: ")))
    ("terminal"
     (hermes-chat--terminal-read-text
      (hermes-chat--entries)
      (plist-get prompt :start)
      (plist-get prompt :count)))
    (_ (read-string "Prompt response: "))))

(defun hermes-chat--approval-response-resolved-count (result)
  "Return positive resolved approval count from RESULT, or nil."
  (let ((resolved (hermes-transport--get result 'resolved)))
    (and (integerp resolved) (> resolved 0) resolved)))

(defun hermes-chat--advance-prompt-response (context prompt count)
  "Advance COUNT queued records for PROMPT owned by CONTEXT.
Return the next pending prompt."
  (let* ((key (plist-get context :key))
         (current (gethash key hermes-chat--pending-prompts))
         (queue (and (hermes-chat--approval-prompt-p prompt)
                     (or (plist-get current :prompt-queue)
                         (plist-get prompt :prompt-queue))))
         (remaining (and queue (nthcdr count queue)))
         (next (and remaining
                    (hermes-chat--approval-prompt-with-queue remaining))))
    (hermes-chat--release-auto-prompt-claim key)
    (if next
        (progn
          (puthash key next hermes-chat--pending-prompts)
          (hermes-chat--upsert-transport-entry
           (or (plist-get next :assistant-id)
               (plist-get prompt :assistant-id))
           next))
      (remhash key hermes-chat--pending-prompts))
    next))

(defun hermes-chat--prompt-response-complete (context prompt canceled result)
  "Mark PROMPT response owned by CONTEXT complete, noting CANCELED and RESULT."
  (let* ((resolved-count
          (and (hermes-chat--approval-prompt-p prompt)
               (hermes-chat--approval-response-resolved-count result)))
         (next-prompt
          (hermes-chat--advance-prompt-response
           context prompt (or resolved-count
                              (plist-get context :response-count)))))
    (let ((status (concat (hermes-chat--prompt-display-name prompt)
                          " "
                          (if canceled "canceled" "response sent"))))
      (hermes-chat--insert-prompt-status
       prompt status (if canceled 'error 'done))
      (unless (hermes-chat--show-pending-prompt-state next-prompt)
        (hermes-chat--set-header-state
         :status (if (hermes-chat--active-turn-p) 'running 'ready)
         :activity status))
      (when next-prompt
        (hermes-chat--schedule-auto-prompt next-prompt)))))

(defun hermes-chat--approval-response-unresolved-p (prompt result)
  "Return non-nil when approval PROMPT RESULT resolved no backend prompt."
  (and (hermes-chat--approval-prompt-p prompt)
       (equal (hermes-transport--get result 'resolved) 0)))

(defun hermes-chat--prompt-response-expired-p (result)
  "Return non-nil when RESULT reports an expired backend prompt."
  (equal (hermes-transport--scalar-string
          (hermes-transport--get result 'status))
         "expired"))

(defun hermes-chat--prompt-response-stale (context prompt)
  "Clear stale PROMPT owned by CONTEXT without claiming a response was sent."
  (let ((status (concat (hermes-chat--prompt-display-name prompt)
                        " request no longer pending"))
        (next-prompt
         (hermes-chat--advance-prompt-response
          context prompt (plist-get context :response-count))))
    (hermes-chat--insert-local-status status 'error)
    (unless (hermes-chat--show-pending-prompt-state next-prompt)
      (hermes-chat--set-header-state
       :status (if (hermes-chat--active-turn-p) 'running 'ready)
       :activity status))
    (when next-prompt
      (hermes-chat--schedule-auto-prompt next-prompt))))

(defun hermes-chat--prompt-missing-error-p (prompt message)
  "Return non-nil when MESSAGE is PROMPT's exact backend-missing error."
  (when-let* ((message (and (stringp message) (downcase (string-trim message)))))
    (pcase (hermes-chat--prompt-event-type prompt)
      ("clarify" (equal message "no pending answer request"))
      ("terminal" (equal message "no pending text request"))
      ("sudo" (equal message "no pending password request"))
      ("secret" (equal message "no pending value request"))
      ("approval" (member message '("no pending approval"
                                     "no pending approval request"))))))

(defun hermes-chat--restore-prompt-response (response)
  "Restore failed chat-tail prompt RESPONSE without queueing a new turn."
  (when-let* ((text (hermes-transport--non-empty-string response)))
    (if (string-empty-p (string-trim (hermes-chat-input-string)))
        (hermes-chat--replace-input-tail text)
      (hermes-chat--append-input-tail text)
      (hermes-chat--insert-local-status
       "Restored failed prompt response after current draft" 'error))))

(defun hermes-chat--prompt-response-rejected
    (context prompt response message &optional preserve-response)
  "Render rejection MESSAGE for PROMPT and RESPONSE owned by CONTEXT.
When PRESERVE-RESPONSE is non-nil, keep clarification RESPONSE recoverable."
  (let* ((safe-message
          (hermes-chat--prompt-safe-error prompt response message))
         (next-prompt
          (if (hermes-chat--prompt-missing-error-p prompt message)
              (hermes-chat--advance-prompt-response
               context prompt (plist-get context :response-count))
            (hermes-chat--release-prompt-response context)
            nil)))
    (hermes-chat--settle-retained-clarify context t)
    (when (and preserve-response
               (hermes-chat--clarify-prompt-p prompt)
               (not (plist-get context :retained-owner-sink)))
      (hermes-chat--restore-prompt-response response))
    (hermes-chat--command-error safe-message)
    (when next-prompt
      (hermes-chat--show-pending-prompt-state next-prompt)
      (hermes-chat--schedule-auto-prompt next-prompt))))

(defun hermes-chat--prompt-response-in-flight-p (key)
  "Return non-nil when prompt KEY already has a response in flight."
  (and-let* ((prompt (and (hash-table-p hermes-chat--pending-prompts)
                          (gethash key hermes-chat--pending-prompts))))
    (plist-get prompt :response-token)))

(defun hermes-chat--release-all-prompt-response-claims ()
  "Release stale response claims and retained owners after invalidation."
  (when (hash-table-p hermes-chat--pending-prompts)
    (maphash
     (lambda (key prompt)
       (let ((token (plist-get prompt :response-token)))
         (when (and token
                    (not (eql (cadr token)
                              hermes-chat--lifecycle-generation)))
           (puthash key (plist-put (copy-sequence prompt) :response-token nil)
                    hermes-chat--pending-prompts))))
     hermes-chat--pending-prompts))
  (setq hermes-chat--retained-clarify-owners
        (cl-remove-if
         (lambda (owner)
           (and (eq (plist-get owner :buffer) (current-buffer))
                (not (eql (plist-get owner :generation)
                          hermes-chat--lifecycle-generation))))
         hermes-chat--retained-clarify-owners)))

(defun hermes-chat--prompt-response-context (client key prompt all)
  "Claim ownership context for CLIENT, KEY, PROMPT, and ALL scope."
  (unless (eq prompt (and (hash-table-p hermes-chat--pending-prompts)
                          (gethash key hermes-chat--pending-prompts)))
    (user-error "Hermes prompt request is no longer pending"))
  (when (hermes-chat--prompt-response-in-flight-p key)
    (user-error "Hermes is accepting the previous prompt response"))
  (hermes-chat--release-auto-prompt-claim key)
  (let ((token (list key hermes-chat--lifecycle-generation))
        (response-count
         (if (and all (hermes-chat--approval-prompt-p prompt))
             (length (plist-get prompt :prompt-queue))
           1)))
    (puthash key
             (plist-put (copy-sequence (gethash key hermes-chat--pending-prompts))
                        :response-token token)
             hermes-chat--pending-prompts)
    (list :buffer (current-buffer)
          :client client
          :session-id hermes-chat--dashboard-active-session-id
          :generation hermes-chat--lifecycle-generation
          :prompts hermes-chat--pending-prompts
          :key key
          :token token
          :response-count response-count)))

(defun hermes-chat--release-prompt-response (context)
  "Release the response claim owned by CONTEXT."
  (let* ((key (plist-get context :key))
         (prompt (gethash key hermes-chat--pending-prompts))
         (claim (and (hash-table-p hermes-chat--auto-prompt-keys)
                     (gethash key hermes-chat--auto-prompt-keys))))
    (when prompt
      (hermes-chat--release-auto-prompt-claim key claim)
      (let ((restored (plist-put (copy-sequence prompt)
                                 :response-token nil)))
        (puthash key restored hermes-chat--pending-prompts)
        (when claim
          (hermes-chat--schedule-auto-prompt restored t))
        restored))))

(defun hermes-chat--prompt-response-current-p (context)
  "Return non-nil when prompt response CONTEXT still owns this chat."
  (and (eq hermes-chat--dashboard-client (plist-get context :client))
       (equal hermes-chat--dashboard-active-session-id
              (plist-get context :session-id))
       (eql hermes-chat--lifecycle-generation (plist-get context :generation))
       (eq hermes-chat--pending-prompts (plist-get context :prompts))
       (eq (plist-get
            (gethash (plist-get context :key) hermes-chat--pending-prompts)
            :response-token)
           (plist-get context :token))))

(defun hermes-chat--prompt-success-callback (context prompt canceled)
  "Return a success callback for PROMPT response owned by CONTEXT."
  (lambda (result)
    (hermes-chat--in-buffer (plist-get context :buffer)
      (when (hermes-chat--prompt-response-current-p context)
        (hermes-chat--settle-retained-clarify context)
        (if (or (hermes-chat--approval-response-unresolved-p prompt result)
                (hermes-chat--prompt-response-expired-p result))
            (hermes-chat--prompt-response-stale context prompt)
          (hermes-chat--prompt-response-complete
           context prompt canceled result))))))

(defun hermes-chat--prompt-reject-callback
    (context prompt response preserve-response)
  "Return an error callback for PROMPT and RESPONSE owned by CONTEXT."
  (lambda (message)
    (hermes-chat--in-buffer (plist-get context :buffer)
      (when (hermes-chat--prompt-response-current-p context)
        (hermes-chat--prompt-response-rejected
         context prompt response message preserve-response)))))

(defun hermes-chat--batch-clarify-answer-alist (prompt)
  "Return PROMPT's accepted batch answers as a string-keyed alist."
  (let ((answers (hermes-chat--event-value prompt '(:answers))))
    (cl-loop for question in (hermes-chat--prompt-questions prompt)
             for qid = (hermes-transport--scalar-string
                        (hermes-transport--get question 'qid))
             when (and qid (hermes-transport--field-present-p answers qid))
             collect (cons qid (hermes-transport--get answers qid)))))

(defun hermes-chat--record-batch-clarify-answer (context qid answer)
  "Record QID's accepted ANSWER in the batch prompt owned by CONTEXT."
  (let* ((key (plist-get context :key))
         (prompt (gethash key hermes-chat--pending-prompts))
         (answers (hermes-chat--batch-clarify-answer-alist prompt))
         (updated (cons (cons qid answer)
                        (cl-remove qid answers :key #'car :test #'equal)))
         (base (plist-put (copy-sequence prompt) :answers updated))
         (content (hermes-dashboard-transport--batch-clarify-content base))
         (next (plist-put (plist-put base :content content)
                          :prompt-content content)))
    (puthash key next hermes-chat--pending-prompts)
    (when-let* ((assistant-id (plist-get next :assistant-id)))
      (hermes-chat--upsert-transport-entry assistant-id next))))

(defun hermes-chat--batch-clarify-success-callback
    (context prompt qid answer remaining)
  "Return callback advancing PROMPT response CONTEXT through REMAINING answers."
  (lambda (result)
    (hermes-chat--in-buffer (plist-get context :buffer)
      (when (hermes-chat--prompt-response-current-p context)
        (cond
         ((hermes-chat--prompt-response-expired-p result)
          (hermes-chat--prompt-response-stale context prompt))
         (remaining
          (hermes-chat--record-batch-clarify-answer context qid answer)
          (hermes-chat--send-next-batch-clarify context prompt remaining))
         (t
          (hermes-chat--record-batch-clarify-answer context qid answer)
          (hermes-chat--prompt-response-complete
           context prompt nil result)))))))

(defun hermes-chat--send-next-batch-clarify (context prompt responses)
  "Send RESPONSES' next batch answer for PROMPT owned by CONTEXT."
  (pcase-let* ((`(,qid . ,answer) (car responses))
               (request (hermes-chat--request-prompt-id
                         (plist-get context :key) prompt)))
    (condition-case err
        (hermes-dashboard-transport-clarify-question-respond
         (plist-get context :client) request qid answer
         (hermes-chat--batch-clarify-success-callback
          context prompt qid answer (cdr responses))
         (hermes-chat--prompt-reject-callback
          context prompt answer nil))
      (error
       (when (hermes-chat--prompt-response-current-p context)
         (hermes-chat--prompt-response-rejected
          context prompt answer (error-message-string err)))))))

(defun hermes-chat--send-batch-clarify-response
    (key prompt responses owner)
  "Send RESPONSES for batch PROMPT under KEY and OWNER."
  (unless responses
    (user-error "No unanswered Hermes clarification questions"))
  (let* ((client (plist-get owner :client))
         (context (hermes-chat--prompt-response-context
                   client key prompt nil)))
    (hermes-chat--send-next-batch-clarify context prompt responses)))

(defun hermes-chat--approval-session-id (prompt)
  "Return the dashboard session id for approval PROMPT."
  (or (hermes-chat--event-string prompt '(:session-id :session_id))
      hermes-chat--dashboard-active-session-id))

(defun hermes-chat--request-prompt-id (key prompt)
  "Return request id for prompt KEY/PROMPT."
  (or (hermes-chat--event-string prompt '(:request-id :request_id)) key))

(defun hermes-chat--send-prompt-response
    (key prompt response all canceled &optional preserve-response owner)
  "Send RESPONSE for prompt KEY/PROMPT through the dashboard transport."
  (when (and owner (not (hermes-chat--prompt-owner-current-p owner)))
    (user-error "Hermes prompt request is no longer current"))
  (let* ((client (if owner
                     (plist-get owner :client)
                   (hermes-chat--dashboard-control-client)))
         (context (hermes-chat--prompt-response-context
                   client key prompt all))
         (type (hermes-chat--prompt-event-type prompt))
         (context (if (and preserve-response
                           (hermes-chat--clarify-prompt-p prompt))
                      (hermes-chat--retain-clarify-response context response)
                    context)))
    (condition-case err
        (pcase type
          ("approval"
           (hermes-dashboard-transport-approval-respond
            client :session-id (hermes-chat--approval-session-id prompt)
            :choice response :all (and all t)
            :resolve (hermes-chat--prompt-success-callback
                      context prompt canceled)
            :reject (hermes-chat--prompt-reject-callback
                     context prompt response preserve-response)))
          ((or "clarify" "sudo" "secret")
           (funcall (pcase type
                      ("clarify" #'hermes-dashboard-transport-clarify-respond)
                      ("sudo" #'hermes-dashboard-transport-sudo-respond)
                      ("secret" #'hermes-dashboard-transport-secret-respond))
                    client (hermes-chat--request-prompt-id key prompt) response
                    (hermes-chat--prompt-success-callback
                     context prompt canceled)
                    (hermes-chat--prompt-reject-callback
                     context prompt response preserve-response)))
          ("terminal"
           (hermes-dashboard-transport-terminal-read-respond
            client (hermes-chat--request-prompt-id key prompt) response
            (hermes-chat--prompt-success-callback
             context prompt canceled)
            (hermes-chat--prompt-reject-callback
             context prompt response preserve-response)))
          (_ (user-error "Unsupported Hermes prompt type: %s" type)))
      (error
       (if (hermes-chat--prompt-response-current-p context)
           (hermes-chat--prompt-response-rejected
            context prompt response (error-message-string err)
            preserve-response)
         (signal
          (car err)
          (if (hermes-chat--clarify-prompt-p prompt)
              (cdr err)
            (list (hermes-chat--prompt-safe-error
                   prompt response (error-message-string err)))))))
      (quit
       (when (hermes-chat--prompt-response-current-p context)
         (hermes-chat--release-prompt-response context)
         (hermes-chat--settle-retained-clarify context t)
         (when (and preserve-response
                    (hermes-chat--clarify-prompt-p prompt)
                    (not (plist-get context :retained-owner-sink)))
           (hermes-chat--restore-prompt-response response)))
       (signal (car err) (cdr err))))))

(defun hermes-chat-respond-to-prompt (&optional key response all preserve-response)
  "Respond to pending prompt KEY with RESPONSE.
When called interactively, select the prompt and read RESPONSE in the
minibuffer.  With prefix argument ALL, approval responses apply to all pending
approvals in the dashboard session.  PRESERVE-RESPONSE keeps programmatic
clarification input recoverable when the request fails."
  (interactive (list nil nil current-prefix-arg))
  (let* ((prompt-key (hermes-chat--select-pending-prompt-key key))
         (prompt (hermes-chat--pending-prompt prompt-key))
         (context (hermes-chat--prompt-owner-context prompt-key prompt)))
    (when (hermes-chat--prompt-response-in-flight-p prompt-key)
      (user-error "Hermes is accepting the previous prompt response"))
    (if (hermes-chat--batch-clarify-p prompt)
        (let ((responses (hermes-chat--batch-clarify-responses prompt response)))
          (unless (hermes-chat--prompt-owner-current-p context)
            (user-error "Hermes prompt request is no longer current"))
          (hermes-chat--send-batch-clarify-response
           prompt-key prompt responses context))
      (let ((answer (or response (hermes-chat--read-prompt-response prompt))))
        (unless (hermes-chat--prompt-owner-current-p context)
          (user-error "Hermes prompt request is no longer current"))
        (hermes-chat--send-prompt-response
         prompt-key prompt answer all nil preserve-response context)))))

(defun hermes-chat-cancel-prompt (&optional key)
  "Cancel pending prompt KEY by sending the protocol's safe empty/deny value."
  (interactive)
  (let* ((prompt-key (hermes-chat--select-pending-prompt-key key))
         (prompt (hermes-chat--pending-prompt prompt-key))
         (response (if (equal (hermes-chat--prompt-event-type prompt) "approval")
                       "deny"
                     "")))
    (hermes-chat--send-prompt-response prompt-key prompt response nil t)))

(add-hook 'hermes-chat-lifecycle-invalidation-hook #'hermes-chat--release-all-prompt-response-claims)

(provide 'hermes-chat-prompts)
;;; hermes-chat-prompts.el ends here
