Skip to content

query() with hooks closes stdin after 60s timeout, killing hook callbacks mid-conversation #730

@martin-rounds-ai

Description

@martin-rounds-ai

Description

When using query() with a string prompt and SDK hooks (e.g., PreToolUse), the SDK closes stdin after a 60-second timeout via wait_for_result_and_end_input(), even if the CLI is still actively processing and sending hook callback requests. This kills the bidirectional control protocol, causing the CLI to fail with:

Error in hook callback hook_0: ... Tool permission stream closed before response received

The error then propagates as "unhandled errors in a TaskGroup (1 sub-exception)".

Root Cause

In claude_agent_sdk/_internal/query.py, wait_for_result_and_end_input() waits for the first type: "result" message with a default timeout of 60 seconds (CLAUDE_CODE_STREAM_CLOSE_TIMEOUT). Since the "result" message only arrives when the entire conversation completes, any agent session with hooks that runs longer than 60 seconds will have stdin closed prematurely:

async def wait_for_result_and_end_input(self) -> None:
    if self.sdk_mcp_servers or self.hooks:
        with anyio.move_on_after(self._stream_close_timeout):  # default: 60s
            await self._first_result_event.wait()
    await self.transport.end_input()  # closes stdin regardless of timeout

After stdin closes, the CLI can no longer send hook callback requests, and any in-flight hook callback fails with "Tool permission stream closed before response received".

Minimal Reproducible Example

"""MRE: SDK closes stdin after 60s, killing hook callbacks.

Run with: uv run python mre.py
Requires: claude-agent-sdk, claude CLI installed
"""

import asyncio

from claude_agent_sdk import (
    ClaudeAgentOptions,
    HookContext,
    HookInput,
    HookJSONOutput,
    HookMatcher,
    query,
)


async def my_hook(
    input_data: HookInput, tool_use_id: str | None, context: HookContext
) -> HookJSONOutput:
    """Simple PreToolUse hook that allows everything."""
    print(f"[hook] tool={input_data.get('tool_name', '?')}")
    return {}  # no opinion — pass through


async def main() -> None:
    options = ClaudeAgentOptions(
        permission_mode="bypassPermissions",
        hooks={
            "PreToolUse": [
                HookMatcher(
                    matcher="Bash",
                    hooks=[my_hook],
                )
            ]
        },
    )

    # This prompt forces the agent to work for >60s, triggering many Bash
    # tool calls (each invoking the hook). After 60s the SDK closes stdin
    # and the next hook callback fails.
    prompt = (
        "Search this entire repository recursively for every Python file. "
        "For each file, read it and count the lines. "
        "Then produce a summary table of all files and their line counts. "
        "Use the Bash tool with 'find' and 'wc' commands. "
        "Do this file by file, not all at once."
    )

    try:
        async for msg in query(prompt=prompt, options=options):
            msg_type = getattr(msg, "type", None) or type(msg).__name__
            print(f"[msg] {msg_type}")
    except Exception as e:
        print(f"\n[ERROR] {e}")
        # Expected after ~60s:
        #   "unhandled errors in a TaskGroup (1 sub-exception)"
        # CLI stderr will show:
        #   "Error in hook callback hook_0: ... Tool permission stream closed
        #    before response received"


if __name__ == "__main__":
    asyncio.run(main())

Expected Behavior

The SDK should keep stdin open for the entire duration of the conversation when hooks (or SDK MCP servers) are active, since they require the bidirectional control protocol.

Actual Behavior

After 60 seconds (the default CLAUDE_CODE_STREAM_CLOSE_TIMEOUT), stdin is closed regardless of whether the conversation is still active, breaking all subsequent hook callbacks.

Workaround

Set a very large timeout via environment variable:

export CLAUDE_CODE_STREAM_CLOSE_TIMEOUT=3600000  # 1 hour in ms

Suggested Fix

When hooks or SDK MCP servers are present, wait_for_result_and_end_input() should not apply a timeout — stdin should remain open until the first result arrives or the CLI process exits:

async def wait_for_result_and_end_input(self) -> None:
    if self.sdk_mcp_servers or self.hooks:
        # No timeout — stdin must stay open for the control protocol
        await self._first_result_event.wait()
    await self.transport.end_input()

Environment

  • claude-agent-sdk version: 0.1.50 (bundled CLI 2.1.81)
  • Python: 3.13
  • OS: macOS (Darwin 25.3.0)
  • anyio used as the async runtime

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    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