Skip to content

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

Closed

Description

@nekohasekai

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 CLI What Claude did Agent answered the steer
4.2 s after the tool_use, after task_started Kept the steer queued until the command returned 61.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) Same 65.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 [Bug]: Stop silently drops a Claude steer that is waiting behind a running Bash command #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 @ 4ee6bfd. 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

# repro-steer-wait.ts, steer after task_started (outgoing = server to CLI)
17:23:39.212 in  {"type": "assistant", "content": ["tool_use:Bash"]}
17:23:42.296 in  {"type": "system", "subtype": "task_started", "task_type": "local_bash", "is_backgrounded": false}
17:23:43.400 out {"type": "prompt.offer", "content": "Change of plan: stop waiting for that command an…", "priority": "now", "uuid": null}
             (nothing for 61 s; the script releases the command at 17:24:44.4)
17:24:44.440 in  {"type": "user", "content": ["tool_result:GATE_OPEN"]}
17:24:44.451 in  {"type": "command_lifecycle", "state": "cancelled", "command_uuid": "e4080d38…"}
17:24:46.163 in  {"type": "assistant", "content": ["text:STEERED_NOW"]}

# esc-then-send.ts, same setup through the Agent SDK, seconds since start
 7.49 in  {"type": "system", "subtype": "task_started"}
 9.49 out prompt.offer with priority "now", then query.interrupt() -> {"still_queued": []}
 9.50 in  tool_result "The user doesn't want to proceed with this tool use. …"
 9.51 in  {"type": "result", "subtype": "error_during_execution", "terminal_reason": "aborted_tools"}
10.61 in  {"type": "assistant", "content": ["text:STEERED_NOW"]}   same session_id, query never closed

Screenshots, recordings, or supporting files

steer-waits-for-command.mp4

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.

Activity

  1. added
    bugSomething is broken or behaving incorrectly.
    needs-triageIssue needs maintainer review and initial categorization.
    on Oct 4, 2026
  2. juliusmarminge commented on Oct 4, 2026

    @juliusmarminge
    Member

    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 ([Bug]: Cursor Steer waits for the turn to finish #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 [Bug]: Stop silently drops a Claude steer that is waiting behind a running Bash command #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 [Bug]: Stop silently drops a Claude steer that is waiting behind a running Bash command #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

    A maintainer will decide on the fix direction.

  3. added
    via-triageFiled through npx t3 triage
    and removed
    needs-triageIssue needs maintainer review and initial categorization.
    on Oct 4, 2026
  4. added 2 commits that reference this issue on Oct 12, 2026
    5f3174e
    f39ae82
  5. danielmillar commented on Oct 18, 2026

    @danielmillar

    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…):

    15:02:04.335 in  assistant tool_use Bash (toolu_01MEZ…)
    15:02:07.369 in  system task_started local_bash is_backgrounded=false
    15:02:34 … 15:08:34 in  tool_progress heartbeats every 30 s
    15:08:40.691 out prompt.offer priority "now"  ("Both agents are done, what we waiting for")
                 (nothing incoming for 2 m 36 s, including no further tool_progress heartbeats)
    15:11:16.569 in  user tool_result "Exit code 144"   <- loop killed from outside
    15: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.

  6. added
    needs more infoInitial triage showed no bug. Awaiting more info
    and removed
    needs more infoInitial triage showed no bug. Awaiting more info
    on Oct 22, 2026
  7. derektrimm commented on Oct 23, 2026

    @derektrimm
    Contributor

    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:

    05:46:29.454 assistant tool_use Bash (UaDG3S)
    05:47:56.786 queue-operation enqueue "what password"
    05:52:43.317 queue-operation enqueue "what password"
    05:52:57.570 tool_result UaDG3S "Command was aborted before completion"   <- Stop
    05:53:03.933 task-notification: 2 background shell tasks didn't finish before the previous session ended
    05: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.

    Arm Model Bash tool result Steer answered Same session
    control (no steer, gate opens at 8 s) haiku-5-5 GATE_OPEN n/a yes
    offer with priority: "now" (current steerTurn) haiku-5-5 GATE_OPEN, 60.03 s after steer 61.31 s after steer yes
    offer with priority: "now" (current steerTurn) opus-5-5 GATE_OPEN, 60.05 s after steer 62.34 s after steer yes
    query.interrupt() then offer, no close (#23892) haiku-5-5 aborted, 0.02 s after steer 1.24 s after steer yes
    query.interrupt() then offer, no close (#23892) opus-5-5 aborted, 0.15 s after steer 1.27 s after steer yes

    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])
    // Measures whether a Claude steer reaches the agent while a foreground Bash
    // command is running, the way T3 Code's ClaudeAdapterV2 delivers it.
    //
    //   node steer-probe.mjs <arm> [model]
    //
    // Arms:
    //   control           no steer; the gate opens 8 s after the command starts
    //   offer-now         T3 0.1.1 / main: offer the steer with priority "now"
    //   interrupt-offer   PR #23892: query.interrupt() (no close), then offer
    //
    // The agent runs `curl` against a local gate that answers only when released,
    // so the command stays in the foreground for as long as the probe wants.
    import { query } from "@anthropic-ai/claude-agent-sdk";
    import http from "node:http";
    import { mkdtempSync } from "node:fs";
    import { tmpdir } from "node:os";
    import path from "node:path";
    
    const arm = process.argv[2];
    const model = process.argv[3] ?? "claude-haiku-5-5";
    if (!["control", "offer-now", "interrupt-offer"].includes(arm)) {
      console.error("usage: node steer-probe.mjs control|offer-now|interrupt-offer [model]");
      process.exit(2);
    }
    
    const STEER_AFTER_MS = 4_000; // after the command reaches the gate
    const RELEASE_AFTER_STEER_MS = 60_000; // a steer that waits shows as ~60 s
    const CONTROL_RELEASE_MS = 8_000;
    const HARD_TIMEOUT_MS = 180_000;
    
    const t0 = Date.now();
    const at = () => ((Date.now() - t0) / 1000).toFixed(2);
    const log = (...args) => console.log(`${at().padStart(7)}s`, ...args);
    
    // Gate: holds GET /hold until released.
    let releaseGate = () => {};
    const gateOpened = new Promise((resolve) => (releaseGate = resolve));
    let gateHit = null;
    const gateHitPromise = new Promise((resolve) => (gateHit = resolve));
    const server = http.createServer(async (req, res) => {
      if (req.url !== "/hold") return res.writeHead(404).end();
      gateHit();
      await gateOpened;
      res.writeHead(200).end("GATE_OPEN\n");
    });
    await new Promise((r) => server.listen(0, "127.0.0.1", r));
    const port = server.address().port;
    
    // Streaming input, like the T3 adapter's prompt queue.
    const pending = [];
    let wake = null;
    let inputClosed = false;
    const push = (msg) => {
      pending.push(msg);
      wake?.();
    };
    async function* input() {
      while (true) {
        while (pending.length) yield pending.shift();
        if (inputClosed) return;
        await new Promise((r) => (wake = r));
        wake = null;
      }
    }
    const userMessage = (text, priority) => ({
      type: "user",
      message: { role: "user", content: [{ type: "text", text }] },
      parent_tool_use_id: null,
      ...(priority ? { priority } : {}),
    });
    
    const cwd = mkdtempSync(path.join(process.env.TMPDIR ?? tmpdir(), "steer-probe-"));
    push(
      userMessage(
        `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` +
          "It waits on a local test gate. When it returns, reply with its output and nothing else.",
      ),
    );
    
    const q = query({
      prompt: input(),
      options: {
        model,
        cwd,
        // Uses the CLI's existing login. Set CLAUDE_BIN to test a specific CLI build.
        ...(process.env.CLAUDE_BIN ? { pathToClaudeCodeExecutable: process.env.CLAUDE_BIN } : {}),
        settingSources: [],
        permissionMode: "bypassPermissions",
        allowDangerouslySkipPermissions: true,
      },
    });
    
    const STEER_TEXT = "Change of plan: stop waiting for that command and reply with exactly STEERED_NOW.";
    const result = { arm, model, cliVersion: null, sessionIds: new Set() };
    let steerAt = null;
    
    gateHitPromise.then(async () => {
      log("gate: command is running (curl reached the gate)");
      result.commandRunningAt = Date.now();
      if (arm === "control") {
        setTimeout(() => {
          log("gate: released (control)");
          releaseGate();
        }, CONTROL_RELEASE_MS);
        return;
      }
      await new Promise((r) => setTimeout(r, STEER_AFTER_MS));
      steerAt = Date.now();
      if (arm === "interrupt-offer") {
        const answer = await q.interrupt().catch((e) => `error: ${e.message}`);
        log("query.interrupt() answered", JSON.stringify(answer ?? null));
      }
      push(userMessage(STEER_TEXT, "now"));
      log(`steer offered with priority "now"`);
      setTimeout(() => {
        log("gate: released (60 s after the steer)");
        releaseGate();
      }, RELEASE_AFTER_STEER_MS);
    });
    
    const hard = setTimeout(() => {
      log("HARD TIMEOUT");
      finish(1);
    }, HARD_TIMEOUT_MS);
    
    let finished = false;
    function finish(code) {
      if (finished) return;
      finished = true;
      clearTimeout(hard);
      releaseGate();
      inputClosed = true;
      wake?.();
      const s = (ms) => (ms == null ? null : +((ms - (steerAt ?? 0)) / 1000).toFixed(2));
      const summary = {
        arm,
        model,
        cliVersion: result.cliVersion,
        sameSession: result.sessionIds.size === 1,
        toolResult: result.toolResult ?? null,
        toolResultSecondsAfterSteer: steerAt ? s(result.toolResultAt) : null,
        steerAnsweredSecondsAfterSteer: steerAt ? s(result.answerAt) : null,
        finalText: result.finalText ?? null,
      };
      console.log("SUMMARY " + JSON.stringify(summary));
      server.close();
      setTimeout(() => process.exit(code), 500);
    }
    
    for await (const m of q) {
      if (m.session_id) result.sessionIds.add(m.session_id);
      if (m.type === "system" && m.subtype === "init") {
        result.cliVersion = m.claude_code_version ?? null;
        log("init", `cli=${result.cliVersion}`, `model=${m.model}`);
      } else if (m.type === "system" && m.subtype === "task_started") {
        log("task_started", m.task_type ?? "", `backgrounded=${m.is_backgrounded}`);
      } else if (m.type === "assistant") {
        for (const b of m.message.content) {
          if (b.type === "tool_use") log("assistant tool_use", b.name, JSON.stringify(b.input).slice(0, 90));
          if (b.type === "text" && b.text.trim()) {
            log("assistant text", JSON.stringify(b.text.slice(0, 120)));
            result.finalText = b.text.trim();
            if (b.text.includes("STEERED_NOW") && !result.answerAt) result.answerAt = Date.now();
          }
        }
      } else if (m.type === "user" && Array.isArray(m.message?.content)) {
        for (const b of m.message.content) {
          if (b.type === "tool_result") {
            const text = typeof b.content === "string" ? b.content : JSON.stringify(b.content);
            result.toolResult ??= text.slice(0, 100);
            result.toolResultAt ??= Date.now();
            log("tool_result", JSON.stringify(text.slice(0, 100)));
          }
        }
      } else if (m.type === "result") {
        log("result", m.subtype, m.terminal_reason ?? "");
        const done =
          arm === "control" ? m.subtype === "success" : result.answerAt != null && m.subtype === "success";
        if (done) finish(0);
      }
    }
    finish(result.answerAt || arm === "control" ? 0 : 1);
  8. juliusmarminge commented on Oct 23, 2026

    @juliusmarminge
    Member

    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

    Image Image
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething is broken or behaving incorrectly.via-triageFiled through npx t3 triage

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions