# CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

## Project

claude-runner is a Python library wrapping the Claude Agent SDK (`claude-agent-sdk`) with critical bug workarounds, automatic retry with exponential backoff, and timeout management. Extracted from `bmad-sprint-orchestrator`. ~376 lines across 5 modules, one dependency.

## Build & Install

```bash
pip install -e .              # Editable install
pip install build && python -m build  # Build wheel
```

Build backend: hatchling. Python >=3.11. No tests, no linter, no CLI — pure library.

## Public API

Four exports from `claude_runner`: `run`, `run_sync`, `RunResult`, `clean_claude_env`.

- **`run(prompt, **kwargs) -> RunResult`** — Async function. Streams a Claude Agent SDK session with retry/timeout. Never raises for session failures; check `result.is_error` instead. Defaults to a direct LLM call: `tools=[]` (no tools) and `max_turns=1`. Pass `tools=None` for SDK defaults.
- **`run_sync(prompt, **kwargs) -> RunResult`** — Synchronous wrapper; calls `asyncio.run(run(...))`. Cannot be called from inside a running event loop.
- **`RunResult`** — Dataclass with `text`, `cost_usd`, `usage`, `duration_ms`, `num_turns`, `session_id`, `is_error`, `error`, `result_message`, `messages`.
- **`clean_claude_env()`** — Strips `CLAUDE*` env vars (except `ANTHROPIC_API_KEY`) to prevent subprocess contamination when running from within Claude Code.

## Architecture

```
src/claude_runner/
  __init__.py   — Exports + calls patch_sdk() at import time
  _runner.py    — run(), _build_options(), _run_single_attempt()
  _types.py     — RunResult dataclass
  _env.py       — clean_claude_env()
  _patch.py     — Monkey-patch SDK's parse_message()
```

### Key design decisions

**Fail-open**: `run()` returns `RunResult(is_error=True, error=...)` instead of raising. Only programmer errors (e.g., SDK not installed) raise.

**Manual async iteration**: `_run_single_attempt` uses `__aiter__()/__anext__()` instead of `async for` because Python finalizes async generators when exceptions propagate out of `yield`. Manual iteration lets us catch/skip unknown message types while keeping the generator alive.

**Options merging**: `_build_options()` takes an optional `ClaudeAgentOptions` base object; flat kwargs (`model`, `system_prompt`, etc.) override matching fields. SDK types are imported lazily inside `run()` to avoid hard import-time dependency.

**Retry**: Formula is `delay = retry_base_delay * 2^attempt`. Optional `retry_max_delay` caps the computed delay; `retry_jitter=True` multiplies by `random(0.75, 1.25)`. Rate-limit `AssistantMessage` with `error="rate_limit"` raises `RuntimeError` to trigger retry.

## SDK Bug Workarounds

See `SDK_BUGS.md` for full root cause analysis. Three workarounds:

1. **Rate limit event kills generator** (`_patch.py`): SDK's `parse_message()` raises on unknown message types like `rate_limit_event`, killing the async generator. Patch returns `SystemMessage` instead. Must patch in both `message_parser` and `client` modules (name binding at import time).

2. **Env var contamination** (`_env.py`): `CLAUDECODE=1` and `CLAUDE_CODE_ENTRYPOINT=cli` inherited from Claude Code cause the CLI subprocess to refuse startup. `clean_claude_env()` strips them.

3. **Silent stderr discard** (`_runner.py`): SDK sends stderr to `/dev/null` unless `ClaudeAgentOptions.stderr` is set. The `on_stderr` parameter wires through automatically.

## Debugging "Stream ended with no content"

1. Pass `on_stderr=print` to see CLI subprocess errors
2. Check `env | grep CLAUDE` — call `clean_claude_env()` if running from Claude Code
3. Verify `_patch_sdk()` ran (happens automatically on import)
4. Check `claude-agent-sdk>=0.1.39`
