Skip to content

Streaming-input query() closes stdin after the top-level turn's first result, permanently breaking canUseTool for a still-running background subagent ("Tool permission request failed: Error: Stream closed") #376

Description

@doublefx

Summary

When query() is called with a streaming-input prompt (an AsyncIterable that yields exactly one message and then completes) and a canUseTool callback, the SDK closes its write side of the child CLI process's stdin (transport.endInput()) as soon as the first result SDK message is observed — even if the top-level turn dispatched a background subagent (the CLI's native async-agent / Task-style tool) that is still running and will still need to make canUseTool-gated tool calls.

Once stdin is closed, the CLI process sets a process-wide inputClosed flag. From that point on, every subsequent can_use_tool control request — from the subagent, or from anything else in the same CLI process — is rejected synchronously before it is even sent, and the tool call is denied with:

Tool permission request failed: Error: Stream closed

(or AbortError: Stream closed on newer builds — see "Version note" below). This is permanent for the life of the process — a later query.setPermissionMode(...) call still round-trips successfully (it doesn't need canUseTool), but it does not reopen the closed channel, and no further subagent tool call ever succeeds again.

Minimal reproduction

No custom tooling required — this reproduces with the CLI's own built-in subagent-spawning tool (surfaced to the model as Agent) and a single-message streaming prompt.

import { query } from '@anthropic-ai/claude-agent-sdk'

async function* promptStream(text: string) {
  yield { type: 'user', message: { role: 'user', content: text }, parent_tool_use_id: null }
}

const prompt =
  'Use the Task tool to spawn ONE general-purpose subagent. Give the subagent this exact instruction: ' +
  '"Step 1: Read the file /etc/hostname with the Read tool. Step 2: after that finishes, Read the file ' +
  '/etc/os-release with the Read tool. Step 3: then write one short sentence summarizing what you found ' +
  'in both files as your final answer. Do these steps in order, one at a time, and do nothing else." ' +
  'Do not do anything else yourself — just spawn the subagent, wait for it, and relay its final answer.'

const q = query({
  prompt: promptStream(prompt),
  options: {
    settingSources: [],
    permissionMode: 'default',
    allowedTools: [],
    model: 'haiku',
    canUseTool: async (toolName, input, options) => {
      console.log('canUseTool fired', toolName, options.agentID)
      return { behavior: 'allow', updatedInput: input }
    },
  },
})

for await (const msg of q) {
  // observe messages
}

Observed (SDK 0.3.205, CLI 2.1.205 — bundled binary)

[t+5013ms] assistant tool_use name=Agent
[t+5042ms] tool_result: "Async agent launched successfully. ..."   <- launch is fire-and-forget, returns immediately
[t+6885ms] canUseTool #1 FIRED  (subagent's first Read — agentID populated)
[t+6892ms] tool_result: /etc/hostname content                      <- subagent's 1st tool call SUCCEEDS
[t+6965ms] RESULT (top-level turn's OWN result — "I'll wait for it to complete...")
[t+9080ms] subagent's 2nd Read -> tool_result is_error=true: "Tool permission request failed: Error: Stream closed"
[t+11654ms] retry -> same error
[t+15593ms] retry -> same error
[t+19028ms] task_notification: subagent finished, having failed step 2 permanently

Reproduced again on SDK 0.3.209 / CLI 2.1.209 (haiku model), with a 3rd tool call (a Bash call, not just Read) also dying the same way after the channel closed — confirming the closure is process-wide, not per-tool or per-agent:

[t+17441ms] RESULT (top-level turn's own result)
[t+18885ms] tool_result is_error=true: "Tool permission request failed: AbortError: Stream closed"
[t+20936ms] tool_result is_error=true: "Tool permission request failed: AbortError: Stream closed"
[t+23238ms] tool_result is_error=true: "Tool permission request failed: AbortError: Stream closed"  (Bash tool)

It's a race, not a deterministic failure

Running the identical shape multiple times, the subagent's tool calls sometimes all succeed — when the subagent happens to finish before the top-level turn's own result fires. This makes the bug timing-dependent and easy to miss in quick manual testing, but very easy to hit in production whenever the top-level prompt is engineered to "stop immediately" after dispatching the subagent (which naturally minimizes how long it takes for result to fire, tipping the race toward failure).

Root cause (traced in the SDK's own source, sdk.mjs, both 0.3.205 and 0.3.209 — byte-identical in this path)

Query.streamInput():

async streamInput(e){
  try {
    let t = 0;
    for await (let r of e) {
      t++;
      if (this.abortController?.signal.aborted) break;
      await this.transport.write(JSON.stringify(r) + "\n");
    }
    if (t > 0 && this.hasBidirectionalNeeds()) {
      await this.waitForFirstResult();
    }
    this.transport.endInput();   // closes the CLI child process's stdin
  } catch (t) { if (!(t instanceof AbortError)) throw t; }
}

hasBidirectionalNeeds() {
  return this.sdkMcpTransports.size > 0
    || (this.hooks !== undefined && Object.keys(this.hooks).length > 0)
    || this.canUseTool !== undefined
    || this.onElicitation !== undefined
    || this.onUserDialog !== undefined
    || this.getOAuthToken !== undefined
    || this.getHostAuthToken !== undefined;
}

waitForFirstResult() resolves on the session's very first result-type SDK message — i.e. as soon as the top-level conversational turn finishes (its own model output stops / no more pending top-level tool calls), which is exactly what happens right after a fire-and-forget "launch a background subagent" tool call returns. It has no concept of an outstanding background task/subagent that will keep needing the control channel after that point.

endInput() (in the process transport) does this.processStdin.end() — a genuine EOF on the child CLI process's stdin.

On the CLI side, its own read loop over this.input sets a process-wide flag once it reaches EOF:

// CLI 2.1.209 (matches 2.1.205 in shape)
async *readSdkMessages() {
  ...
  this.inputClosed = true;
  for (let r of this.pendingRequests.values())
    r.reject(new BS("Tool permission stream closed before response received"));
}

And the control-request sender used for every can_use_tool request (top-level or subagent, keyed only by agent_id inside the same shared queue) checks that flag synchronously, before attempting to enqueue anything:

// CLI 2.1.209
async sendRequest(e, t, r, n = randomUUID()) {
  let o = { type: "control_request", request_id: n, request: e };
  if (this.inputClosed) throw new BS("Stream closed");
  if (r?.aborted) throw new bc("Request aborted");
  ...
}
// CLI 2.1.205 — identical structural check, plain Error class
async sendRequest(e, t, r, n = randomUUID()) {
  let o = { type: "control_request", request_id: n, request: e };
  if (this.inputClosed) throw Error("Stream closed");
  if (r?.aborted) throw Error("Request aborted");
  ...
}

The thrown error propagates into createCanUseTool's wrapper, which converts ANY thrown error from the permission round-trip into a deny decision surfaced to the tool caller as:

catch (y) {
  let _ = `Tool permission request failed: ${y}`;
  ...
  return denyWith({ behavior: "deny", message: _, toolUseID: i }, ...);
}

There is no retry, no re-check of whether the flag might later clear (it never does — inputClosed is one-way for the life of the process), and no code path reopens the write side of stdin. A caller flipping setPermissionMode(...) mid-run round-trips fine (that control request/response pair travels over the read side, stdout, which is unaffected), but it does not touch inputClosed, so it cannot recover the channel.

Version note (0.3.205 → 0.3.209)

The control-channel logic itself (streamInput, hasBidirectionalNeeds, endInput, the CLI's inputClosed guard) is unchanged between SDK 0.3.205 (bundles CLI 2.1.205) and SDK 0.3.209 (bundles CLI 2.1.209) — confirmed by a byte-level diff of the relevant functions in sdk.mjs. The only change found in this exact path is cosmetic: CLI 2.1.205 throws a plain Error("Stream closed"); CLI 2.1.209 throws new BS("Stream closed") where BS extends bc extends Error and bc's constructor sets this.name = "AbortError" — so the surfaced message text changed from Error: Stream closed to AbortError: Stream closed. The underlying permanent-failure behavior is identical.

Why this matters

Any caller that (a) uses streaming input specifically to unlock the Query control surface (setPermissionMode, interrupt, etc. — which the SDK's own typings gate behind streaming input), (b) supplies canUseTool so it can gate tool calls instead of using permissionMode: 'bypassPermissions', and (c) dispatches a subagent/background task from the top-level turn — will intermittently (and, with a "stop immediately after dispatch" prompt, almost deterministically) lose the ability to gate ANY further tool call for the rest of the process, with a misleading "Stream closed" message that gives no indication of the actual cause.

Client-side workaround (verified)

The bug is fully avoided by never letting the input iterable complete while the run is in flight. Because endInput() is only reached after streamInput()'s for await loop finishes, keeping the async generator suspended (yield the message, then await a deferred you resolve only once the run's own terminal signal has been observed) means waitForFirstResult()/endInput() are never reached, inputClosed is never set, and the permission channel stays open for the whole run.

Verified on SDK 0.3.205, 3/3 runs of the reproduction shape above: with a held-open generator, the subagent's 2nd (and later) canUseTool requests fire and succeed after the top-level turn's own result (the exact moment that closes the channel in the one-shot case), zero "Stream closed" errors. Releasing the hold once the final relayed result is observed lets the generator return and the process exit cleanly (~200–260 ms later, no lingering child, exit 0).

// instead of a one-shot generator that returns immediately:
async function* heldPrompt(text: string, done: Promise<void>) {
  yield { type: 'user', message: { role: 'user', content: text }, parent_tool_use_id: null }
  await done   // resolve this only after observing the run's terminal signal
}

This is a mitigation, not a substitute for a fix: it requires every caller who uses canUseTool with a background subagent to know to hold input open, and it is easy to get wrong (the deferred must resolve on every exit path or the process never exits).

Suggested fix directions (not prescriptive)

  • streamInput()'s waitForFirstResult() heuristic should account for outstanding background tasks/subagents (e.g. track task_started/task_notification state and only call endInput() once no backgrounded task is still outstanding), or
  • expose an explicit opt-out / explicit Query method to keep the control channel open until the caller says otherwise (rather than inferring "done" from the first top-level result), or
  • at minimum, surface a clearer error than "Stream closed" when a can_use_tool request is rejected purely because of this heuristic, so callers can distinguish "your callback is broken" from "the SDK closed the channel out from under a background task."

Environment

  • @anthropic-ai/claude-agent-sdk 0.3.205 and 0.3.209 (both tested, same behavior)
  • Bundled CLI 2.1.205 and 2.1.209 respectively (both tested, same behavior; only the thrown error's .name differs)
  • Linux x64, Node 24

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions