[Bug]: Stop silently drops a Claude steer that is waiting behind a running Bash command#15708

openedOct 4, 2026byNnekohasekai

Before submitting

  • I searched existing issues and did not find a duplicate.
  • I included enough detail to reproduce or investigate the problem.

Area

apps/server

Steps to reproduce

  1. Set Settings → General → Follow-up behavior to Steer.
  2. In a Claude thread, ask the agent to run a command that takes a minute or more in the foreground. Claude Code refuses a bare sleep, so I used curl against a local endpoint that does not answer. The first time I hit this, it was a slow du scan.
  3. Wait until the command has run for about 5 seconds, then send a follow-up, for example: Codeword: PELICAN. Remember it, I will ask for it later. It shows in the thread as a steer. The agent does not react, because the command is still running.
  4. Press Stop before the command finishes.
  5. Send: Earlier in this conversation, did I send you a codeword? If yes, reply with exactly CODEWORD=<the codeword>. If not, reply with exactly NO_CODEWORD.

The timing in step 3 matters. About 3 seconds after a foreground Bash call starts, Claude Code registers it as a task (task_started). A steer that arrives before that aborts the call and is read at once, so nothing is lost. A steer that arrives after that waits until the call returns.

I scripted these steps against a fresh server and a disposable Git project, sending the same message.dispatch (steer_active) and run.interrupt commands as the web client: repro-steer-stop.ts. Results on claude-sonnet-5-5:

RunAfter the steerAnswer to step 5
Stop, 3 runsCommand still running 5 s after the steer reached the CLI, then StopNO_CODEWORD every time
Control, no StopCommand released 5 s after the steer reached the CLICODEWORD=PELICAN-60C6

Expected behavior

A steer that the thread shows as sent either reaches the agent, or the thread shows that it did not, for example by returning it to the composer or the queue when Stop drops it.

Actual behavior

The steer stays in the stopped run and looks delivered, but Claude never reads it. The next turn answers NO_CODEWORD, and nothing tells the user the message was dropped.

What happens, from the provider log and Claude's session file:

  • The server offers the steer to the Claude CLI with priority: "now" (steerTurn in ClaudeAdapterV2.ts). The Bash call is already a task, so the CLI keeps the steer in its in-memory queue. The session file records only a queue-operation enqueue for it, with no dequeue and no user message.
  • Stop runs query.interrupt and then query.close (interruptTurn), which ends the CLI process with the steer still queued.
  • The next turn resumes the session in a new process and offers only the new prompt. The queued steer is not part of the resumed conversation.
  • The steer is offered without a uuid. Prompts that carry one get command_lifecycle events (queued, started, completed, cancelled), so the adapter could tell whether Claude read the steer before Stop. Today it cannot.

The V1 Cursor adapter had the reverse problem, a steer that still ran after Stop. #13423 fixed it by dropping the queued steer, and that PR was closed during the V2 migration. If dropping an unread steer is the intended Stop behavior, the bug here is only that the drop is silent.

Impact

Major degradation or frequent failure

Version or commit

main @ 4ee6bfd50e. First seen on Nightly 0.0.46-nightly.20261004.2644.

Environment

macOS 27.0.1, Node 26.9.0, Claude Code 2.1.289. Seen with claude-opus-5-5 and reproduced with claude-sonnet-5-5.

Logs or stack traces

shell
1# Native Claude protocol log of one Stop run (outgoing = server to CLI)
216:25:42.732 in {"type": "assistant", "content": ["tool_use:Bash"]}
316:25:46.160 in {"type": "system", "subtype": "task_started", "task_type": "local_bash", "is_backgrounded": false}
416:25:47.402 out {"type": "prompt.offer", "content": "Codeword: PELICAN-C362. Remember it, I will ask …", "priority": "now", "uuid": null}
516:25:53.686 out {"type": "query.interrupt"}
616:25:53.687 out {"type": "query.close"}
716:26:04.061 out {"type": "query.open", "resume": true}
816:26:04.064 out {"type": "prompt.offer", "content": "Earlier in this conversation, did I send you a c…", "uuid": "795ee797…"}
916:26:06.918 in {"type": "command_lifecycle", "state": "queued", "command_uuid": "795ee797…"}
1016:26:07.675 in {"type": "command_lifecycle", "state": "started", "command_uuid": "795ee797…"}
1116:26:09.292 in {"type": "assistant", "content": ["text:NO_CODEWORD"]}
12
13# Claude session file of the same run: the steer was only ever queued
14{"type":"queue-operation","operation":"enqueue","timestamp":"2026-10-04T16:25:47.406Z","content":"Codeword: PELICAN-C362. Remember it, I will ask for it later."}

Screenshots, recordings, or supporting files

https://github.com/user-attachments/assets/eebdd993-3f66-4688-b971-75db48a942e7

The web client against a fresh dev server and a demo project, with Claude Sonnet 5.5 and Follow-up behavior set to Steer. The recording script overlaid the yellow captions; they do not change the app. The agent's command is curl against a local endpoint that never answers, so it stays in the foreground until Stop.

repro-steer-stop.ts runs the same flow without a browser. Save it as apps/server/scripts/repro-steer-stop.ts and run node apps/server/scripts/repro-steer-stop.ts, or add --control for the run without Stop. It uses the existing Claude CLI login and keeps the server log, provider log and projection in the directory it prints.

Workaround

Wait for the running command to finish before pressing Stop, or send the message again after stopping.

Note

Grok responding on behalf of Julius.

Triage

Thanks for the scripted repro, the control run, and the protocol log, @nekohasekai. They made this straightforward to trace.

What I found

This only happens after Claude emits task_started for a foreground local_bash call. A steer sent before that point aborts the call and is read in the same process. A steer sent after it waits in Claude's in-memory queue until the tool returns. If Stop is pressed during that wait, the CLI process exits with the steer still in its queue.

  • dispatchSteerIntoRun in Orchestrator.ts writes the steer into the active run as a completed user message. The timeline shows "Steered the active turn" as soon as the offer is accepted, before Claude has read it.
  • ClaudeAdapterV2.steerTurn offers the text with priority: "now" and no uuid. Prompts from startTurn get claudePromptUuid(attemptId), and the adapter already reads command_lifecycle for that uuid. A steer has no uuid, so the adapter can't tell a queued steer from one Claude has started.
  • Stop (run.interrupt, holdQueue: true) calls query.interrupt and then query.close, which ends the process. holdQueue only holds later queued runs and doesn't cover a steer already attached to the running run.
  • The resumed turn offers only the new prompt. Nothing returns the unread steer to the composer or the queue, and the bubble still shows it as steered.

#13423 dealt with the reverse case, a steer still running after Stop. It was closed during the V2 migration and doesn't cover this case.

Likely fix area

Some options:

  • Give steers a uuid so command_lifecycle can show whether Claude actually started reading one before Stop.
  • On Stop, mark an unread steer as not delivered, or return it to the composer or the queue.
  • Alternatively, send the unread steer again on the next turn.

A maintainer will decide on the fix direction.

Synced from GitHub

The same thing happens on OpenCode 2 threads, by design: pressing Stop deletes any steer OpenCode hasn't read yet. The adapter collects it in strandedSteers and calls session.inbox.cancel before the next prompt (from #14468), and the bubble stays in the stopped run looking sent.

Repro (OpenCode 2.0.29+, Follow-up behavior set to Steer): ask the agent to run sleep 60 && echo done in the foreground; while it runs, send Codeword: PELICAN. Remember it. (OpenCode reads steers only at a step boundary, so it waits behind the command); press Stop, then ask Did I send you a codeword earlier? The agent says no.

Cancelling it makes sense, since otherwise OpenCode would slip the old steer in ahead of the next prompt. What I'd change is that the text is lost. Of the options in the triage, I'd go with returning it to the composer: after a Stop you usually want to rethink what to send, and the composer lets you edit, resend or drop it without an extra step. Roughly:

  • turn.terminal gets an optional list of message ids the provider took back (packages/provider-core/src/server/ProviderAdapter.ts). The OpenCode 2 adapter fills it from the turn's undelivered steers when an interrupted turn ends (packages/provider-opencode/src/server/v2/adapter.ts); each inbox id is already built from the T3 message id.
  • The server marks those user messages as not delivered, so the bubble no longer looks sent.
  • Web and mobile put the text back in the composer, appended to any draft rather than replacing it, reusing the restore path behind rewindToMessage / "Restore prompt".

Returning them to the queue as held items would be smaller (server only, since Stop already holds the queue and the queue is editable), but it takes an extra step and resuming sends them as they were.

Claude could fill the same field once its steers carry a uuid, as suggested above; I'd keep that separate. If this direction works for you, I can open a small PR for the OpenCode 2 part.

Reproduced on main b5a226e49c, Ubuntu Linux, Node 24.21.0, Claude Code 2.1.303 / Sonnet 5.5, using the real web dev app and a disposable Git project.

With Follow-up behavior set to Steer, I ran foreground curl against a held local endpoint, sent the codeword after task_started, pressed Stop, waited for the run to end, then asked for the codeword. Claude answered NO_CODEWORD; the steer remained visible without an undelivered indication. Control: releasing the endpoint without Stop produced CODEWORD=PELICAN-CONTROL-15708.

Protocol transcript. Related #15720 covers delayed delivery; this report covers the silent loss on Stop.