[Bug]: A Claude steer waiting behind a running command shows as delivered and the thread goes silent#23948
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
- Set Settings > General > Follow-up behavior to Steer.
- In a Claude thread, have the agent run a foreground Bash command that takes a few minutes.
- While it runs, send a follow-up. It shows with the Steer marker ("Steered the active turn").
- Watch the thread until the command returns.
Expected behavior
While a steer has not been read by the agent, the thread says so: for example, the steer marker reads "Waiting for the agent to read this" (or similar) until Claude acknowledges the message, and the running command keeps showing that it is alive. The user can tell "my message is waiting behind a command" apart from "the session is hung".
Actual behavior
The steer is marked as steered as soon as the server offers it, and from then on the thread shows nothing at all until the command returns or the user presses Stop. It reads as a frozen session. In my case the steer waited 300 s, I pressed Stop, and the steer was then dropped (#15708).
Two things combine to make this silent:
- No delivery state.
ClaudeAdapterV2.steerTurnoffers the steer withpriority: "now"and nouuid, so nocommand_lifecycleevent can tie it to Claude reading it. The web marker (UserMessageIntentMarker, "Steered the active turn") is static. - Claude stops reporting the running command. Claude Code sends a
tool_progressheartbeat for a foreground Bash call every 30 s. After apriority: "now"offer it stops sending them for the rest of the call, so nothing reaches the thread during the wait. This was also noted in a comment on #15720. Measured below.
This is separate from #15720 (the steer should interrupt the command; #23892) and #15708 (Stop drops the unread steer; #23807 adds a notice after Stop). #23892 makes the steer interrupt a root Bash command, but by its own description it still offers the steer without interrupting when a question or approval is pending or the interrupt is not answered within five seconds. #23807 adds steer UUIDs and acknowledgment tracking but only reports after Stop. Exposing that same acknowledgment state while the turn is running would cover the wait itself.
Impact
Major degradation or frequent failure
Version or commit
T3 Code desktop 0.1.1; ClaudeAdapterV2.steerTurn and UserMessageIntentMarker unchanged on main @ 71a283aed2
Environment
Linux x64 desktop app, Claude Code 2.1.308, claude-opus-5-5 (thread) and claude-haiku-5-5 (probe), @anthropic-ai/claude-agent-sdk 0.3.276
Logs or stack traces
Provider log of the real thread (decoded, in = CLI to server):
| 1 | 05:46:29.456 in assistant tool_use Bash |
| 2 | 05:46:32.469 in system task_started local_bash is_backgrounded=false |
| 3 | 05:46:59.466 in tool_progress Bash elapsed=30 |
| 4 | 05:47:29.466 in tool_progress Bash elapsed=60 |
| 5 | 05:47:56.783 out prompt.offer priority=now uuid=null <- steer 1 |
| 6 | (no incoming frames for 4 m 47 s: no tool_progress at 90, 120, ... 270) |
| 7 | 05:52:43.313 out prompt.offer priority=now uuid=null <- steer 2 |
| 8 | 05:52:55.539 out query.interrupt |
| 9 | 05:52:55.539 out query.close <- Stop; both steers dropped (#15708) |
Agent SDK probe (heartbeat-probe.mjs below; the agent runs curl against a local gate that answers only when released), CLI 2.1.308:
| Arm | Command held | Steer offered | tool_progress heartbeats |
|---|---|---|---|
| control, no steer | 100 s | none | 30, 60, 90 |
priority: "now" offer (current steerTurn) | 107 s | at 35 s | 30 before the steer; none after it |
In the steered arm the command ran another 70 s after the steer with no frames from the CLI; the steer was answered 71.5 s after it was offered, once the gate opened.
heartbeat-probe.mjs (run with STEER_AFTER_MS=35000 RELEASE_AFTER_STEER_MS=70000 node heartbeat-probe.mjs offer-now; control: CONTROL_RELEASE_MS=100000 node heartbeat-probe.mjs control)
| 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 heartbeat-probe.mjs <arm> [model] |
| 5 | // |
| 6 | // Env: STEER_AFTER_MS, RELEASE_AFTER_STEER_MS, CONTROL_RELEASE_MS, CLAUDE_BIN. |
| 7 | // Logs Claude's tool_progress heartbeats for the running command. |
| 8 | // |
| 9 | // Arms: |
| 10 | // control no steer; the gate opens 8 s after the command starts |
| 11 | // offer-now T3 0.1.1 / main: offer the steer with priority "now" |
| 12 | // interrupt-offer PR #23892: query.interrupt() (no close), then offer |
| 13 | // |
| 14 | // The agent runs `curl` against a local gate that answers only when released, |
| 15 | // so the command stays in the foreground for as long as the probe wants. |
| 16 | import { query } from "@anthropic-ai/claude-agent-sdk"; |
| 17 | import http from "node:http"; |
| 18 | import { mkdtempSync } from "node:fs"; |
| 19 | import { tmpdir } from "node:os"; |
| 20 | import path from "node:path"; |
| 21 | |
| 22 | const arm = process.argv[2]; |
| 23 | const model = process.argv[3] ?? "claude-haiku-5-5"; |
| 24 | if (!["control", "offer-now", "interrupt-offer"].includes(arm)) { |
| 25 | console.error("usage: node heartbeat-probe.mjs control|offer-now|interrupt-offer [model]"); |
| 26 | process.exit(2); |
| 27 | } |
| 28 | |
| 29 | const STEER_AFTER_MS = Number(process.env.STEER_AFTER_MS ?? 4_000); // after the command reaches the gate |
| 30 | const RELEASE_AFTER_STEER_MS = Number(process.env.RELEASE_AFTER_STEER_MS ?? 60_000); // a steer that waits shows as ~60 s |
| 31 | const CONTROL_RELEASE_MS = Number(process.env.CONTROL_RELEASE_MS ?? 8_000); |
| 32 | const HARD_TIMEOUT_MS = 240_000; |
| 33 | |
| 34 | const t0 = Date.now(); |
| 35 | const at = () => ((Date.now() - t0) / 1000).toFixed(2); |
| 36 | const log = (...args) => console.log(`${at().padStart(7)}s`, ...args); |
| 37 | |
| 38 | // Gate: holds GET /hold until released. |
| 39 | let releaseGate = () => {}; |
| 40 | const gateOpened = new Promise((resolve) => (releaseGate = resolve)); |
| 41 | let gateHit = null; |
| 42 | const gateHitPromise = new Promise((resolve) => (gateHit = resolve)); |
| 43 | const server = http.createServer(async (req, res) => { |
| 44 | if (req.url !== "/hold") return res.writeHead(404).end(); |
| 45 | gateHit(); |
| 46 | await gateOpened; |
| 47 | res.writeHead(200).end("GATE_OPEN\n"); |
| 48 | }); |
| 49 | await new Promise((r) => server.listen(0, "127.0.0.1", r)); |
| 50 | const port = server.address().port; |
| 51 | |
| 52 | // Streaming input, like the T3 adapter's prompt queue. |
| 53 | const pending = []; |
| 54 | let wake = null; |
| 55 | let inputClosed = false; |
| 56 | const push = (msg) => { |
| 57 | pending.push(msg); |
| 58 | wake?.(); |
| 59 | }; |
| 60 | async function* input() { |
| 61 | while (true) { |
| 62 | while (pending.length) yield pending.shift(); |
| 63 | if (inputClosed) return; |
| 64 | await new Promise((r) => (wake = r)); |
| 65 | wake = null; |
| 66 | } |
| 67 | } |
| 68 | const userMessage = (text, priority) => ({ |
| 69 | type: "user", |
| 70 | message: { role: "user", content: [{ type: "text", text }] }, |
| 71 | parent_tool_use_id: null, |
| 72 | ...(priority ? { priority } : {}), |
| 73 | }); |
| 74 | |
| 75 | const cwd = mkdtempSync(path.join(process.env.TMPDIR ?? tmpdir(), "steer-probe-")); |
| 76 | push( |
| 77 | userMessage( |
| 78 | `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` + |
| 79 | "It waits on a local test gate. When it returns, reply with its output and nothing else.", |
| 80 | ), |
| 81 | ); |
| 82 | |
| 83 | const q = query({ |
| 84 | prompt: input(), |
| 85 | options: { |
| 86 | model, |
| 87 | cwd, |
| 88 | // Uses the CLI's existing login. Set CLAUDE_BIN to test a specific CLI build. |
| 89 | ...(process.env.CLAUDE_BIN ? { pathToClaudeCodeExecutable: process.env.CLAUDE_BIN } : {}), |
| 90 | settingSources: [], |
| 91 | permissionMode: "bypassPermissions", |
| 92 | allowDangerouslySkipPermissions: true, |
| 93 | }, |
| 94 | }); |
| 95 | |
| 96 | const STEER_TEXT = "Change of plan: stop waiting for that command and reply with exactly STEERED_NOW."; |
| 97 | const result = { arm, model, cliVersion: null, sessionIds: new Set() }; |
| 98 | let steerAt = null; |
| 99 | |
| 100 | gateHitPromise.then(async () => { |
| 101 | log("gate: command is running (curl reached the gate)"); |
| 102 | result.commandRunningAt = Date.now(); |
| 103 | if (arm === "control") { |
| 104 | setTimeout(() => { |
| 105 | log("gate: released (control)"); |
| 106 | releaseGate(); |
| 107 | }, CONTROL_RELEASE_MS); |
| 108 | return; |
| 109 | } |
| 110 | await new Promise((r) => setTimeout(r, STEER_AFTER_MS)); |
| 111 | steerAt = Date.now(); |
| 112 | if (arm === "interrupt-offer") { |
| 113 | const answer = await q.interrupt().catch((e) => `error: ${e.message}`); |
| 114 | log("query.interrupt() answered", JSON.stringify(answer ?? null)); |
| 115 | } |
| 116 | push(userMessage(STEER_TEXT, "now")); |
| 117 | log(`steer offered with priority "now"`); |
| 118 | setTimeout(() => { |
| 119 | log(`gate: released (${RELEASE_AFTER_STEER_MS / 1000} s after the steer)`); |
| 120 | releaseGate(); |
| 121 | }, RELEASE_AFTER_STEER_MS); |
| 122 | }); |
| 123 | |
| 124 | const hard = setTimeout(() => { |
| 125 | log("HARD TIMEOUT"); |
| 126 | finish(1); |
| 127 | }, HARD_TIMEOUT_MS); |
| 128 | |
| 129 | let finished = false; |
| 130 | function finish(code) { |
| 131 | if (finished) return; |
| 132 | finished = true; |
| 133 | clearTimeout(hard); |
| 134 | releaseGate(); |
| 135 | inputClosed = true; |
| 136 | wake?.(); |
| 137 | const s = (ms) => (ms == null ? null : +((ms - (steerAt ?? 0)) / 1000).toFixed(2)); |
| 138 | const summary = { |
| 139 | arm, |
| 140 | model, |
| 141 | cliVersion: result.cliVersion, |
| 142 | sameSession: result.sessionIds.size === 1, |
| 143 | toolResult: result.toolResult ?? null, |
| 144 | toolResultSecondsAfterSteer: steerAt ? s(result.toolResultAt) : null, |
| 145 | steerAnsweredSecondsAfterSteer: steerAt ? s(result.answerAt) : null, |
| 146 | finalText: result.finalText ?? null, |
| 147 | heartbeatsBeforeSteer: (result.heartbeats ?? []).filter((h) => !steerAt || h.at < steerAt).map((h) => h.elapsed), |
| 148 | heartbeatsAfterSteer: (result.heartbeats ?? []).filter((h) => steerAt && h.at >= steerAt).map((h) => h.elapsed), |
| 149 | }; |
| 150 | console.log("SUMMARY " + JSON.stringify(summary)); |
| 151 | server.close(); |
| 152 | setTimeout(() => process.exit(code), 500); |
| 153 | } |
| 154 | |
| 155 | for await (const m of q) { |
| 156 | if (m.session_id) result.sessionIds.add(m.session_id); |
| 157 | if (m.type === "system" && m.subtype === "init") { |
| 158 | result.cliVersion = m.claude_code_version ?? null; |
| 159 | log("init", `cli=${result.cliVersion}`, `model=${m.model}`); |
| 160 | } else if (m.type === "system" && m.subtype === "task_started") { |
| 161 | log("task_started", m.task_type ?? "", `backgrounded=${m.is_backgrounded}`); |
| 162 | } else if (m.type === "tool_progress") { |
| 163 | log("tool_progress", m.tool_name, `elapsed=${m.elapsed_time_seconds}`); |
| 164 | (result.heartbeats ??= []).push({ at: Date.now(), elapsed: m.elapsed_time_seconds }); |
| 165 | } else if (m.type === "assistant") { |
| 166 | for (const b of m.message.content) { |
| 167 | if (b.type === "tool_use") log("assistant tool_use", b.name, JSON.stringify(b.input).slice(0, 90)); |
| 168 | if (b.type === "text" && b.text.trim()) { |
| 169 | log("assistant text", JSON.stringify(b.text.slice(0, 120))); |
| 170 | result.finalText = b.text.trim(); |
| 171 | if (b.text.includes("STEERED_NOW") && !result.answerAt) result.answerAt = Date.now(); |
| 172 | } |
| 173 | } |
| 174 | } else if (m.type === "user" && Array.isArray(m.message?.content)) { |
| 175 | for (const b of m.message.content) { |
| 176 | if (b.type === "tool_result") { |
| 177 | const text = typeof b.content === "string" ? b.content : JSON.stringify(b.content); |
| 178 | result.toolResult ??= text.slice(0, 100); |
| 179 | result.toolResultAt ??= Date.now(); |
| 180 | log("tool_result", JSON.stringify(text.slice(0, 100))); |
| 181 | } |
| 182 | } |
| 183 | } else if (m.type === "result") { |
| 184 | log("result", m.subtype, m.terminal_reason ?? ""); |
| 185 | const done = |
| 186 | arm === "control" ? m.subtype === "success" : result.answerAt != null && m.subtype === "success"; |
| 187 | if (done) finish(0); |
| 188 | } |
| 189 | } |
| 190 | finish(result.answerAt || arm === "control" ? 0 : 1); |
Workaround
Press Stop and send the message again.