Symptom
With a can_use_tool callback and no hooks and no SDK MCP servers — the query() + AsyncIterable path that can_use_tool's own ValueError directs users to — the callback is never invoked. Every tool call that needs permission fails and the model burns turns retrying:
ToolResultBlock(content='Tool permission request failed: Error: Stream closed', is_error=True)
Mechanism
Query.wait_for_result_and_end_input (src/claude_agent_sdk/_internal/query.py:819) keeps stdin open until the first result only for SDK MCP servers and hooks:
if self.sdk_mcp_servers or self.hooks:
...
await self._first_result_event.wait()
await self.transport.end_input()
can_use_tool is missing from the condition, but it is bidirectional in exactly the same way: the CLI sends a can_use_tool control_request mid-turn and the SDK must write the permission response back over stdin. With only can_use_tool configured, stream_input() reaches end_input() as soon as the input stream is exhausted — i.e. seconds before the turn's first tool call — so the control channel is gone by the time the CLI asks for permission.
The string-prompt path can't hit this (can_use_tool + string prompt raises ValueError in _process_query_inner), so the affected path is precisely the one the error message recommends.
Repro (real CLI, SDK 0.2.116, CLI 2.1.207)
One user message via an async generator, ClaudeAgentOptions(can_use_tool=...), prompt asks Claude to Write a file (permission-gated):
- Case A —
can_use_tool only: callback never fires; every Write attempt returns Tool permission request failed: Error: Stream closed; run ends in error_max_turns.
- Case B — identical + one no-op hook whose matcher (
"NoSuchToolEver") never fires: callback is invoked, PermissionResultAllow() is honored, file is created, run succeeds.
The only difference between A and B is that the hook's presence flips the keep-stdin-open condition — confirming the missing can_use_tool term is the root cause. (Repro script available on request; it's ~70 lines.)
Fix
Include can_use_tool in the condition:
if self.sdk_mcp_servers or self.hooks or self.can_use_tool:
PR with this fix + a regression test (mock transport that enforces the real CLI contract: permission request arrives after input is exhausted, result only after the response is written, writes after end_input() raise) is attached.
Symptom
With a
can_use_toolcallback and no hooks and no SDK MCP servers — thequery()+AsyncIterablepath thatcan_use_tool's ownValueErrordirects users to — the callback is never invoked. Every tool call that needs permission fails and the model burns turns retrying:Mechanism
Query.wait_for_result_and_end_input(src/claude_agent_sdk/_internal/query.py:819) keeps stdin open until the first result only for SDK MCP servers and hooks:can_use_toolis missing from the condition, but it is bidirectional in exactly the same way: the CLI sends acan_use_toolcontrol_request mid-turn and the SDK must write the permission response back over stdin. With onlycan_use_toolconfigured,stream_input()reachesend_input()as soon as the input stream is exhausted — i.e. seconds before the turn's first tool call — so the control channel is gone by the time the CLI asks for permission.The string-prompt path can't hit this (
can_use_tool+ string prompt raisesValueErrorin_process_query_inner), so the affected path is precisely the one the error message recommends.Repro (real CLI, SDK 0.2.116, CLI 2.1.207)
One user message via an async generator,
ClaudeAgentOptions(can_use_tool=...), prompt asks Claude to Write a file (permission-gated):can_use_toolonly: callback never fires; every Write attempt returnsTool permission request failed: Error: Stream closed; run ends inerror_max_turns."NoSuchToolEver") never fires: callback is invoked,PermissionResultAllow()is honored, file is created, run succeeds.The only difference between A and B is that the hook's presence flips the keep-stdin-open condition — confirming the missing
can_use_toolterm is the root cause. (Repro script available on request; it's ~70 lines.)Fix
Include
can_use_toolin the condition:PR with this fix + a regression test (mock transport that enforces the real CLI contract: permission request arrives after input is exhausted, result only after the response is written, writes after
end_input()raise) is attached.