Repository navigation
Description
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 wheninputReadyis false (it takes theensureSessionLivewake path first, then callsfeed.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 occupiedExpected 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-draftis 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:screenpayloads carryplain gate.evalis the only artifact that retains the detailed verdict; everything downstream has already collapsed it
Activity
test(claude): pin the readiness reason that names a blocked composer
Merge origin/main into fix/orphaned-prompt-write-deadlock
fix(composer): never forward Tab to the provider and let Escape clear…