Skip to content

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

@pfrank8

Description

@pfrank8

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 <cmd...>`
  • 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    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