[Bug]: Claude Steer waits for a running Bash command to finish instead of reaching the agent#15720

opened19d agobyNnekohasekai

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 keeps running in the foreground. Claude Code refuses a bare sleep, so I used curl against a local endpoint that does not answer.
  3. While the command runs, send: Change of plan: stop waiting for that command and reply with exactly STEERED_NOW. It shows in the thread as a steer.
  4. The agent does not react. It answers STEERED_NOW only after the command returns.

I scripted these steps against a fresh server and a disposable Git project, sending the same message.dispatch (steer_active) command as the web client, and kept the command running for 60 s after the steer reached the CLI: repro-steer-wait.ts. Results on claude-sonnet-5-5:

When the steer reached the CLIWhat Claude didAgent answered the steer
4.2 s after the tool_use, after task_startedKept the steer queued until the command returned61.8 s after the steer, right after the script released the command at 60 s
0.74 s after the tool_use, 2.9 s before task_started (--early)Same65.1 s after the steer
0.11 s after the tool_use (--early)Cancelled the call before it started (Command was aborted before completion)1.2 s after the steer

The web recording below shows the first case: the steer is answered 35 s after it was sent, only after the command is released.

I first hit this with a slow scan that ran for about 8 minutes. A steer sent 4.4 minutes in was read only when the process was killed from outside, 3.5 minutes later.

Expected behavior

Steer reaches the agent right away, as the composer docs describe ("steer the running turn immediately") and as it does for Cursor since V2 (#14585). In Claude Code itself, the way to do this is Esc, then send: the running command is interrupted and the new message is read at once.

Actual behavior

For Claude, Steer behaves like Queue whenever the agent is inside a running command. The thread shows the message as steered immediately, but Claude reads it only after the command returns, however long that takes.

What happens, from the provider log:

  • ClaudeAdapterV2.steerTurn only offers the message to the Claude CLI with priority: "now". It does not interrupt the turn.
  • Claude Code does not interrupt a running Bash call for a now message. The message waits in its queue and is folded in after the call returns.
  • Correction to #15708: there I wrote that a steer arriving before task_started (about 3 s in) aborts the call. That is not the boundary. In the second row above, the steer arrived before task_started and still waited. Only a steer that arrives within a fraction of a second, before the command has started, cancels the call. A user cannot type a follow-up that fast, so in practice the steer always waits.

Offering the message and then interrupting the turn, in the same CLI process, does what Esc then send does. I checked this directly against the Agent SDK (esc-then-send.ts in the same gist): prompt.offer with priority: "now", then query.interrupt() without query.close(). The command was aborted at once, the queued message ran next, and the agent answered STEERED_NOW 1.1 s after the offer, in the same session. Without the interrupt, which is what T3 does today, it waits for the command as in the first two rows above.

Before V2, #12541 made Claude's Steer interrupt the running turn first, and was closed with "if it's still a bug when v2 lands feel free to re-file". This is that re-file against V2.

This is also the wait in which #15708 happens: a user who sees no reaction to a steer presses Stop, and the steer that was still queued is lost. A steer that interrupts the command would be read before that.

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, @anthropic-ai/claude-agent-sdk 0.3.276. Seen with claude-opus-5-5 and reproduced with claude-sonnet-5-5.

Logs or stack traces

shell
1# repro-steer-wait.ts, steer after task_started (outgoing = server to CLI)
217:23:39.212 in {"type": "assistant", "content": ["tool_use:Bash"]}
317:23:42.296 in {"type": "system", "subtype": "task_started", "task_type": "local_bash", "is_backgrounded": false}
417:23:43.400 out {"type": "prompt.offer", "content": "Change of plan: stop waiting for that command an…", "priority": "now", "uuid": null}
5 (nothing for 61 s; the script releases the command at 17:24:44.4)
617:24:44.440 in {"type": "user", "content": ["tool_result:GATE_OPEN"]}
717:24:44.451 in {"type": "command_lifecycle", "state": "cancelled", "command_uuid": "e4080d38…"}
817:24:46.163 in {"type": "assistant", "content": ["text:STEERED_NOW"]}
9
10# esc-then-send.ts, same setup through the Agent SDK, seconds since start
11 7.49 in {"type": "system", "subtype": "task_started"}
12 9.49 out prompt.offer with priority "now", then query.interrupt() -> {"still_queued": []}
13 9.50 in tool_result "The user doesn't want to proceed with this tool use. …"
14 9.51 in {"type": "result", "subtype": "error_during_execution", "terminal_reason": "aborted_tools"}
1510.61 in {"type": "assistant", "content": ["text:STEERED_NOW"]} same session_id, query never closed

Screenshots, recordings, or supporting files

https://github.com/user-attachments/assets/e87bdf2b-6b2c-4a84-a60f-a7921c7cab39

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 answers only when the script releases it.

Gist with both scripts. Save repro-steer-wait.ts as apps/server/scripts/repro-steer-wait.ts and run node apps/server/scripts/repro-steer-wait.ts, or add --early to steer as soon as the command shows as running. It uses the existing Claude CLI login and keeps the server log, provider log and projection in the directory it prints. esc-then-send.ts needs only @anthropic-ai/claude-agent-sdk and the Claude CLI.

Workaround

Press Stop, then send the message again. Because of #15708, a steer already sent before Stop is lost, so it has to be sent again.

Note

Grok responding on behalf of Julius.

Triage

Thanks for the follow-up and the Agent SDK check, @nekohasekai. Separating this from #15708 made both easier to follow.

What I found

#15708 covers Stop dropping a queued steer. This issue explains why the steer is queued in the first place: a Claude Steer doesn't interrupt a running command, so it waits for the tool to finish, the same as Queue.

  • Cursor sets supportsActiveSteering: false and supportsSteeringByInterruptRestart: true, so decideSteeringExecution uses interrupt_restart (#14585). Claude has the opposite flags, so Steer only calls ClaudeAdapterV2.steerTurn. That offers the text with priority: "now" and doesn't interrupt.
  • Claude Code doesn't abort a Bash call that has already started when it gets that offer. The message stays in the CLI queue until the command returns. task_started isn't really the cutoff. The only case that cancelled the call was a steer that arrived about 0.1 s after tool_use, before the command started. That also corrects the timing described in my #15708 comment.
  • The adapter already handles the abort a real interrupt would cause. When a turn was steered and the user didn't interrupt it, isClaudeActiveSteeringAbortResult ignores aborted_tools / aborted_streaming, so the turn stays open, and the "handles aborted_tools with active steering" test covers that. But steerTurn never triggers that abort.
  • interruptTurn isn't a fit here: it calls query.close after query.interrupt, which causes the #15708 drop. Your SDK check (prompt.offer with priority: "now", then query.interrupt() with no close) aborted the command right away and kept the same session.

This is the V2 refile of closed PR #12541. It's separate from #15517, #15351, #15600, and the closed #14524 / #12100.

Likely fix area

  • ClaudeAdapterV2.steerTurn: one option is to follow the offer with an in-process query.interrupt() (no close), so the existing active-steering abort handling keeps the turn open.
  • A steer that's read right away would also close the window where #15708 loses it on Stop.

A maintainer will decide on the fix direction.

Synced from GitHub

Confirmed on a newer build, via t3 triage.

Environment: Desktop app, Linux x64 (Omarchy, kernel 7.2.5), local server 0.1.1-nightly.20261018.3302, Claude Code 2.1.302, claude-opus-5-5 (effort xhigh), Follow-up behavior: queued then promoted to Steer. Still applies to main @ 9c161462c9: ClaudeAdapterV2.steerTurn still only offers with priority: "now".

What happened: The agent ran a foreground polling loop (until grep -q "<phrase>" README.md; do sleep 3; done; …, timeout 600000) while waiting for subagents to edit a file. The subagents finished but never wrote that phrase, so the loop could only end at the 10-minute timeout. The user queued a follow-up, then promoted it to Steer. The thread showed no working/thinking indication after the steer and looked paused.

Provider log (thread 6c22fb70…):

115:02:04.335 in assistant tool_use Bash (toolu_01MEZ…)
215:02:07.369 in system task_started local_bash is_backgrounded=false
315:02:34 … 15:08:34 in tool_progress heartbeats every 30 s
415:08:40.691 out prompt.offer priority "now" ("Both agents are done, what we waiting for")
5 (nothing incoming for 2 m 36 s, including no further tool_progress heartbeats)
615:11:16.569 in user tool_result "Exit code 144" <- loop killed from outside
715:11:19.353 in assistant text (agent answers the steer)

Server trace: ClaudeAdapterV2.steerTurn succeeded (≈25 ms) right after EnvironmentCommands.promoteQueuedRun; the queued run (ordinal 4) went to cancelled as expected and the steer was folded into run 3.

Notable: after the "now" offer, the CLI also stopped sending tool_progress heartbeats for the running Bash call, so nothing reached the UI that could have shown the thread was still working. Together with the missing interrupt, this makes the steer look like it was swallowed.

Posted by Claude Opus 5.5 (claude-opus-5-5) in Claude Code, via t3 triage.

Synced from GitHub

Still reproduces on the stable desktop release, and the interrupt-then-offer ordering in #23892 fixes it on the current Claude Code CLI.

Environment: T3 Code desktop 0.1.1 (Linux x64), Claude Code 2.1.308, claude-opus-5-5, Follow-up behavior: Steer.

What happened: the agent ran a foreground batch import (timeout 1500 ..., about 7 minutes of work). Two steers were sent 1.5 and 6.2 minutes into the command. The thread showed both as steered and then nothing else happened, so it looked frozen until Stop was pressed. From the Claude session file:

105:46:29.454 assistant tool_use Bash (UaDG3S)
205:47:56.786 queue-operation enqueue "what password"
305:52:43.317 queue-operation enqueue "what password"
405:52:57.570 tool_result UaDG3S "Command was aborted before completion" <- Stop
505:53:03.933 task-notification: 2 background shell tasks didn't finish before the previous session ended
605:53:04.267 user "what password" <- the message sent after Stop

The first steer waited 300.8 s and was never read: Stop restarted the CLI process and both queued steers were dropped (#15708). The next turn contained only the message sent after Stop. Earlier steers in the same session that arrived while a Bash call was running waited 1.2 s, 17.4 s and 28.4 s for it to return.

SDK probe: @anthropic-ai/claude-agent-sdk 0.3.276 (the version in the lockfile), CLI 2.1.308, streaming input like ClaudeAdapterV2. The agent runs curl against a local gate that answers only when released. The steer is offered 4 s after the command reaches the gate (after task_started local_bash backgrounded=false), and the gate opens 60 s after the steer.

ArmModelBash tool resultSteer answeredSame session
control (no steer, gate opens at 8 s)haiku-5-5GATE_OPENn/ayes
offer with priority: "now" (current steerTurn)haiku-5-5GATE_OPEN, 60.03 s after steer61.31 s after steeryes
offer with priority: "now" (current steerTurn)opus-5-5GATE_OPEN, 60.05 s after steer62.34 s after steeryes
query.interrupt() then offer, no close (#23892)haiku-5-5aborted, 0.02 s after steer1.24 s after steeryes
query.interrupt() then offer, no close (#23892)opus-5-5aborted, 0.15 s after steer1.27 s after steeryes

In the interrupt arms, query.interrupt() answered {"still_queued":[]}, the turn ended with error_during_execution / aborted_tools, and the steer ran as the next turn in the same process. That is the result isClaudeActiveSteeringAbortResult already handles. In the offer-only arms, the CLI also ends the first turn with aborted_tools, but only after the tool returns.

steer-probe.mjs (run with node steer-probe.mjs control|offer-now|interrupt-offer [model])
js
1// Measures whether a Claude steer reaches the agent while a foreground Bash
2// command is running, the way T3 Code's ClaudeAdapterV2 delivers it.
3//
4// node steer-probe.mjs <arm> [model]
5//
6// Arms:
7// control no steer; the gate opens 8 s after the command starts
8// offer-now T3 0.1.1 / main: offer the steer with priority "now"
9// interrupt-offer PR #23892: query.interrupt() (no close), then offer
10//
11// The agent runs `curl` against a local gate that answers only when released,
12// so the command stays in the foreground for as long as the probe wants.
13import { query } from "@anthropic-ai/claude-agent-sdk";
14import http from "node:http";
15import { mkdtempSync } from "node:fs";
16import { tmpdir } from "node:os";
17import path from "node:path";
18
19const arm = process.argv[2];
20const model = process.argv[3] ?? "claude-haiku-5-5";
21if (!["control", "offer-now", "interrupt-offer"].includes(arm)) {
22 console.error("usage: node steer-probe.mjs control|offer-now|interrupt-offer [model]");
23 process.exit(2);
24}
25
26const STEER_AFTER_MS = 4_000; // after the command reaches the gate
27const RELEASE_AFTER_STEER_MS = 60_000; // a steer that waits shows as ~60 s
28const CONTROL_RELEASE_MS = 8_000;
29const HARD_TIMEOUT_MS = 180_000;
30
31const t0 = Date.now();
32const at = () => ((Date.now() - t0) / 1000).toFixed(2);
33const log = (...args) => console.log(`${at().padStart(7)}s`, ...args);
34
35// Gate: holds GET /hold until released.
36let releaseGate = () => {};
37const gateOpened = new Promise((resolve) => (releaseGate = resolve));
38let gateHit = null;
39const gateHitPromise = new Promise((resolve) => (gateHit = resolve));
40const server = http.createServer(async (req, res) => {
41 if (req.url !== "/hold") return res.writeHead(404).end();
42 gateHit();
43 await gateOpened;
44 res.writeHead(200).end("GATE_OPEN\n");
45});
46await new Promise((r) => server.listen(0, "127.0.0.1", r));
47const port = server.address().port;
48
49// Streaming input, like the T3 adapter's prompt queue.
50const pending = [];
51let wake = null;
52let inputClosed = false;
53const push = (msg) => {
54 pending.push(msg);
55 wake?.();
56};
57async function* input() {
58 while (true) {
59 while (pending.length) yield pending.shift();
60 if (inputClosed) return;
61 await new Promise((r) => (wake = r));
62 wake = null;
63 }
64}
65const userMessage = (text, priority) => ({
66 type: "user",
67 message: { role: "user", content: [{ type: "text", text }] },
68 parent_tool_use_id: null,
69 ...(priority ? { priority } : {}),
70});
71
72const cwd = mkdtempSync(path.join(process.env.TMPDIR ?? tmpdir(), "steer-probe-"));
73push(
74 userMessage(
75 `Use the Bash tool to run exactly this command in the foreground (not in the background, timeout 600000): curl -s http://127.0.0.1:${port}/hold\n` +
76 "It waits on a local test gate. When it returns, reply with its output and nothing else.",
77 ),
78);
79
80const q = query({
81 prompt: input(),
82 options: {
83 model,
84 cwd,
85 // Uses the CLI's existing login. Set CLAUDE_BIN to test a specific CLI build.
86 ...(process.env.CLAUDE_BIN ? { pathToClaudeCodeExecutable: process.env.CLAUDE_BIN } : {}),
87 settingSources: [],
88 permissionMode: "bypassPermissions",
89 allowDangerouslySkipPermissions: true,
90 },
91});
92
93const STEER_TEXT = "Change of plan: stop waiting for that command and reply with exactly STEERED_NOW.";
94const result = { arm, model, cliVersion: null, sessionIds: new Set() };
95let steerAt = null;
96
97gateHitPromise.then(async () => {
98 log("gate: command is running (curl reached the gate)");
99 result.commandRunningAt = Date.now();
100 if (arm === "control") {
101 setTimeout(() => {
102 log("gate: released (control)");
103 releaseGate();
104 }, CONTROL_RELEASE_MS);
105 return;
106 }
107 await new Promise((r) => setTimeout(r, STEER_AFTER_MS));
108 steerAt = Date.now();
109 if (arm === "interrupt-offer") {
110 const answer = await q.interrupt().catch((e) => `error: ${e.message}`);
111 log("query.interrupt() answered", JSON.stringify(answer ?? null));
112 }
113 push(userMessage(STEER_TEXT, "now"));
114 log(`steer offered with priority "now"`);
115 setTimeout(() => {
116 log("gate: released (60 s after the steer)");
117 releaseGate();
118 }, RELEASE_AFTER_STEER_MS);
119});
120
121const hard = setTimeout(() => {
122 log("HARD TIMEOUT");
123 finish(1);
124}, HARD_TIMEOUT_MS);
125
126let finished = false;
127function finish(code) {
128 if (finished) return;
129 finished = true;
130 clearTimeout(hard);
131 releaseGate();
132 inputClosed = true;
133 wake?.();
134 const s = (ms) => (ms == null ? null : +((ms - (steerAt ?? 0)) / 1000).toFixed(2));
135 const summary = {
136 arm,
137 model,
138 cliVersion: result.cliVersion,
139 sameSession: result.sessionIds.size === 1,
140 toolResult: result.toolResult ?? null,
141 toolResultSecondsAfterSteer: steerAt ? s(result.toolResultAt) : null,
142 steerAnsweredSecondsAfterSteer: steerAt ? s(result.answerAt) : null,
143 finalText: result.finalText ?? null,
144 };
145 console.log("SUMMARY " + JSON.stringify(summary));
146 server.close();
147 setTimeout(() => process.exit(code), 500);
148}
149
150for await (const m of q) {
151 if (m.session_id) result.sessionIds.add(m.session_id);
152 if (m.type === "system" && m.subtype === "init") {
153 result.cliVersion = m.claude_code_version ?? null;
154 log("init", `cli=${result.cliVersion}`, `model=${m.model}`);
155 } else if (m.type === "system" && m.subtype === "task_started") {
156 log("task_started", m.task_type ?? "", `backgrounded=${m.is_backgrounded}`);
157 } else if (m.type === "assistant") {
158 for (const b of m.message.content) {
159 if (b.type === "tool_use") log("assistant tool_use", b.name, JSON.stringify(b.input).slice(0, 90));
160 if (b.type === "text" && b.text.trim()) {
161 log("assistant text", JSON.stringify(b.text.slice(0, 120)));
162 result.finalText = b.text.trim();
163 if (b.text.includes("STEERED_NOW") && !result.answerAt) result.answerAt = Date.now();
164 }
165 }
166 } else if (m.type === "user" && Array.isArray(m.message?.content)) {
167 for (const b of m.message.content) {
168 if (b.type === "tool_result") {
169 const text = typeof b.content === "string" ? b.content : JSON.stringify(b.content);
170 result.toolResult ??= text.slice(0, 100);
171 result.toolResultAt ??= Date.now();
172 log("tool_result", JSON.stringify(text.slice(0, 100)));
173 }
174 }
175 } else if (m.type === "result") {
176 log("result", m.subtype, m.terminal_reason ?? "");
177 const done =
178 arm === "control" ? m.subtype === "success" : result.answerAt != null && m.subtype === "success";
179 if (done) finish(0);
180 }
181}
182finish(result.answerAt || arm === "control" ? 0 : 1);

steering at the harness level in both claude code and codex steers after the next tool call, which matches the current t3code behavior. so I'm pretty sure this works as expected.

T3 Code uses the native functionality whenever possible, so if it works differently in our Cursor adapter or ACP or whatever, usually means that harness doesn't support steering directly so we do an interupt-then-send to mimic it

ImageImage