[Bug]: Claude AskUserQuestion renders as a plain tool call in the desktop Chat UI on a paired headless Remote Orca Server — no question card, answerable only in the terminal · Issue #11761 · stablyai/orca · GitHub
Skip to content

[Bug]: Claude AskUserQuestion renders as a plain tool call in the desktop Chat UI on a paired headless Remote Orca Server — no question card, answerable only in the terminal #11761

Closed

Description

@mattbkiv

Environment

  • Server: headless orca serve (orca-ide --serve --serve-port 6768 --pairing-address <tailnet-ip>) on an Ubuntu 26.04 LTS Hetzner VPS, v1.4.162, reachable tailnet-only
  • Client: Orca desktop on Windows, paired to that server
  • Agent: Claude Code, running in a workspace on the server
  • View: native Chat UI (not terminal view)

The desktop host's shell (PowerShell vs WSL) is not in this path — the agent runs on the remote Linux host and the desktop is only a viewer. This is a different topology from the WSL Chat UI reports (#10326, #10523, #11327).

Symptom

When Claude calls AskUserQuestion, the desktop Chat UI renders it as an ordinary collapsed tool call in the message stream. No question card, no clickable options. The session reads as still working, so you wait on an agent that is actually blocked on you. The question is only discoverable by expanding the tool-call details, and only answerable by switching to the terminal view, where the TUI prompt has been waiting the whole time.

Root cause — two independent layers

1. The desktop chat has no transcript fallback for the ask prompt; mobile has one

src/renderer/src/components/native-chat/NativeChatInteractiveCard.tsx derives the card from exactly one source:

const interactivePrompt = useAppStore(
  (s) => s.agentStatusByPaneKey[paneKey]?.interactivePrompt ?? null
)

NativeChatView.tsx imports NativeChatInteractiveCard and nothing else — there is no transcript-derived path anywhere in the desktop view.

Mobile resolves from two sources, in mobile/src/session/use-mobile-native-chat-prompts.ts:

const askFromStatus = useMemo(
  () => parseAskFromStatus(status?.interactivePrompt, status?.toolName), ...)
const askFromMessages = useMemo(
  () => (askFromStatus ? null : extractPendingAsk(messages)), ...)
return { ..., ask: enabled ? (askFromStatus ?? askFromMessages) : null }

extractPendingAsk lives in src/shared/native-chat-ask.ts — cross-platform code — but is imported only by mobile. Its own docstring describes the resilience the desktop therefore lacks:

Transcript replay parses each tool-call through the same registered-parser + canonical-shape fallback as live status, so a question tool that rendered live cannot vanish from pending state after a reconnect/replay.

Consequence: on desktop, if the live status never arrives, the card cannot render at all — even though the pending AskUserQuestion tool call is sitting right there in the transcript the view has already parsed and rendered.

2. HTTP-hook status does not reach paired desktop clients on a headless server (#11210)

Claude reports status over the HTTP hook Orca installs into ~/.claude/settings.json (src/main/claude/hook-settings.ts, PreToolUse among others), and src/shared/agent-hook-listener.ts is what stamps interactivePrompt when the tool is AskUserQuestion.

That is precisely the transport #11210 documents as broken under headless --serve:

applyNormalizedStatus only updates the in-process cache, writes last-status.json, and sends agentStatus:set over Electron IPC to the local renderer. On a headless server there is no renderer, so the IPC is a no-op. […] session.tabs's agent-status projection (getFreshRetainedAgentStatusForMobileTab) only reads the OSC 9999 retained store, not the agent-hook cache.

#11210 was filed for OpenCode's missing activity dots, but the broken path is shared by every HTTP-hook agent, and Claude is one of them.

Verified on the affected server:

  • ~/.claude/settings.json contains the Orca hook entries invoking ~/.orca/agent-hooks/claude-hook.sh for PreToolUse / PostToolUse / PermissionRequest / Stop / SubagentStart etc.
  • ~/.config/orca/agent-hooks/last-status.json is written live with agentType: "claude" rows carrying state and toolName — so server-side capture works.

PR #11212 fixes the projection for working/idle state. It is not clear from that diff whether interactivePrompt and toolName are carried through to paired clients, which is what the question card needs.

Now confirmed (see the follow-up comment below): polling that cache at 200 ms during a live AskUserQuestion captured 188 consecutive samples over 6m35s, every one carrying state: "waiting", toolName: "AskUserQuestion" and the full untruncated interactivePrompt JSON — while the paired desktop client showed only a collapsed tool call. Server-side capture is correct; the defect is in delivery to paired clients and in the absence of a desktop fallback.

Why this is worse than a missing activity dot

A missing dot is cosmetic. A missing question card means the agent is blocked on human input with no signal in the view the user is looking at — the session reads as working. On a remote server this is the ordinary case, not an edge case, and the workaround (switch to terminal view) defeats the purpose of the Chat UI.

Expected

The question card renders in the desktop Chat UI whenever Claude has an unanswered AskUserQuestion, whether the agent runs locally or on a paired headless remote server, and answering from the card works without switching to terminal view.

Proposed fix

Two parts, ideally both:

  1. Desktop transcript fallback (client-side, robust). Give the desktop view the same askFromStatus ?? extractPendingAsk(messages) resolution mobile already has. The shared helper exists and is already covered by tests; this survives any relay gap, not only this one, and needs no server change.
  2. Carry the full hook payload over the headless relay (server-side). Ensure the fix(agent-status): sync HTTP-hook status to remote clients on headless servers #11212 projection propagates interactivePrompt and toolName, not just live working/idle state, so the live path works as designed.

Local modifications (disclosure)

The server has one local patch, applied because the AppImage AppRun unconditionally injects --no-sandbox, which crashes ELECTRON_RUN_AS_NODE mode on a userns-restricted host. The patch replaces the orca-ide CLI wrapper on $PATH so it execs the extracted app directly. It does not modify the application bundle, and the running serve process is the stock AppImage. Mentioning it for completeness only — it is not plausibly related to chat rendering.

Related

Activity

  1. mattbkiv commented on Jul 31, 2026

    @mattbkiv
    Author

    Confirmed: the server captures everything the card needs — the gap is delivery, not capture

    Closing the one open question from the report. I polled ~/.config/orca/agent-hooks/last-status.json at 200 ms while an AskUserQuestion was live, since that file retains only the newest status per pane and is therefore useless to read after the fact.

    Result: 188 consecutive samples over 6m35s (12:05:03 → 12:11:38), every one of them carrying the complete prompt.

    {
      "state": "waiting",
      "toolName": "AskUserQuestion",
      "hasInteractivePrompt": true,
      "interactivePrompt": "{\"questions\":[{\"question\":\"Given that AskUserQuestion is currently invisible in the desktop Chat UI on this VPS, how should I handle questions for the rest of our sessions here?\",\"header\":\"Workaround\",\"options\":[{\"label\":\"Ask in plain chat text, skip the tool (Recommended)\", ...
      "pane": "08607fd9-31c2-4898…"
    }

    So on the headless --serve host the hook pipeline is working exactly as designed: state: "waiting", the correct toolName, and the full untruncated interactivePrompt JSON — the three fields parseAskFromStatus(status.interactivePrompt, status.toolName) needs to build the card.

    Meanwhile the paired Windows desktop client showed the call as an ordinary collapsed tool call in the Chat UI, with the question answerable only after switching to terminal view.

    That isolates the defect precisely. Server-side capture: correct. Client-side parser: correct, and already shared. What is missing is (1) the transport that would carry interactivePrompt / toolName from the headless server's agent-hook cache to a paired client's agentStatusByPaneKey, and (2) any desktop fallback for when it doesn't arrive.

    Point (2) is worth restating in light of this: the pending AskUserQuestion tool call was in the transcript the desktop Chat UI had already parsed and rendered — it drew it as a tool-call block. extractPendingAsk(messages) would have found it there with no server change whatsoever. Mobile does exactly that. The desktop simply never asks.

    Correction to an earlier draft of this comment

    An earlier version of this comment inferred something about the waiting state not clearing at answer time. That inference was unsound and I have removed it: the sampling window closed at 12:11:39, while the tool call did not return an answer until 12:33:44 — 22 minutes later. The window therefore says nothing about clearing behaviour, only that the prompt was continuously present and correct on the server for as long as it was observed.

    What the window does establish stands unchanged: for its full duration the question was genuinely pending and the server held state: "waiting", the correct toolName, and the complete interactivePrompt.

  2. mattbkiv commented on Jul 31, 2026

    @mattbkiv
    Author

    This is worse than a hidden question: the invisible card silently converts the user's next message into an answer

    Reporting a consequence we hit twice while investigating this issue. It escalates the severity well beyond "the card doesn't render".

    What happened

    Two AskUserQuestion calls in this session returned answers that the user never gave. Both selected option index 0 — the one labelled "(Recommended)". The agent then acted on both as if they were decisions.

    Transcript evidence (Claude Code 2.1.220):

    tool call answer returned user's next real message
    Q1 11:04:16Z 11:07:22Z 11:09:58Z (2m36s later)
    Q2 12:05:02Z 12:33:44Z 12:38:41Z (4m57s later)

    Both answer records carry type: user, userType: "external", entrypoint: "cli" — so the answer arrived through the TUI, indistinguishable from a real keypress. Nothing synthesised it upstream. And no user turn exists in the transcript at either answer timestamp: the user's typed text was consumed, not delivered.

    Mechanism

    The user's own explanation, which the source supports:

    1. The question card, when it renders, replaces the composer — that is the stated purpose of onShowingQuestionChange in NativeChatInteractiveCard.tsx: "Reports whether a question card is on screen so the view can replace the composer with it (the card's free-text row is the answer input)."
    2. Because of this bug the card never renders, so the composer stays live over a pane whose TUI is sitting on a question selector.
    3. A composer send writes into that PTY. Per this repo's own note on STA-1860 (native-chat-agent-support.ts), Claude's selector "ignores typed label text (pasting "Blue" + Enter commits the highlighted FIRST option)".
    4. So typing any ordinary message and pressing Enter commits option 1 and discards the message.

    #11511 is the same event observed from the Chat UI side: a composer send the agent never accepts leaves a permanent optimistic "sent" bubble, because the only thing that retires it is a matching transcript turn that will never exist.

    Why this deserves more weight than a rendering defect

    A hidden question wastes time. A hidden selector under a live composer fabricates consent: the user's next message becomes an affirmative answer to a question they never saw, and it lands on the highlighted default — which, by convention, is usually the option the agent recommended. It then reads downstream as a considered decision. In our case the agent reported one of these back to the user as "you chose X"; the user had chosen nothing.

    For anyone using agents to gate real actions behind AskUserQuestion, that is a correctness problem, not a UX one.

    Suggested handling

    The fix already proposed in this issue resolves it — render the card and the composer is replaced, closing the hole. Two defensive additions worth considering independently:

    • Never write a composer send into a pane with a known-pending question tool. The pending state is derivable from the transcript via extractPendingAsk even when live status is unavailable, which is exactly the fallback this issue asks for.
    • Do not treat a bare Enter as an answer commit when the selector received text it discarded — an ignored-text commit is indistinguishable from a deliberate default selection, which is what makes the failure silent.
  3. mattbkiv commented on Jul 31, 2026

    @mattbkiv
    Author

    Correction and refinement (from the reporter)

    Two clarifications that sharpen the above, both from the user who ran the test.

    1. During this particular run, nothing appeared in the chat pane at all. Not the question, and not even the tool-call log for it, nor the two Bash calls that preceded it. Everything materialised at once only after canary-7734 was typed. My step 2 above said the user was "seeing only a collapsed tool call" — that was true of the original incident but not of this test run, and I have left the original text above rather than silently rewriting it.

    That behaviour matches #11265 (paired remote terminals stall with live TCP sockets while bytes sit unconsumed in the client's receive queue; any read-only RPC drains them). The environments line up closely: Ubuntu 26.04 LTS remote host, Tailscale WebSocket to orca serve --port 6768. Typing into the composer appears to act as the same kind of wake-up as orca status does there.

    2. The 8h37m pending window was simply the user not replying — they were working in other sessions — not an artefact of any Orca behaviour. The conclusion it supports is unchanged and is worth restating precisely: nothing auto-committed the question during those eight hours; the commit coincided with the user's keystroke.

    Why this makes the report stronger, not weaker

    The two failures are separable, and both leave the composer live over a pending selector:

    Stream state What the user sees Issue
    Flowing The tool call, rendered as an ordinary collapsed block — no question card this issue
    Stalled Nothing whatsoever, until a local action drains the queue #11265

    In the first state the user has a weak signal they are unlikely to interpret correctly. In the second they have none at all. In both, the next thing they type is silently converted into an answer to a question they never saw. The stall does not cause the fabricated consent — the missing card does, by leaving the composer in place — but it removes the last cue that anything is pending.

  4. added 2 commits that reference this issue on Oct 15, 2026
    dfed6ec
    3030ce7
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions