Skip to content

claude: degrade AskUserQuestion to a text question when viewed in chat mode#353

Merged
edwin-zvs merged 4 commits into
mainfrom
claude-askuserquestion-chat-gate
Jun 6, 2026
Merged

claude: degrade AskUserQuestion to a text question when viewed in chat mode#353
edwin-zvs merged 4 commits into
mainfrom
claude-askuserquestion-chat-gate

Conversation

@edwin-zvs

Copy link
Copy Markdown
Contributor

Problem

When a Claude session runs interactively, AskUserQuestion renders as a native TUI picker inside the PTY. agentd's chat view shows the structured transcript, not the live PTY — so a user watching in chat mode sees the question arrive as an inert tool call and has no way to answer it without switching to the terminal view. (Headless mode is worse: Claude auto-dismisses AskUserQuestion entirely.)

We explored reconstructing the picker natively in chat, or driving Claude's widget via keystrokes — both are brittle: we can't read the widget's cursor state, and Claude can change its widget/keymap at any time. Driving an opaque TUI we don't own risks committing the wrong answer silently.

Approach — let Claude ask in plain text, but only when it matters

A PreToolUse hook on AskUserQuestion, injected by the adapter, denies the tool with a reason instructing Claude to ask the question as a plain-text message with the options inline. Claude complies, and that text question is visible in chat and answerable via the normal send-message path. Conditional: the hook only degrades when a chat viewer is active for that session — terminal viewers keep Claude's native picker untouched.

Key property: we suppress the native picker (deny before it renders) rather than reconstruct or drive it — so there's nothing brittle to break, and it's immune to Claude changing its widgets.

model calls AskUserQuestion
  └─ PreToolUse hook → `agent ask-gate`
       └─ asks daemon: chat viewer active for this session?
          ├─ yes → deny("ask as plain text…")  → Claude re-asks as text → answer in chat
          └─ no  → allow                        → native picker renders in the terminal

Changes

  • protocolClientView{Chat,Terminal}; session.set_view / session.chat_viewer_active.
  • daemon — track (conn → session, view) per client connection; clear on disconnect; answer chat_viewer_active.
  • client / cliset_view + chat_viewer_active; hidden agent ask-gate hook body. Fails open (any error/timeout/ambiguity → allow) so it never strands the model.
  • adapter-claude — injects --settings registering the gate hook (interactive and headless). --settings merges with the user's settings. Opt out with AGENTD_CLAUDE_ASKGATE=0.
  • TUI + webui — report chat/terminal view via set_view (terminal only when actually viewing a live PTY).

Verification (each link proven against the real claude binary)

  • Live PTY (Phase 0): denying AskUserQuestion mid-turn suppresses the picker cleanly — no widget, tidy inline error, model re-asks as text, turn completes.
  • Deny → text fallback: confirmed the model re-asks with options inline (1) … 2) …).
  • --settings merge: a project hook and the injected hook both fire — no clobbering.
  • Backend end-to-end: a connected chat viewer makes ask-gate deny; disconnect makes it allow (per-connection cleanup); unknown/no viewer → allow.
  • Builds clean; 223 cli + protocol tests pass.

Notes

  • The injected hook carries is_error:true on the synthesized tool_result; the model handles it correctly (treats the reason as guidance and re-asks).
  • Bonus: this also makes headless AskUserQuestion answerable-as-text when a chat viewer is present, instead of auto-dismissing.
  • Not included: a single fully-integrated webui demo recording (every link is individually verified). Happy to add one.

Binaries

Code lives in agentd (daemon), agent (CLI), agentd-adapter-claude.

edwin-zvs added 4 commits June 5, 2026 20:57
Foundation for the AskUserQuestion chat-gate: clients report which surface
(chat vs terminal) they're viewing a session through, and the daemon can be
asked whether any chat viewer is active. Phase-0 verified that a PreToolUse
deny cleanly suppresses Claude's native picker in a live PTY (no widget, tidy
inline error, model re-asks as text).
- daemon: track (conn_id -> session, view) per client connection; clear on
  disconnect; answer session.chat_viewer_active. Wire session.set_view +
  session.chat_viewer_active into dispatch.
- client: set_view / chat_viewer_active methods.
- cli: hidden 'agent ask-gate' PreToolUse hook body — denies (degrade to
  text) only when a chat viewer is confirmed active; fails open otherwise.

Verified live: gate allows with no viewer and fails open on unknown session.
Both interactive and headless arg paths now pass --settings pointing the
AskUserQuestion matcher at 'agent ask-gate'. Verified --settings merges with
the user's existing hooks (doesn't clobber). Opt out via AGENTD_CLAUDE_ASKGATE=0.
Activates the AskUserQuestion chat-gate: both clients now tell the daemon
which surface they're showing the focused session through.
- TUI: debounced report_view() once per loop iteration.
- webui: reportView() on selectSession + view-mode toggle (terminal only when
  actually viewing a live PTY).
Plus the test_app constructor field.

Full chain verified end-to-end earlier: a chat viewer makes the gate deny,
which makes Claude re-ask AskUserQuestion as plain text; disconnect clears it.
@edwin-zvs
edwin-zvs merged commit b8659ee into main Jun 6, 2026
1 check passed
@edwin-zvs
edwin-zvs deleted the claude-askuserquestion-chat-gate branch June 6, 2026 04:01
@edwin-zvs edwin-zvs mentioned this pull request Jun 6, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant