# NousResearch/hermes-agent · clarify/secret prompt timeout and expiry handling

## Open now

- Nothing open on GitHub.

These issues and fixes address how clarify and secret prompts behave when they expire or receive late responses, including hard errors returned to clients, uncancellable overlays in the TUI, and lost resolver state across Telegram gateway restarts.

## TL;DR

- 9 member(s): 0 open issue(s), 3 closed issue(s), 0 open PR(s), 3 merged PR(s), 3 PR(s) closed without merging.
- A fix was merged to main in PR #54056. Verify it addresses your case.
- **Recommended:** read PR #54056 (and any competing PRs below) before filing. Confirm the merged change covers your symptom.

## Maintainer resolution verdict

- Internal-tracker verdict over this cluster's issue members: `resolved` · deciding PR(s): `pr-54056,pr-61885,pr-69773`. This is distinct from the fix status above. Weigh it yourself.

## How this cluster was grouped

- Link evidence (`STRUCTURAL_PARTIAL`): some members are connected by structural evidence (shared changed files, or a closing/superseded reference); the rest are joined by an automated relevance judgement over their text.
- Members outside that structural core may belong to a different underlying problem.
- Pairwise link density `0.19`: the share of member pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36. `1.00` means every pair is linked.

## Closed / duplicate issues

- #32762: gateway: Telegram clarify-button resolver loses state on SIGTERM restart and 600s timeout
- #56558: gateway: clarify.respond to an expired prompt returns hard 4009 'no pending answer request', leaking to client and wedging the chat
- #61748: TUI secret.respond zombie overlay: 4009 'no pending value request' leaves secret prompt uncancellable

## Prior & competing PRs

- **#54056: fix(clarify): notify on expired Telegram tap + raise default clarify_timeout (#32762)**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`; implication `superseded`
  - files: `hermes_cli/config.py`, `infographic/clarify-expiry-32762/infographic.png`, `plugins/platforms/telegram/adapter.py`, `tests/gateway/test_telegram_clarify_buttons.py`, `tests/tools/test_clarify_gateway.py`, `tools/clarify_gateway.py`
  - fix hunks:
  - `hermes_cli/config.py`

    ```diff
    @@ -1012,7 +1012,13 @@ def _ensure_hermes_home_managed(home: Path):
             # unblocks with "[user did not respond within Xm]" so it can adapt
             # rather than pinning the running-agent guard forever.  CLI clarify
             # blocks indefinitely (input() is synchronous) and ignores this.
    -        "clarify_timeout": 600,
    +        # Default 3600 (1h): real users step away (meetings, AFK) and the
    +        # old 600s default evicted the entry mid-think, so a later button
    +        # tap landed on a dead entry (#32762).  Tradeoff: a higher value
    +        # holds the gateway's running-agent guard longer for a genuinely
    +        # abandoned prompt — lower it if a single session must free up the
    +        # guard sooner.
    +        "clarify_timeout": 3600,
             # Periodic "still working" notification interval (seconds).
             # Sends a status message every N seconds so the user knows the
             # agent hasn't died during long tasks.  0 = disable notifications.
    ```
  - `plugins/platforms/telegram/adapter.py`

    ```diff
    @@ -4292,6 +4292,31 @@ def get_label(slug):
                 # Catch-all (e.g. page counter button "mx:noop")
                 await query.answer()
     
    +    async def _notify_clarify_expired(self, query, user_display: str) -> None:
    +        """Tell the user a clarify tap arrived too late to be delivered.
    +
    +        Fires when the clarify entry was evicted by ``clarify_timeout`` or the
    +        gateway restarted between asking and the tap. In both cases the agent
    +        thread is no longer waiting, so the tap would otherwise leave a
    +        misleading ✓ (or an "awaiting typed response" prompt) on a button the
    +        agent never receives.
    +        """
    +        try:
    +            await query.answer(text="⚠️ This prompt expired — please /retry.")
    +        except Exception:
    +            pass
    +        try:
    +            await query.edit_message_text(
    +                text=(
    +                    f"❓ {_html.escape(query.message.text or '')}\n\n"
    +                    "<i>⚠️ This question expired or the session reset — please /retry.</i>"
    +                ),
    +                parse_mode=ParseMode.HTML,
    +                reply_markup=None,
    +            )
    +        except Exception:
    +            pass
    +
         async def _handle_callback_query(
             self, update: "Update", context: "ContextTypes.DEFAULT_TYPE"
         ) -> None:
    ```

    ```diff
    @@ -4529,12 +4554,20 @@ async def _handle_callback_query(
                         # clarify.  Do NOT pop _clarify_state yet — we still
                         # need it if the user is slow to respond and the entry
                         # is cleared by something else.
    +                    flipped = False
                         try:
                             from tools.clarify_gateway import mark_awaiting_text
    -                        mark_awaiting_text(clarify_id)
    +                        flipped = mark_awaiting_text(clarify_id)
                         except Exception as exc:
                             logger.warning("[%s] mark_awaiting_text failed: %s", self.name, exc)
     
    +                    if not flipped:
    +                        # Entry evicted (clarify_timeout) or gateway restarted
    +                        # between ask and tap — a typed answer would go nowhere.
    +                        self._clarify_state.pop(clarify_id, None)
    +                        await self._notify_clarify_expired(query, user_display)
    +                        return
    +
                         await query.answer(text="✏️ Type your answer in the chat.")
                         try:
                             await query.edit_message_text(
    ```

    ```diff
    @@ -4580,22 +4613,25 @@ async def _handle_callback_query(
                         logger.error("[%s] resolve_gateway_clarify failed: %s", self.name, exc)
                         resolved = False
     
    -                await query.answer(text=f"✓ {resolved_text[:60]}")
    -                try:
    -                    await query.edit_message_text(
    -                        text=f"❓ {_html.escape(query.message.text or '')}\n\n<b>{_html.escape(user_display)}:</b> {_html.escape(resolved_text)}",
    -                        parse_mode=ParseMode.HTML,
    -                        reply_markup=None,
    -                    )
    -                except Exception:
    -                    pass
    -
                     if resolved:
    +                    await query.answer(text=f"✓ {resolved_text[:60]}")
    +                    try:
    +                        await query.edit_message_text(
    +                            text=f"❓ {_html.escape(query.message.text or '')}\n\n<b>{_html.escape(user_display)}:</b> {_html.escape(resolved_text)}",
    +                            parse_mode=ParseMode.HTML,
    +                            reply_markup=None,
    +                        )
    +                    except Exception:
    +                        pass
                         logger.info(
                             "Telegram clarify button resolved (id=%s, choice=%r, user=%s)",
                             clarify_id, resolved_text, user_display,
                         )
                     else:
    +                    # Entry evicted (clarify_timeout) or gateway restarted
    +                    # between ask and tap — surface this instead of leaving a
    +                    # misleading ✓ on a button the agent will never receive.
    +                    await self._notify_clarify_expired(query, user_display)
                         logger.warning(
                             "Telegram clarify button: resolve_gateway_clarify returned False (id=%s)",
                             clarify_id,
    ```
  - `tests/gateway/test_telegram_clarify_buttons.py`

    ```diff
    @@ -351,6 +351,71 @@ def _is_user_authorized(self, source):
             # State preserved
             assert adapter._clarify_state["cidC"] == "sk-auth"
     
    +    @pytest.mark.asyncio
    +    async def test_numeric_choice_expired_notifies_user(self):
    +        """Late tap after the entry was evicted (timeout) or the gateway
    +        restarted must surface an expiry notice, not a misleading ✓."""
    +        adapter = _make_adapter()
    +        # _clarify_state still maps the id (timeout eviction does not pop it),
    +        # but the clarify primitive entry is gone → resolve returns False.
    +        adapter._clarify_state["cidExpired"] = "sk-expired"
    +
    +        query = AsyncMock()
    +        query.data = "cl:cidExpired:0"
    +        query.message = MagicMock()
    +        query.message.chat_id = 12345
    +        query.message.text = "Pick"
    +        query.from_user = MagicMock()
    +        query.from_user.id = "777"
    +        query.from_user.first_name = "Tester"
    +        query.answer = AsyncMock()
    +        query.edit_message_text = AsyncMock()
    +
    +        update = MagicMock()
    +        update.callback_query = query
    +        context = MagicMock()
    +
    +        with patch.dict(os.environ, {"TELEGRAM_ALLOWED_USERS": "*"}, clear=False):
    +            await adapter._handle_callback_query(update, context)
    +
    +        # User is told the prompt expired — not a misleading checkmark.
    +        answer_text = query.answer.call_args[1]["text"].lower()
    +        assert "expired" in answer_text
    +        edit_text = query.edit_message_text.call_args[1]["text"].lower()
    +        assert "expired" in edit_text or "session reset" in edit_text
    +        assert "/retry" in edit_text
    +
    +    @pytest.mark.asyncio
    +    async def test_other_button_expired_notifies_user(self):
    +        """Tapping 'Other' after the entry was evicted must tell the user the
    +        prompt expired instead of silently entering text-capture mode."""
    +        adapter = _make_adapter()
    +        # No clarify primitive entry → mark_awaiting_text returns False.
    +        adapter._clarify_state["cidOtherExpired"] = "sk-other-expired"
    +
    +        query = AsyncMock()
    +        query.data = "cl:cidOtherExpired:other"
    +        query.message = MagicMock()
    +        query.message.chat_id = 12345
    +        query.message.text = "Pick"
    +        query.from_user = MagicMock()
    +        query.from_user.id = "777"
    +        query.from_user.first_name = "Tester"
    +        query.answer = AsyncMock()
    +        query.edit_message_text = AsyncMock()
    +
    +        update = MagicMock()
    +        update.callback_query = query
    +        context = MagicMock()
    +
    +        with patch.dict(os.environ, {"TELEGRAM_ALLOWED_USERS": "*"}, clear=False):
    +            await adapter._handle_callback_query(update, context)
    +
    +        answer_text = query.answer.call_args[1]["text"].lower()
    +        assert "expired" in answer_text
    +        # State popped so a subsequent typed message is not mis-captured.
    +        assert "cidOtherExpired" not in adapter._clarify_state
    +
         @pytest.mark.asyncio
         async def test_invalid_choice_token(self):
             from tools import clarify_gateway as cm
    ```
  - `tests/tools/test_clarify_gateway.py`

    ```diff
    @@ -167,11 +167,11 @@ def test_session_index_isolation(self):
             assert b is not None and b.clarify_id == "idB"
     
         def test_clarify_timeout_config_default(self):
    -        """get_clarify_timeout returns 600 by default."""
    +        """get_clarify_timeout returns a positive int (default 3600)."""
             from tools import clarify_gateway as cm
     
             timeout = cm.get_clarify_timeout()
    -        # Default 600s OR whatever is in the user's loaded config.
    +        # Default 3600s OR whatever is in the user's loaded config.
             # Floor check: must be a positive int, not crashed.
             assert isinstance(timeout, int)
             assert timeout > 0
    ```
  - `tools/clarify_gateway.py`

    ```diff
    @@ -231,10 +231,13 @@ def clear_session(session_key: str) -> int:
     def get_clarify_timeout() -> int:
         """Read the clarify response timeout (seconds) from config.
     
    -    Defaults to 600 (10 minutes) — long enough for the user to type a
    -    thoughtful response, short enough that an abandoned prompt eventually
    +    Defaults to 3600 (1 hour) — long enough that a user who steps away
    +    (meeting, AFK, slow to read) still finds a live entry when they tap
    +    the button, short enough that a genuinely abandoned prompt eventually
         unblocks the agent thread instead of pinning the running-agent guard
    -    forever.
    +    forever.  The old 600s default evicted the entry mid-think, so a late
    +    tap landed on a dead entry and the agent hung on ``running: clarify``
    +    (#32762).
     
         Reads ``agent.clarify_timeout`` from config.yaml.
         """
    ```

    ```diff
    @@ -242,9 +245,9 @@ def get_clarify_timeout() -> int:
             from hermes_cli.config import load_config
             cfg = load_config() or {}
             agent_cfg = cfg.get("agent", {}) or {}
    -        return int(agent_cfg.get("clarify_timeout", 600))
    +        return int(agent_cfg.get("clarify_timeout", 3600))
         except Exception:
    -        return 600
    +        return 3600
     
     
     # =========================================================================
    ```
- **#61885: fix(tui): dismiss expired sensitive prompts**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`; implication `superseded`
  - files: `tests/tui_gateway/test_protocol.py`, `tui_gateway/server.py`, `ui-tui/README.md`, `ui-tui/src/__tests__/createGatewayEventHandler.test.ts`, `ui-tui/src/__tests__/useInputHandlers.test.ts`, `ui-tui/src/app/createGatewayEventHandler.ts`, `ui-tui/src/app/useInputHandlers.ts`, `ui-tui/src/app/useMainApp.ts`, `ui-tui/src/gatewayTypes.ts`, `website/docs/developer-guide/programmatic-integration.md`
- **#69773: fix(tui_gateway): tolerate late clarify + terminal.read replies after timeout**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`; implication `superseded`
  - files: `tests/tui_gateway/test_protocol.py`, `tui_gateway/server.py`
- **#33850: fix(telegram): persist clarify-button resolver state across restarts**. closed without merge
  - maintainer direction: category `uncategorized`; implication `needs_llm`; refs `#32878`, `#33073`, `#32878`, `#33073`, `#32878`, `#33073`, `#32878`, `#33073`
- **#56571: fix(tui): gracefully handle late clarify responses after timeout**. closed without merge
  - maintainer direction: category `superseded_by_pr`; implication `superseded`; refs `#69773`, `#64886`
- **#56596: fix(tui): consume expired clarify responses**. closed without merge
  - maintainer direction: category `uncategorized`; implication `needs_llm`

## Fix status

- Merged to main in PR #54056 (cited above). No release data is tracked. Verify downstream.
- Fix last verified (merged) `2026-07-23`.
- Subsystem: `dashboard`
