anthropics / anthropics/claude-agent-sdk-python

Bundled `claude` binary hangs when stdout is not a TTY (pipe / file / launchd / cron / Docker)

Open
#926 3 comments 0 reactions 0 assignees View on GitHub
bug
Dominant language
Python
Stars
8.1k
Forks
1.3k
Avg merge
2d 31m
Merged PRs (30d)
1

Description

**Status:** workaround in place locally; would benefit from upstream fix.
**Reproduced on:** macOS 25.4.0 (Darwin), claude-agent-sdk 0.1.x, 2026-05-05.
**Affected use case:** anything that captures the SDK's output (orchestrators, CI runners, headless launchd / cron jobs, Docker without `-t`).

## Symptom

Any Python script that uses `claude_agent_sdk` hangs indefinitely when its stdout is a pipe or a regular file (i.e., not a terminal). Once the script hits the first `query()` call, the process goes to a sleeping state, makes no progress, and consumes near-zero CPU. No output is ever emitted. The hang persists past arbitrary timeouts.

The same script, run with stdout connected to a TTY, completes normally in expected time.

## Minimal repro

\`\`\`python
# tty_bug.py
import asyncio
from claude_agent_sdk import (
AssistantMessage, ClaudeAgentOptions, ResultMessage, TextBlock, query,
)

async def main():
options = ClaudeAgentOptions(
system_prompt=\"Reply with exactly one word.\",
allowed_tools=[],
thinking={\"type\": \"adaptive\"},
effort=\"low\",
permission_mode=\"bypassPermissions\",
model=\"claude-opus-4-7\",
setting_sources=None,
)
parts = []
async for msg in query(prompt=\"Say 'hello'.\", options=options):
if isinstance(msg, AssistantMessage):
for block in msg.content:
if isinstance(block, TextBlock):
parts.append(block.text)
elif isinstance(msg, ResultMessage):
pass
print(\"response:\", \"\".join(parts).strip())

asyncio.run(main())
\`\`\`

| Invocation | Result |
|---|---|
| \`python tty_bug.py\` | Completes in ~2s |
| \`python tty_bug.py > out.txt\` | **Hangs indefinitely.** Kill required. \`out.txt\` empty. |
| \`python tty_bug.py \\| cat\` | **Hangs indefinitely.** Kill required. |
| \`script -q /dev/null python tty_bug.py > out.txt\` | Completes; output written to file |

\`script -q\` allocates a real PTY, which is then forwarded to its own stdout. The bundled \`claude\` binary inside claude-agent-sdk sees a TTY and proceeds normally.

## Why this matters

This bug bites anyone running the SDK in:

* **An orchestrator / pipeline that captures step output** (subprocess.PIPE).
* **CI / GitHub Actions runners** — they pipe stdout to log aggregators.
* **launchd / cron jobs** — no controlling terminal, stdout redirected to a file via the agent's plist or crontab.
* **Docker containers** without \`-t\`.

In all four cases the symptom is identical: silent hang, no diagnostic output, no error.

A real-world impact data point: a launchd-driven nightly pipeline saw analyze steps time out at 1800s every single tick for 24+ hours. Diagnosis required adding custom logging to the SDK consumer to discover the stream was hanging silently after a \`RateLimitEvent\` was yielded but no further message arrived.

## Suspected mechanism

The bundled \`claude\` binary at \`/lib/python3.x/site-packages/claude_agent_sdk/_bundled/claude\` appears to do an \`isatty()\` check on stdout and behaves differently when not connected to a terminal. Diagnostic confirmation:

* \`script -q /dev/null\` (which gives a PTY) consistently fixes it.
* Setting \`PYTHONUNBUFFERED=1\` does NOT fix it (so it's not a Python-side stdout buffering issue — it's inside the bundled binary).
* Disconnecting stdin (\`< /dev/null\`) does NOT fix it.

Additional issue: when an Anthropic-side rate limit fires, the SDK yields a \`RateLimitEvent\` message and then **the stream hangs indefinitely** instead of erroring or auto-retrying. Combined with the TTY-required behavior, this means a launchd-style consumer sees no output, no error, no progress — just a 1800s timeout.

## Workaround

Wrap the entire SDK-using process in \`script -q /dev/null\`, OR detect \`RateLimitEvent\` in the iterator and abort with a retryable error so the caller's retry-with-backoff handles it.

For an orchestrator using \`asyncio.create_subprocess_exec\`, prepend the wrapper to the command:

\`\`\`python
cmd = [\"/usr/bin/script\", \"-q\", \"/dev/null\", *original_cmd]
proc = await asyncio.create_subprocess_exec(*cmd, ...)
\`\`\`

\`script\` syntax differs between macOS and Linux:
* macOS: \`script -q \`
* Linux: \`script -q -c \"\" \`

## Real fix (upstream)

Either:

1. Don't \`isatty()\` to switch behavior. The SDK should produce the same output regardless of terminal attachment.
2. If terminal-only behavior (progress spinners, ANSI colors) is genuinely useful when interactive, gate it on a \`--non-interactive\` flag, defaulting to non-interactive when \`not isatty(stdout)\`.
3. Surface \`RateLimitEvent\` as a retryable Python exception that terminates the iterator, rather than as a continuing stream that may hang.

Contributor guide

No contributing guide indexed for this repository

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.