Skip to content

bug(claude): a draft in Claude's composer silently blocks every send, reported only as "waiting for agent" #683

Closed

Description

@Juliusolsson05

Summary

Text sitting in Claude's own composer correctly blocks automated delivery — that gate is working as designed. The bug is that the user is not told, and the way out is undiscoverable. Sessions sit unusable for hours.

Measured across the lifecycle journal: 19 of 216 sessions ended with the gate still occupied, and the three longest episodes lasted 47.5h, 23.5h and 11.5h.

Reproduction, from a recorded session

Session 88547ad9. Screen frames from session-recordings, marker row only:

+  0.0s  ❯ Try "refactor contents.xcworkspacedata"     Claude's dim placeholder
+  8.8s  ❯ hello what is this ?                        typed and sent
+  9.0s  ❯                                             cleared — delivery succeeded
+ 51.4s  ❯ what's left before the demo?                appears, and NEVER clears
+198.5s  ❯ what's left before the demo?x               an 'x' appended

The matching journal for the same session:

16:43:48  submit.begin
16:43:48  gate.eval      occupied / human-draft     <- our own in-flight write
16:43:48  gate.eval      ready
16:43:48  submit.result  ok:true  280ms             <- "hello what is this ?" delivered
16:46:51  gate.eval      occupied / human-draft     <- LATCHES
18:17:07  kill.request                              <- killed 1h30m later

The decisive detail: the text at +51.4s arrived with no submit.begin and no submit.result. It did not come from prompt delivery. It was typed straight into the PTY — the raw terminal view — and then simply stayed there.

From that moment the gate correctly reports occupied / human-draft and refuses every delivery. The session was never usable again and was killed 90 minutes later.

Why the user cannot tell

readiness.ts maps every non-ready verdict onto one string:

'provider-not-ready': 'waiting for agent',

The provider's actual verdict — replay-pending vs composer-unpainted vs human-draft — is collapsed to provider-not-ready before it leaves main. Both readiness.ts and session-runtime/state.ts already flag this as a known deliberate gap.

So a session blocked by a stray draft is visually identical to one that is merely slow to boot. There is nothing on screen that says "your text is sitting in Claude's composer".

Why the way out is undiscoverable

Recovery does exist — this is not a hard deadlock, and an earlier draft of this report overstated it:

  • send() reaches the PTY even when inputReady is false (it takes the ensureSessionLive wake path first, then calls feed.sendInput, which main blocks only for the delivery reservation). So the Stop button pressed twice clears the composer when Claude is idle, because upstream treats double-Esc as "clear input" and saves the text to Claude's history first.
  • Ctrl+U typed manually in the raw terminal view also works.

Neither is labelled, documented, or suggested. Both require knowing the internal mechanism. And the one automatic Ctrl+U in the composer keybinds is gated on the very flag the block clears:

if (clearProviderLine && backendReady) await send('\x15')
const backendReady = runtime.inputReady && ...   // false while occupied

Expected behaviour

  • When delivery is blocked by a draft in the provider's composer, say so specifically — not "waiting for agent".
  • Offer an explicit way to clear it from the app, without requiring the raw terminal view.
  • Clearing must not destroy the text silently. Upstream's double-Esc already pushes it to Claude's input history; anything Agent Code does should be at least as recoverable.

Acceptance criteria

  • A session blocked on human-draft is distinguishable in the UI from one that is booting or replaying.
  • There is a discoverable action that returns the session to ready, reachable without the raw terminal.
  • A genuine human draft is still never overwritten by automated delivery — the protection this gate provides must not regress. Reintroducing a staleness timeout is explicitly out of scope: it was tried, shipped, and removed for destroying user input mid-sentence.
  • The recovery path is exercised by a test, not only by manual verification.

Scope note

This is not the same bug as #679. That one is Agent Code stranding its own bytes after a failed delivery, and it accounts for at most 5 of the 57 recorded not-ready refusals. This issue covers the dominant remainder: 52 of 57 refusals have no orphaned write anywhere on their session, and the sessions matching the original user report — including this one — have none. Fixing #679 does not fix this.

Evidence locations

  • Lifecycle journal: ~/.config/agent-code/incidents/runs/*/events.jsonl — gate.eval, submit.begin, submit.result
  • Screen frames: ~/.config/agent-code/session-recordings/*/events.jsonl — session:screen payloads carry plain
  • gate.eval is the only artifact that retains the detailed verdict; everything downstream has already collapsed it

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions