feat: vibed out some slop over here also

This commit is contained in:
h
2026-05-19 11:20:14 +02:00
commit bf6116dc8b
34 changed files with 6531 additions and 0 deletions
+79
View File
@@ -0,0 +1,79 @@
"""PTY-based wrapper around the `claude` CLI for subscription-mode backends.
`ClaudeCodeBackend` + `BackendOptions` is the surface a gateway
consumes. `TurnManager` and the typed events / errors are re-exported for
callers that want to assemble the lower layers directly (e.g. tests, custom
session orchestration).
"""
from claude_code_api.backend import (
BackendOptions,
ClaudeCodeBackend,
HistoryInjectionMode,
)
from claude_code_api.errors import (
AuthError,
BackendError,
CLINotFoundError,
MessageParseError,
ProcessError,
RateLimitError,
SessionError,
classify_pty_failure,
)
from claude_code_api.events import (
AssistantMessage,
ContentBlock,
Event,
ResultMessage,
SystemMessage,
TextBlock,
ThinkingBlock,
ToolResultBlock,
ToolUseBlock,
UserMessage,
)
from claude_code_api.models import (
ALIASES,
DISPLAY_NAMES,
MODELS_ALL,
MODELS_CURRENT,
MODELS_LEGACY,
is_valid_model,
)
from claude_code_api.normalizer import normalize
from claude_code_api.turn import TurnManager
__version__ = "0.1.0"
__all__ = [
"ALIASES",
"DISPLAY_NAMES",
"MODELS_ALL",
"MODELS_CURRENT",
"MODELS_LEGACY",
"AssistantMessage",
"AuthError",
"BackendError",
"BackendOptions",
"CLINotFoundError",
"ClaudeCodeBackend",
"ContentBlock",
"Event",
"HistoryInjectionMode",
"MessageParseError",
"ProcessError",
"RateLimitError",
"ResultMessage",
"SessionError",
"SystemMessage",
"TextBlock",
"ThinkingBlock",
"ToolResultBlock",
"ToolUseBlock",
"TurnManager",
"UserMessage",
"classify_pty_failure",
"is_valid_model",
"normalize",
]
+404
View File
@@ -0,0 +1,404 @@
"""The gateway-facing public API.
`ClaudeCodeBackend` is the only class the gateway needs to know
about. It owns:
- a pool of live `claude` sessions, keyed by a fingerprint of conversation
history, so a continuing turn reuses an existing PTY (and the
server-side prompt cache) instead of paying a fresh-spawn tax;
- the choice between `native_jsonl` (default) and `concat_message`
(fallback) for seeding a session with prior history that the gateway
sends in but no live session matches;
- the conversion from `BackendOptions` (high-level, takes a dict of MCP
servers) into `PtyProcessOptions` (low-level, takes argv-ready flags),
including materializing an `--mcp-config` file when `mcp_servers` is set.
"""
from __future__ import annotations
import asyncio
import contextlib
import json
import os
import tempfile
import uuid
from collections.abc import AsyncIterator, Callable, Iterable, Mapping
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Literal, Self
from claude_code_api.errors import MessageParseError
from claude_code_api.events import (
AssistantMessage,
ContentBlock,
Event,
TextBlock,
ThinkingBlock,
ToolResultBlock,
ToolUseBlock,
)
from claude_code_api.injection import (
build_concat_prompt,
build_seed_jsonl,
hash_history,
)
from claude_code_api.paths import resolve_jsonl_path
from claude_code_api.pty import PtyClaudeProcess, PtyProcessOptions
from claude_code_api.turn import TurnManager
from claude_code_api.watcher import JsonlWatcher
HistoryInjectionMode = Literal["native_jsonl", "concat_message"]
ParseErrorCallback = Callable[[MessageParseError, dict[str, Any]], None]
_TERMINAL_STOP_REASONS: frozenset[str] = frozenset(
{"end_turn", "max_tokens", "stop_sequence", "refusal"}
)
@dataclass(frozen=True)
class BackendOptions:
"""High-level configuration for `ClaudeCodeBackend`.
Mirrors `PtyProcessOptions` shape but speaks the gateway's vocabulary:
`mcp_servers` is a `{name: config}` mapping (materialized into a temp
`--mcp-config` file under the hood) rather than a tuple of file paths.
"""
cwd: str | os.PathLike[str]
model: str | None = None
system_prompt: str | None = None
append_system_prompt: str | None = None
allowed_tools: tuple[str, ...] = ()
disallowed_tools: tuple[str, ...] = ()
mcp_servers: Mapping[str, Mapping[str, Any]] | None = None
permission_mode: str = "bypassPermissions"
dangerously_skip_permissions: bool = False
effort: str | None = None
add_dir: tuple[str, ...] = ()
settings: str | None = None
extra_args: tuple[str, ...] = ()
extra_env: Mapping[str, str] = field(default_factory=dict)
preserve_provider_env: bool = False
history_injection_mode: HistoryInjectionMode = "native_jsonl"
wait_for_turn_duration: bool = False
include_meta_user: bool = False
startup_delay: float = 1.0
file_wait_timeout: float = 30.0
turn_duration_timeout: float = 5.0
@dataclass
class _LiveSession:
"""One live PTY + watcher + turn manager. Created per conversation."""
pty: PtyClaudeProcess
watcher: JsonlWatcher
tm: TurnManager
@property
def session_id(self) -> str:
return self.pty.session_id
async def aclose(self) -> None:
await self.tm.aclose()
SessionFactory = Callable[
["ClaudeCodeBackend", str, bool, Path, int],
"asyncio.Future[_LiveSession] | _LiveSession",
]
class ClaudeCodeBackend:
"""Persistent multi-session wrapper around the subscription `claude` CLI.
Lifecycle:
async with ClaudeCodeBackend(opts) as backend:
async for event in backend.complete([{"role": "user", "content": "hi"}]):
...
Each call to `complete()` either reuses a live PTY (if the new
`messages[:-1]` matches one we already have running) or spawns a fresh
session, optionally seeding it with prior history. On success, the
session is stashed under a new fingerprint that incorporates this
turn, so the next request can find it.
"""
def __init__(
self,
options: BackendOptions,
*,
on_parse_error: ParseErrorCallback | None = None,
_session_factory: SessionFactory | None = None,
) -> None:
self._opts = options
self._on_parse_error = on_parse_error
self._sessions: dict[str, _LiveSession] = {}
self._mcp_config_path: Path | None = None
self._session_factory = _session_factory
self._closed = False
self._lock = asyncio.Lock()
@property
def options(self) -> BackendOptions:
return self._opts
@property
def live_session_count(self) -> int:
return len(self._sessions)
async def complete(self, messages: list[Mapping[str, Any]]) -> AsyncIterator[Event]:
"""Run one turn against the matching session (or spawn one).
`messages` is an Anthropic-Messages-API style list — alternating
user/assistant entries ending with a user entry. The backend uses
`messages[:-1]` to look up a live session by fingerprint; if none
matches it creates one (seeded with that history if non-empty).
Yields typed events as they arrive; the final event is the
synthesized `ResultMessage` from `TurnManager`.
"""
if self._closed:
msg = "ClaudeCodeBackend is closed"
raise RuntimeError(msg)
if not messages:
msg = "messages must not be empty"
raise ValueError(msg)
last = messages[-1]
if last.get("role") != "user":
msg = "last message must have role='user'"
raise ValueError(msg)
last_text = _user_text_payload(last.get("content"))
async with self._lock:
prior = list(messages[:-1])
fp_prior = hash_history(prior)
session: _LiveSession
if prior and fp_prior in self._sessions:
session = self._sessions.pop(fp_prior)
send_text = last_text
else:
session = await self._create_session(prior)
if prior and self._opts.history_injection_mode == "concat_message":
send_text = build_concat_prompt(prior, last_text)
else:
send_text = last_text
events: list[Event] = []
try:
async for event in session.tm.send_user_message(send_text):
events.append(event)
yield event
except BaseException:
with contextlib.suppress(Exception):
await session.aclose()
raise
synthetic_asst = _synthesize_assistant_dict(events)
new_history = [*list(messages), synthetic_asst]
self._sessions[hash_history(new_history)] = session
async def aclose(self) -> None:
"""Shut down all live sessions; remove the temp mcp-config file."""
self._closed = True
sessions = list(self._sessions.values())
self._sessions.clear()
for s in sessions:
with contextlib.suppress(Exception):
await s.aclose()
if self._mcp_config_path is not None:
with contextlib.suppress(OSError):
self._mcp_config_path.unlink()
self._mcp_config_path = None
async def __aenter__(self) -> Self:
return self
async def __aexit__(self, _exc_type: object, _exc: object, _tb: object) -> None:
await self.aclose()
async def _create_session(self, history: list[Mapping[str, Any]]) -> _LiveSession:
"""Spawn a fresh PTY + watcher + manager, optionally seeded.
`native_jsonl` (default): write a hand-crafted JSONL transcript at
`~/.claude/projects/<key>/<session_id>.jsonl`, then start claude
with `--resume <session_id>`. The watcher starts at the seed
file's end so it sees only fresh records.
`concat_message` (fallback): spawn fresh; the history is injected
into the first user prompt instead (handled by `complete()`).
"""
session_id = str(uuid.uuid4())
cwd = os.fspath(self._opts.cwd)
if history and self._opts.history_injection_mode == "native_jsonl":
jsonl_path = resolve_jsonl_path(cwd, session_id)
jsonl_path.parent.mkdir(parents=True, exist_ok=True)
seed = build_seed_jsonl(history, session_id=session_id, cwd=cwd)
jsonl_path.write_text(seed, encoding="utf-8")
start_offset = jsonl_path.stat().st_size
resume = True
else:
jsonl_path = resolve_jsonl_path(cwd, session_id)
start_offset = 0
resume = False
if self._session_factory is not None:
result = self._session_factory(
self, session_id, resume, jsonl_path, start_offset
)
if asyncio.iscoroutine(result):
return await result
return result # type: ignore[return-value]
return await self._spawn_real_session(
session_id=session_id,
resume=resume,
jsonl_path=jsonl_path,
start_offset=start_offset,
)
async def _spawn_real_session(
self, *, session_id: str, resume: bool, jsonl_path: Path, start_offset: int
) -> _LiveSession:
pty_opts = self._build_pty_options(session_id=session_id, resume=resume)
pty = PtyClaudeProcess(pty_opts)
watcher = JsonlWatcher(jsonl_path, start_offset=start_offset)
tm = TurnManager(
pty,
watcher,
wait_for_turn_duration=self._opts.wait_for_turn_duration,
include_meta_user=self._opts.include_meta_user,
file_wait_timeout=self._opts.file_wait_timeout,
turn_duration_timeout=self._opts.turn_duration_timeout,
startup_delay=self._opts.startup_delay,
on_parse_error=self._on_parse_error,
)
await tm.start()
return _LiveSession(pty=pty, watcher=watcher, tm=tm)
def _build_pty_options(self, *, session_id: str, resume: bool) -> PtyProcessOptions:
mcp_config = self._mcp_config_argument()
kwargs: dict[str, Any] = {
"cwd": self._opts.cwd,
"model": self._opts.model,
"system_prompt": self._opts.system_prompt,
"append_system_prompt": self._opts.append_system_prompt,
"allowed_tools": self._opts.allowed_tools,
"disallowed_tools": self._opts.disallowed_tools,
"mcp_config": mcp_config,
"add_dir": self._opts.add_dir,
"permission_mode": self._opts.permission_mode,
"dangerously_skip_permissions": self._opts.dangerously_skip_permissions,
"effort": self._opts.effort,
"settings": self._opts.settings,
"extra_args": self._opts.extra_args,
"preserve_provider_env": self._opts.preserve_provider_env,
"extra_env": self._opts.extra_env,
}
if resume:
kwargs["resume_session_id"] = session_id
else:
kwargs["session_id"] = session_id
return PtyProcessOptions(**kwargs)
def _mcp_config_argument(self) -> tuple[str, ...]:
"""Materialize `mcp_servers` into a `--mcp-config` file path tuple.
The temp file lives for the backend's lifetime — cleaned up in
`aclose()`. Written lazily so a backend that never spawns a
session leaves no debris.
"""
servers = self._opts.mcp_servers
if not servers:
return ()
if self._mcp_config_path is None:
fd, path = tempfile.mkstemp(prefix="claude-mcp-", suffix=".json")
try:
with os.fdopen(fd, "w", encoding="utf-8") as f:
json.dump({"mcpServers": dict(servers)}, f)
except Exception:
with contextlib.suppress(OSError):
Path(path).unlink()
raise
self._mcp_config_path = Path(path)
return (str(self._mcp_config_path),)
def _user_text_payload(content: Any) -> str:
"""Extract the text we'll write to the PTY for the last user message.
A string `content` passes through as-is. A list of blocks is flattened
to its text content; tool_result blocks are not faithfully
reproducible through stdin and are skipped.
"""
if isinstance(content, str):
return content
if isinstance(content, list):
chunks: list[str] = []
for block in content:
if isinstance(block, Mapping) and block.get("type") == "text":
text = block.get("text")
if isinstance(text, str):
chunks.append(text)
if not chunks:
msg = "last user message content must include at least one text block"
raise ValueError(msg)
return " ".join(chunks)
msg = f"last user message content must be str or list, got {type(content).__name__}"
raise ValueError(msg)
def _synthesize_assistant_dict(events: Iterable[Event]) -> dict[str, Any]:
"""Render the terminal assistant message in Anthropic Messages format."""
terminal: AssistantMessage | None = None
for ev in reversed(list(events)):
if (
isinstance(ev, AssistantMessage)
and ev.stop_reason in _TERMINAL_STOP_REASONS
):
terminal = ev
break
if terminal is None:
return {"role": "assistant", "content": []}
return {
"role": "assistant",
"content": [_block_to_dict(b) for b in terminal.content],
}
def _block_to_dict(block: ContentBlock) -> dict[str, Any]:
if isinstance(block, TextBlock):
return {"type": "text", "text": block.text}
if isinstance(block, ToolUseBlock):
return {
"type": "tool_use",
"id": block.id,
"name": block.name,
"input": block.input,
}
if isinstance(block, ToolResultBlock):
return {
"type": "tool_result",
"tool_use_id": block.tool_use_id,
"content": block.content,
"is_error": block.is_error,
}
if isinstance(block, ThinkingBlock):
return {
"type": "thinking",
"thinking": block.thinking,
"signature": block.signature,
}
msg = f"unknown content block type: {type(block).__name__}"
raise TypeError(msg)
__all__ = [
"BackendOptions",
"ClaudeCodeBackend",
"HistoryInjectionMode",
"ParseErrorCallback",
]
+138
View File
@@ -0,0 +1,138 @@
"""Backend exception hierarchy.
Mirrors `claude_agent_sdk._errors` so a gateway that already catches its
shapes keeps working when it wires this backend in.
- `SessionError` is raised when the JSONL session file never appears within
`TurnManager.file_wait_timeout`.
- `ProcessError` carries `exit_code` and `stderr` (the rolling PTY output
buffer — claude's TUI writes its error chrome to the PTY stream, not to
stderr).
- `classify_pty_failure(captured)` inspects that buffer for known error
markers and returns the most specific subclass to raise (or `None` for
"no signal — caller picks the default").
"""
from __future__ import annotations
import re
from typing import Any
class BackendError(Exception):
"""Base for every error raised by this package."""
class MessageParseError(BackendError):
"""A JSONL record was malformed or missing fields required to type it."""
def __init__(self, message: str, data: Any = None) -> None:
self.data = data
super().__init__(message)
class ProcessError(BackendError):
"""The `claude` subprocess died unexpectedly or refused to start.
`exit_code` is `None` if we never observed an exit (e.g. the process is
still alive but unresponsive); `stderr` carries the rolling PTY output
buffer at the moment of failure (capped, oldest dropped) — claude prints
its error chrome there rather than to stderr.
"""
def __init__(
self, message: str, *, exit_code: int | None = None, stderr: str | None = None
) -> None:
self.exit_code = exit_code
self.stderr = stderr
if exit_code is not None:
message = f"{message} (exit code: {exit_code})"
if stderr:
tail = stderr[-2000:] if len(stderr) > 2000 else stderr
message = f"{message}\nPTY output (tail):\n{tail}"
super().__init__(message)
class CLINotFoundError(ProcessError):
"""The `claude` binary could not be located on PATH."""
def __init__(
self, message: str = "claude CLI not found", *, executable: str | None = None
) -> None:
if executable:
message = f"{message}: {executable}"
self.executable = executable
super().__init__(message)
class AuthError(BackendError):
"""OAuth / subscription auth is blocked — user must re-`claude /login`."""
def __init__(self, message: str = "claude subscription auth is blocked") -> None:
super().__init__(message)
class RateLimitError(BackendError):
"""Subscription rate limit hit."""
def __init__(self, message: str = "claude subscription rate limit reached") -> None:
super().__init__(message)
class SessionError(BackendError):
"""JSONL session file failed to materialize within the configured timeout.
Usually means claude is hung in startup (e.g. workspace-trust prompt
blocking), or its TUI never accepted our stdin write.
"""
_ANSI_RE = re.compile(r"\x1b\[[0-?]*[ -/]*[@-~]")
_NON_ALNUM_RE = re.compile(r"[^a-z0-9]+")
def _compact(text: str) -> str:
return _NON_ALNUM_RE.sub("", _ANSI_RE.sub("", text).lower())
def classify_pty_failure(captured: bytes | str) -> type[BackendError] | None:
"""Inspect a PTY-output buffer for known TUI error-chrome markers.
Returns the most specific `BackendError` subclass to raise, or `None`
when no marker is matched (caller falls back to a generic
`ProcessError`/`SessionError`).
"""
text = (
captured.decode("utf-8", errors="replace")
if isinstance(captured, bytes)
else captured
)
low = _ANSI_RE.sub("", text).lower()
compact = _compact(text)
if (
"failed to authenticate" in low
or "api error: 403" in low
or "pleaserunlogin" in compact
or "pleaserun/login" in compact
):
return AuthError
if (
"you've hit your limit" in low
or "you have hit your limit" in low
or "hit your limit" in low
):
return RateLimitError
return None
__all__ = [
"AuthError",
"BackendError",
"CLINotFoundError",
"MessageParseError",
"ProcessError",
"RateLimitError",
"SessionError",
"classify_pty_failure",
]
+129
View File
@@ -0,0 +1,129 @@
"""Public event types emitted by the backend.
The shapes mirror the `claude-agent-sdk` Python types (`AssistantMessage`,
`UserMessage`, `SystemMessage`, `ResultMessage`, plus the content-block
dataclasses) so the gateway that consumes this backend can re-expose
everything over an Anthropic Messages API-compatible HTTP surface with a
one-liner serializer.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Any
@dataclass
class TextBlock:
text: str
@dataclass
class ThinkingBlock:
thinking: str
signature: str
@dataclass
class ToolUseBlock:
id: str
name: str
input: dict[str, Any]
@dataclass
class ToolResultBlock:
tool_use_id: str
content: str | list[dict[str, Any]] | None = None
is_error: bool | None = None
ContentBlock = TextBlock | ThinkingBlock | ToolUseBlock | ToolResultBlock
@dataclass
class UserMessage:
"""User turn record from JSONL.
`content` is either a verbatim string (the prompt we sent) or a list of
content blocks (the only observed block-list shape is a list of
`ToolResultBlock`s, written by claude after a tool call completes).
"""
content: str | list[ContentBlock]
uuid: str | None = None
session_id: str | None = None
parent_uuid: str | None = None
@dataclass
class AssistantMessage:
"""Assistant turn record.
`stop_reason` can be ``None`` for intermediate streaming chunks claude
writes mid-turn; only terminal values (``end_turn`` / ``tool_use`` /
``max_tokens`` / ``stop_sequence`` / ``refusal``) close out a turn.
"""
content: list[ContentBlock]
model: str
stop_reason: str | None = None
usage: dict[str, Any] | None = None
message_id: str | None = None
session_id: str | None = None
uuid: str | None = None
parent_uuid: str | None = None
@dataclass
class SystemMessage:
"""Out-of-band signal record.
The only subtype the normalizer surfaces by default is ``turn_duration``,
which marks the end of a turn (after all post-turn hooks have flushed).
All other system subtypes (`stop_hook_summary`, `local_command`) are
filtered out — they are claude's internal bookkeeping, not consumer
signal. `data` carries the full raw record for callers that want it.
"""
subtype: str
data: dict[str, Any]
session_id: str | None = None
uuid: str | None = None
@dataclass
class ResultMessage:
"""Synthesized turn-completion summary.
Not emitted by the normalizer — JSONL has no native ``result`` record.
`TurnManager` fabricates one when a turn closes, aggregating usage from
the last assistant record and timing from the final `turn_duration`
system signal.
"""
subtype: str
duration_ms: int
num_turns: int
session_id: str
is_error: bool = False
stop_reason: str | None = None
usage: dict[str, Any] | None = None
uuid: str | None = None
Event = UserMessage | AssistantMessage | SystemMessage | ResultMessage
__all__ = [
"AssistantMessage",
"ContentBlock",
"Event",
"ResultMessage",
"SystemMessage",
"TextBlock",
"ThinkingBlock",
"ToolResultBlock",
"ToolUseBlock",
"UserMessage",
]
+253
View File
@@ -0,0 +1,253 @@
"""History fingerprinting + injection formats.
Three pure functions:
- `hash_history(messages)` — deterministic fingerprint of an Anthropic-style
message list. Two requests whose `messages[:-1]` hash to the same value
are considered the same conversation up to that turn, regardless of which
client sent them.
- `build_seed_jsonl(messages, session_id, cwd, ...)` — render a prior
history as a native `claude` JSONL transcript ready to be written under
`~/.claude/projects/<key>/<session_id>.jsonl` and resumed via
`claude --resume <session_id>`.
- `build_concat_prompt(history, last_user_text)` — render the same prior
history as a single big text prompt for the fallback `concat_message`
injection mode (no JSONL surgery; everything goes through stdin).
This module does NOT touch the filesystem.
"""
from __future__ import annotations
import datetime as dt
import hashlib
import json
import uuid
from collections.abc import Iterable, Mapping
from typing import Any
_DEFAULT_CLAUDE_VERSION = "2.1.143"
_DEFAULT_MODEL = "claude-opus-4-7"
def hash_history(messages: Iterable[Mapping[str, Any]]) -> str:
"""Return a stable sha256 hex digest of a conversation prefix.
Only the content-bearing fields are considered: `role`, `content` (with
text/tool_use/tool_result blocks normalized to their semantic shape).
Ordering of dict keys inside blocks is normalized via canonical-JSON
(`sort_keys=True`) so two clients that serialize the same content
blocks in different key orders still collide.
"""
canonical = [_canonical_message(m) for m in messages]
payload = json.dumps(canonical, sort_keys=True, separators=(",", ":"))
return hashlib.sha256(payload.encode("utf-8")).hexdigest()
def _canonical_message(message: Mapping[str, Any]) -> dict[str, Any]:
role = message.get("role")
if role not in ("user", "assistant"):
msg = f"message must have role 'user' or 'assistant', got {role!r}"
raise ValueError(msg)
content = message.get("content")
return {"role": role, "content": _canonical_content(content)}
def _canonical_content(content: Any) -> Any:
if isinstance(content, str):
return content
if isinstance(content, list):
return [_canonical_block(b) for b in content]
msg = f"content must be str or list, got {type(content).__name__}"
raise ValueError(msg)
def _canonical_block(block: Any) -> dict[str, Any]:
if not isinstance(block, Mapping):
msg = f"content block must be a mapping, got {type(block).__name__}"
raise ValueError(msg)
btype = block.get("type")
if btype == "text":
return {"type": "text", "text": block.get("text", "")}
if btype == "tool_use":
return {
"type": "tool_use",
"id": block.get("id", ""),
"name": block.get("name", ""),
"input": block.get("input", {}),
}
if btype == "tool_result":
return {
"type": "tool_result",
"tool_use_id": block.get("tool_use_id", ""),
"content": block.get("content"),
"is_error": block.get("is_error"),
}
return {"type": btype, **{k: v for k, v in block.items() if k != "type"}}
def build_seed_jsonl(
messages: Iterable[Mapping[str, Any]],
*,
session_id: str,
cwd: str,
claude_version: str = _DEFAULT_CLAUDE_VERSION,
model: str = _DEFAULT_MODEL,
now_iso: str | None = None,
) -> str:
"""Render a message list as a native claude JSONL transcript.
Output is a newline-terminated string of one JSON object per line. The
schema mirrors what claude itself writes (minus the snapshot records).
The caller writes the result to
`~/.claude/projects/<key>/<session_id>.jsonl` and spawns
`claude --resume <session_id>`. Claude appends its own
`file-history-snapshot` / `last-prompt` / `permission-mode` records on
resume — we don't need to.
Empty history is permitted; the returned string is empty in that case.
"""
if now_iso is None:
now_iso = _now_iso()
lines: list[str] = []
parent_uuid: str | None = None
common = {
"isSidechain": False,
"userType": "external",
"entrypoint": "cli",
"cwd": cwd,
"sessionId": session_id,
"version": claude_version,
"gitBranch": "",
}
for m in messages:
role = m.get("role")
if role == "user":
user_uuid = str(uuid.uuid4())
record = {
"parentUuid": parent_uuid,
"promptId": str(uuid.uuid4()),
"type": "user",
"message": {
"role": "user",
"content": _content_for_seed(m.get("content"), role="user"),
},
"isMeta": False,
"uuid": user_uuid,
"timestamp": now_iso,
**common,
}
parent_uuid = user_uuid
elif role == "assistant":
assistant_uuid = str(uuid.uuid4())
record = {
"parentUuid": parent_uuid,
"message": {
"model": model,
"id": f"msg_{uuid.uuid4().hex[:24]}",
"type": "message",
"role": "assistant",
"content": _content_for_seed(m.get("content"), role="assistant"),
"stop_reason": "end_turn",
"stop_sequence": None,
"stop_details": None,
"usage": {
"input_tokens": 0,
"output_tokens": 0,
"cache_creation_input_tokens": 0,
"cache_read_input_tokens": 0,
"service_tier": "standard",
},
},
"requestId": f"req_{uuid.uuid4().hex[:24]}",
"type": "assistant",
"uuid": assistant_uuid,
"timestamp": now_iso,
**common,
}
parent_uuid = assistant_uuid
else:
msg = f"message role must be 'user' or 'assistant', got {role!r}"
raise ValueError(msg)
lines.append(json.dumps(record))
if not lines:
return ""
return "\n".join(lines) + "\n"
def _content_for_seed(content: Any, *, role: str) -> Any:
"""Normalize Anthropic message content to what claude expects in JSONL."""
if role == "user":
if isinstance(content, str):
return content
if isinstance(content, list):
return [dict(b) for b in content]
msg = f"user content must be str or list, got {type(content).__name__}"
raise ValueError(msg)
if isinstance(content, str):
return [{"type": "text", "text": content}]
if isinstance(content, list):
return [dict(b) for b in content]
msg = f"assistant content must be str or list, got {type(content).__name__}"
raise ValueError(msg)
_CONCAT_PREAMBLE = "Previous conversation context:"
_CONCAT_DIVIDER = "Continue from here. New user message:"
def build_concat_prompt(
history: Iterable[Mapping[str, Any]], last_user_text: str
) -> str:
"""Render prior history + the new user prompt as one stdin payload.
Fallback for when `native_jsonl` injection can't be used. Costs more
tokens per request and breaks the server-side prompt cache, but always
works because it goes through the same stdin path as a normal first
turn.
"""
parts: list[str] = []
history_list = list(history)
if history_list:
parts.append(_CONCAT_PREAMBLE)
parts.append("")
for m in history_list:
role = m.get("role")
if role not in ("user", "assistant"):
msg = f"message role must be 'user' or 'assistant', got {role!r}"
raise ValueError(msg)
label = "[User]" if role == "user" else "[Assistant]"
parts.append(f"{label}: {_flatten_text(m.get('content'))}")
parts.append("")
parts.append(f"{_CONCAT_DIVIDER} {last_user_text}")
else:
parts.append(last_user_text)
return "\n".join(parts)
def _flatten_text(content: Any) -> str:
"""Extract a single string from a content payload for concat mode."""
if isinstance(content, str):
return content
if isinstance(content, list):
chunks: list[str] = []
for b in content:
if isinstance(b, Mapping) and b.get("type") == "text":
text = b.get("text")
if isinstance(text, str):
chunks.append(text)
return " ".join(chunks)
return ""
def _now_iso() -> str:
"""RFC3339 with millisecond precision and a 'Z' suffix, matching claude."""
now = dt.datetime.now(dt.UTC)
return now.strftime("%Y-%m-%dT%H:%M:%S.") + f"{now.microsecond // 1000:03d}Z"
__all__ = ["build_concat_prompt", "build_seed_jsonl", "hash_history"]
+129
View File
@@ -0,0 +1,129 @@
"""Hardcoded inventory of `claude` CLI models and aliases.
Sourced by inspecting the Mach-O binary at
`~/.local/share/claude/versions/<ver>` on macOS (or the equivalent Linux
location). The data lives in plain string literals — no decryption or
unpacking needed. See `scripts/extract_models.py` to refresh after a
`claude` update.
The CLI accepts either a *short alias* (e.g. ``sonnet``) or a *full model
id* (e.g. ``claude-sonnet-4-6``) for ``--model``. Aliases get resolved to
whatever Anthropic considers current; pin a full id when you need
stability across `claude` updates.
Last refreshed against `claude 2.1.143`.
"""
from __future__ import annotations
from typing import Final
CLAUDE_VERSION: Final = "2.1.143"
ALIASES: Final[tuple[str, ...]] = (
"default",
"sonnet",
"opus",
"haiku",
"best",
"sonnet[1m]",
"opus[1m]",
"opusplan",
)
"""Short aliases the CLI's `/model` command lists as ``Available:``.
`opusplan` routes /plan calls through opus and execution through sonnet.
`*[1m]` selects the 1M-context variant of the family (requires entitlement).
`best` resolves to whatever Anthropic currently advertises as flagship.
"""
MODELS_CURRENT: Final[tuple[str, ...]] = (
"claude-opus-4-7",
"claude-opus-4-6",
"claude-opus-4-6-fast",
"claude-sonnet-4-6",
"claude-haiku-4-5",
)
"""Currently-recommended model ids — current minor release of each family."""
MODELS_LEGACY: Final[tuple[str, ...]] = (
"claude-opus-4-5",
"claude-opus-4-5-20251101",
"claude-opus-4-1",
"claude-opus-4-1-20250805",
"claude-opus-4-0",
"claude-opus-4",
"claude-opus-4-20250514",
"claude-4-opus-20250514",
"claude-sonnet-4-5",
"claude-sonnet-4-5-20250929",
"claude-sonnet-4-0",
"claude-sonnet-4",
"claude-sonnet-4-20250514",
"claude-sonnet-3-7",
"claude-haiku-4",
"claude-haiku-3-5",
"claude-3-7-sonnet",
"claude-3-7-sonnet-latest",
"claude-3-7-sonnet-20250219",
"claude-3-5-sonnet",
"claude-3-5-sonnet-20241022",
"claude-3-5-haiku",
"claude-3-5-haiku-latest",
"claude-3-5-haiku-20241022",
"claude-3-opus",
"claude-3-opus-20240229",
"claude-3-sonnet",
"claude-3-sonnet-20240229",
"claude-3-haiku",
)
"""Older model ids the CLI still accepts but Anthropic no longer fronts."""
MODELS_ALL: Final[tuple[str, ...]] = MODELS_CURRENT + MODELS_LEGACY
"""Every model id the `claude 2.1.143` binary references."""
DISPLAY_NAMES: Final[dict[str, str]] = {
"claude-opus-4-7": "Opus 4.7",
"claude-opus-4-6": "Opus 4.6",
"claude-opus-4-6-fast": "Opus 4.6 (fast)",
"claude-opus-4-5": "Opus 4.5",
"claude-opus-4-1": "Opus 4.1",
"claude-opus-4": "Opus 4",
"claude-sonnet-4-6": "Sonnet 4.6",
"claude-sonnet-4-5": "Sonnet 4.5",
"claude-sonnet-4": "Sonnet 4",
"claude-haiku-4-5": "Haiku 4.5",
"claude-haiku-4": "Haiku 4",
"claude-3-7-sonnet": "Sonnet 3.7",
"claude-3-5-sonnet": "Sonnet 3.5",
"claude-3-5-haiku": "Haiku 3.5",
"claude-3-opus": "Opus 3",
"claude-3-sonnet": "Sonnet 3",
"claude-3-haiku": "Haiku 3",
}
"""Human-readable labels for the canonical model ids (no dated suffixes)."""
def is_valid_model(name: str) -> bool:
"""Return ``True`` if `name` is a known alias or a known model id.
A `False` return doesn't necessarily mean `claude` will reject the
value — Anthropic may have added a new model since this module was
refreshed. Use as a hint, not a gate.
"""
return name in ALIASES or name in MODELS_ALL
__all__ = [
"ALIASES",
"CLAUDE_VERSION",
"DISPLAY_NAMES",
"MODELS_ALL",
"MODELS_CURRENT",
"MODELS_LEGACY",
"is_valid_model",
]
+190
View File
@@ -0,0 +1,190 @@
"""JSONL record -> typed `Event`.
A pure function. Stateless, side-effect-free, and unaware of the file the
record came from. Higher layers call `normalize(record)` on every line:
- a typed `Event` instance — surface it to the consumer;
- ``None`` — filtered out (forward-compat unknown type, bookkeeping record,
meta caveat) — drop silently;
- `MessageParseError` — the record claimed to be of a type we handle but
was missing fields we cannot fabricate.
Filter policy: only the records that carry consumer-visible signal are
mapped (`user`, `assistant`, and the `system.subtype=turn_duration`
heartbeat). Everything else is bookkeeping and gets dropped.
"""
from __future__ import annotations
from typing import Any
from claude_code_api.errors import MessageParseError
from claude_code_api.events import (
AssistantMessage,
ContentBlock,
Event,
SystemMessage,
TextBlock,
ThinkingBlock,
ToolResultBlock,
ToolUseBlock,
UserMessage,
)
_BOOKKEEPING_TYPES: frozenset[str] = frozenset(
{
"attachment",
"file-history-snapshot",
"last-prompt",
"ai-title",
"permission-mode",
"queue-operation",
}
)
_BOOKKEEPING_SYSTEM_SUBTYPES: frozenset[str] = frozenset(
{"local_command", "stop_hook_summary"}
)
def normalize(
record: dict[str, Any], *, include_meta_user: bool = False
) -> Event | None:
"""Map one JSONL record to a typed event, or ``None`` if filtered.
Args:
record: parsed JSON object from a JSONL line.
include_meta_user: when ``True``, user records with ``isMeta=True``
(local-command caveats claude injects) are emitted instead of
dropped. Off by default — those records are not part of the
real conversation history.
Raises:
MessageParseError: the record's `type` is one we handle but it is
missing fields needed to construct the event.
"""
if not isinstance(record, dict):
msg = f"record must be a dict, got {type(record).__name__}"
raise MessageParseError(msg, record)
record_type = record.get("type")
if record_type is None:
msg = "record missing 'type' field"
raise MessageParseError(msg, record)
if record_type in _BOOKKEEPING_TYPES:
return None
if record_type == "user":
return _parse_user(record, include_meta=include_meta_user)
if record_type == "assistant":
return _parse_assistant(record)
if record_type == "system":
return _parse_system(record)
return None
def _parse_user(record: dict[str, Any], *, include_meta: bool) -> UserMessage | None:
if record.get("isMeta") and not include_meta:
return None
try:
raw_content = record["message"]["content"]
except KeyError as exc:
msg = f"user record missing required field: {exc}"
raise MessageParseError(msg, record) from exc
content: str | list[ContentBlock]
if isinstance(raw_content, str):
content = raw_content
elif isinstance(raw_content, list):
content = [_parse_block(block, record) for block in raw_content]
else:
got = type(raw_content).__name__
msg = f"user record content must be str or list, got {got}"
raise MessageParseError(msg, record)
return UserMessage(
content=content,
uuid=record.get("uuid"),
session_id=record.get("sessionId"),
parent_uuid=record.get("parentUuid"),
)
def _parse_assistant(record: dict[str, Any]) -> AssistantMessage:
try:
message = record["message"]
raw_content = message["content"]
model = message["model"]
except KeyError as exc:
msg = f"assistant record missing required field: {exc}"
raise MessageParseError(msg, record) from exc
if not isinstance(raw_content, list):
msg = f"assistant content must be a list, got {type(raw_content).__name__}"
raise MessageParseError(msg, record)
content = [_parse_block(block, record) for block in raw_content]
return AssistantMessage(
content=content,
model=model,
stop_reason=message.get("stop_reason"),
usage=message.get("usage"),
message_id=message.get("id"),
session_id=record.get("sessionId"),
uuid=record.get("uuid"),
parent_uuid=record.get("parentUuid"),
)
def _parse_system(record: dict[str, Any]) -> SystemMessage | None:
subtype = record.get("subtype")
if subtype is None:
msg = "system record missing 'subtype'"
raise MessageParseError(msg, record)
if subtype in _BOOKKEEPING_SYSTEM_SUBTYPES:
return None
return SystemMessage(
subtype=subtype,
data=record,
session_id=record.get("sessionId"),
uuid=record.get("uuid"),
)
def _parse_block(block: Any, record: dict[str, Any]) -> ContentBlock:
if not isinstance(block, dict):
msg = f"content block must be a dict, got {type(block).__name__}"
raise MessageParseError(msg, record)
block_type = block.get("type")
try:
if block_type == "text":
return TextBlock(text=block["text"])
if block_type == "thinking":
return ThinkingBlock(
thinking=block["thinking"], signature=block["signature"]
)
if block_type == "tool_use":
return ToolUseBlock(
id=block["id"], name=block["name"], input=block["input"]
)
if block_type == "tool_result":
return ToolResultBlock(
tool_use_id=block["tool_use_id"],
content=block.get("content"),
is_error=block.get("is_error"),
)
except KeyError as exc:
msg = f"{block_type} block missing required field: {exc}"
raise MessageParseError(msg, record) from exc
msg = f"unknown content block type: {block_type!r}"
raise MessageParseError(msg, record)
__all__ = ["normalize"]
+113
View File
@@ -0,0 +1,113 @@
"""Helpers for locating `claude` JSONL session files.
`claude` stores per-session transcripts at
`~/.claude/projects/<project_key>/<session_id>.jsonl`, where `project_key` is
the absolute cwd with every non-alphanumeric character (other than `-`)
replaced by `-`. So `/Users/h/.t3/worktrees/foo` becomes
`-Users-h--t3-worktrees-foo`.
The encoding is intentionally lossy (existing `-` is preserved) but matches
what claude writes for every cwd inspected. None of these helpers touch the
filesystem except `find_jsonl_by_session_id`.
"""
from __future__ import annotations
import os
import re
from pathlib import Path
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from collections.abc import Iterable
_KEY_SAFE_RE = re.compile(r"[^A-Za-z0-9-]")
_PROJECTS_DIRNAME = "projects"
_CLAUDE_HOME_DIRNAME = ".claude"
def claude_home(home: str | os.PathLike[str] | None = None) -> Path:
"""Return the `~/.claude` directory; honors `$HOME` via `Path.home()`."""
base = Path(home) if home is not None else Path.home()
return base / _CLAUDE_HOME_DIRNAME
def projects_root(home: str | os.PathLike[str] | None = None) -> Path:
"""Return `~/.claude/projects`."""
return claude_home(home) / _PROJECTS_DIRNAME
def encode_project_key(cwd: str | os.PathLike[str]) -> str:
"""Encode an absolute cwd into the directory name claude uses on disk.
Rules:
- `cwd` must be absolute; relative paths raise `ValueError`.
- The leading `/` becomes `-`, so the key always starts with `-`.
- Every char outside `[A-Za-z0-9-]` becomes `-`. Adjacent specials
produce adjacent dashes — matches what claude writes.
Symlinks are not resolved; claude stores the literal invocation path.
"""
raw = os.fspath(cwd)
if not raw:
msg = "cwd must not be empty"
raise ValueError(msg)
if not Path(raw).is_absolute():
msg = f"cwd must be absolute, got {raw!r}"
raise ValueError(msg)
return _KEY_SAFE_RE.sub("-", raw)
def session_dir(
cwd: str | os.PathLike[str], *, home: str | os.PathLike[str] | None = None
) -> Path:
"""Return the directory that holds JSONL session files for `cwd`."""
return projects_root(home) / encode_project_key(cwd)
def resolve_jsonl_path(
cwd: str | os.PathLike[str],
session_id: str,
*,
home: str | os.PathLike[str] | None = None,
) -> Path:
"""Return the canonical JSONL path for `(cwd, session_id)`.
Does not check existence — both higher layers (watcher reads, injection
writes) want to be able to compute this path before the file exists.
"""
if not session_id:
msg = "session_id must not be empty"
raise ValueError(msg)
return session_dir(cwd, home=home) / f"{session_id}.jsonl"
def find_jsonl_by_session_id(
session_id: str, *, home: str | os.PathLike[str] | None = None
) -> Path | None:
"""Search `~/.claude/projects/**/<session_id>.jsonl`.
Useful as a sanity check when the cwd-derived key seems wrong. Returns
the first match, or `None` if no session file with that id exists.
"""
if not session_id:
msg = "session_id must not be empty"
raise ValueError(msg)
root = projects_root(home)
if not root.is_dir():
return None
for path in root.glob(f"*/{session_id}.jsonl"):
return path
return None
__all__: Iterable[str] = (
"claude_home",
"encode_project_key",
"find_jsonl_by_session_id",
"projects_root",
"resolve_jsonl_path",
"session_dir",
)
+389
View File
@@ -0,0 +1,389 @@
"""PTY-driven `claude` subprocess.
`PtyClaudeProcess` owns the lifecycle of one long-running interactive `claude`
under a pseudo-TTY. Higher layers consume events from the JSONL session file,
not from this process's PTY output — but the PTY must still be drained
continuously so the child does not block on a full kernel buffer. A background
drain thread handles that; raw output is exposed only for smoke tests and an
optional callback for debug consumers.
This module knows nothing about turns, JSONL, or event normalization.
"""
from __future__ import annotations
import asyncio
import contextlib
import errno
import os
import select
import signal
import threading
import uuid
from collections.abc import Callable, Iterable, Mapping
from dataclasses import dataclass, field
from typing import Self
from ptyprocess import PtyProcess
from claude_code_api.errors import CLINotFoundError
PtyOutputCallback = Callable[[bytes], None]
_PROVIDER_ENV_VARS: tuple[str, ...] = (
"ANTHROPIC_API_KEY",
"ANTHROPIC_AUTH_TOKEN",
"ANTHROPIC_BASE_URL",
)
_VALID_PERMISSION_MODES: frozenset[str] = frozenset(
{"acceptEdits", "auto", "bypassPermissions", "default", "dontAsk", "plan"}
)
_DEFAULT_DRAIN_CHUNK = 65536
_DEFAULT_OUTPUT_BUFFER_CAP = 1_000_000
@dataclass(frozen=True)
class PtyProcessOptions:
"""Configuration for a single PTY-spawned `claude` interactive process.
`cwd` is required because it determines the JSONL project key used by
higher layers. `session_id` is auto-generated as a UUID4 if omitted.
"""
cwd: str | os.PathLike[str]
session_id: str | None = None
resume_session_id: str | None = None
model: str | None = None
system_prompt: str | None = None
append_system_prompt: str | None = None
allowed_tools: tuple[str, ...] = ()
disallowed_tools: tuple[str, ...] = ()
mcp_config: tuple[str, ...] = ()
add_dir: tuple[str, ...] = ()
permission_mode: str = "bypassPermissions"
dangerously_skip_permissions: bool = False
effort: str | None = None
settings: str | None = None
executable: str = "claude"
extra_args: tuple[str, ...] = ()
term: str = "xterm-256color"
dimensions: tuple[int, int] = (24, 80)
preserve_provider_env: bool = False
extra_env: Mapping[str, str] = field(default_factory=dict)
def __post_init__(self) -> None:
if (
not self.dangerously_skip_permissions
and self.permission_mode not in _VALID_PERMISSION_MODES
):
msg = (
f"invalid permission_mode={self.permission_mode!r}; "
f"expected one of {sorted(_VALID_PERMISSION_MODES)}"
)
raise ValueError(msg)
rows, cols = self.dimensions
if rows <= 0 or cols <= 0:
msg = f"dimensions must be positive, got {self.dimensions!r}"
raise ValueError(msg)
if self.resume_session_id is not None and self.session_id is not None:
msg = "set either session_id or resume_session_id, not both"
raise ValueError(msg)
def build_argv(opts: PtyProcessOptions, session_id: str) -> list[str]:
"""Materialize CLI argv for `claude` interactive mode.
Subscription-mode TUI must NOT pass `--print`, `--output-format`, or
`--input-format` — they either force headless mode or are silently
ignored by interactive claude.
When `opts.resume_session_id` is set, emit `--resume <id>` instead of
`--session-id <id>` — claude rejects the two flags together unless
`--fork-session` is also passed, which would branch the session into a
new JSONL.
"""
if opts.resume_session_id is not None:
argv: list[str] = [opts.executable, "--resume", opts.resume_session_id]
else:
argv = [opts.executable, "--session-id", session_id]
if opts.dangerously_skip_permissions:
argv.append("--dangerously-skip-permissions")
else:
argv += ["--permission-mode", opts.permission_mode]
if opts.model:
argv += ["--model", opts.model]
if opts.system_prompt is not None:
argv += ["--system-prompt", opts.system_prompt]
if opts.append_system_prompt is not None:
argv += ["--append-system-prompt", opts.append_system_prompt]
if opts.allowed_tools:
argv += ["--allowedTools", ",".join(opts.allowed_tools)]
if opts.disallowed_tools:
argv += ["--disallowedTools", ",".join(opts.disallowed_tools)]
for cfg in opts.mcp_config:
argv += ["--mcp-config", cfg]
if opts.add_dir:
argv += ["--add-dir", *opts.add_dir]
if opts.effort:
argv += ["--effort", opts.effort]
if opts.settings:
argv += ["--settings", opts.settings]
argv.extend(opts.extra_args)
return argv
def build_env(
opts: PtyProcessOptions, base: Mapping[str, str] | None = None
) -> dict[str, str]:
"""Build the env for the subprocess.
Starts from `base` (defaults to `os.environ`), optionally strips the
three Anthropic provider env vars so the CLI uses OAuth/subscription
auth, and sets `TERM` / `NO_COLOR` for a predictable TUI surface.
"""
env = dict(base if base is not None else os.environ)
if not opts.preserve_provider_env:
for name in _PROVIDER_ENV_VARS:
env.pop(name, None)
env["TERM"] = opts.term
env["NO_COLOR"] = "1"
env.update(opts.extra_env)
return env
class PtyClaudeProcess:
"""A live `claude` interactive process under a PTY.
Public lifecycle:
proc = PtyClaudeProcess(opts)
await proc.start()
await proc.write("hi")
await proc.terminate() # SIGTERM, then SIGKILL after grace
A background daemon thread drains PTY output continuously to prevent
the child blocking on a full pty buffer. Captured output is available
via `captured_output()` (capped at ~1MB) and optionally streamed to
`on_pty_output`. Higher layers should ignore PTY output and read
JSONL.
"""
def __init__(
self,
options: PtyProcessOptions,
*,
on_pty_output: PtyOutputCallback | None = None,
output_buffer_cap: int = _DEFAULT_OUTPUT_BUFFER_CAP,
) -> None:
self._opts = options
self._on_output = on_pty_output
self._output_buffer_cap = output_buffer_cap
if options.resume_session_id is not None:
self._session_id = options.resume_session_id
else:
self._session_id = options.session_id or str(uuid.uuid4())
self._argv: list[str] = build_argv(options, self._session_id)
self._env: dict[str, str] = build_env(options)
self._pty: PtyProcess | None = None
self._drain_thread: threading.Thread | None = None
self._drain_stop = threading.Event()
self._output_lock = threading.Lock()
self._output_buffer = bytearray()
@property
def session_id(self) -> str:
return self._session_id
@property
def argv(self) -> list[str]:
return list(self._argv)
@property
def env(self) -> dict[str, str]:
return dict(self._env)
@property
def cwd(self) -> str:
return os.fspath(self._opts.cwd)
@property
def pid(self) -> int | None:
return self._pty.pid if self._pty is not None else None
def is_alive(self) -> bool:
return self._pty is not None and self._pty.isalive()
def captured_output(self) -> bytes:
"""Snapshot of the rolling PTY output buffer (capped, oldest dropped)."""
with self._output_lock:
return bytes(self._output_buffer)
async def start(self) -> None:
"""Spawn the child synchronously on the main thread.
ptyprocess uses `pty.openpty()` + `os.forkpty()`; forking from a
worker thread on macOS can leave the child in an unstable state.
The fork itself is microsecond-scale.
"""
if self._pty is not None:
msg = "PtyClaudeProcess.start() called twice"
raise RuntimeError(msg)
try:
self._pty = PtyProcess.spawn(
self._argv,
cwd=self.cwd,
env=self._env,
echo=False,
dimensions=self._opts.dimensions,
)
except FileNotFoundError as exc:
raise CLINotFoundError(executable=self._opts.executable) from exc
self._drain_stop.clear()
self._drain_thread = threading.Thread(
target=self._drain_loop,
name=f"pty-drain-{self._session_id[:8]}",
daemon=True,
)
self._drain_thread.start()
def _drain_loop(self) -> None:
pty = self._pty
if pty is None:
return
fd = pty.fileno()
while not self._drain_stop.is_set():
try:
ready, _, _ = select.select([fd], [], [], 0.1)
except (OSError, ValueError):
break
if not ready:
continue
try:
data = os.read(fd, _DEFAULT_DRAIN_CHUNK)
except OSError as exc:
if exc.errno in (errno.EIO, errno.EBADF):
break
if self._drain_stop.wait(0.05):
break
continue
if not data:
break
cb = self._on_output
if cb is not None:
with contextlib.suppress(Exception):
cb(data)
with self._output_lock:
self._output_buffer.extend(data)
overflow = len(self._output_buffer) - self._output_buffer_cap
if overflow > 0:
del self._output_buffer[:overflow]
async def write(self, data: str | bytes, *, newline: bool = True) -> int:
r"""Write bytes to the child's stdin.
Strings are UTF-8 encoded. When `newline=True` (the default) the
payload is wrapped in xterm bracketed-paste markers (`ESC [ 200 ~`
... `ESC [ 201 ~`) and followed by a carriage return — that is the
Enter-key keycode interactive `claude` expects.
Without the bracketed-paste framing, the TUI heuristically treats
bursts longer than ~63 bytes as a paste and *buffers* them in the
input box without submitting; the trailing `\r` is then absorbed
as a newline inside the box rather than acting as Submit.
Bracketed paste makes the framing explicit for any length payload.
Callers that need raw byte streaming (e.g. arrow keys, individual
keypresses) pass `newline=False` and write the framing themselves.
"""
if self._pty is None:
msg = "PtyClaudeProcess not started"
raise RuntimeError(msg)
payload = data.encode("utf-8") if isinstance(data, str) else bytes(data)
if newline:
if payload.endswith(b"\r"):
payload = payload[:-1]
payload = b"\x1b[200~" + payload + b"\x1b[201~\r"
pty = self._pty
return await asyncio.to_thread(pty.write, payload)
async def send_control(self, char: str) -> None:
"""Send a control character (e.g. 'c' for Ctrl-C, 'd' for Ctrl-D)."""
if self._pty is None:
msg = "PtyClaudeProcess not started"
raise RuntimeError(msg)
pty = self._pty
await asyncio.to_thread(pty.sendcontrol, char)
async def wait(self) -> int | None:
"""Block until the child exits; return its exit status."""
if self._pty is None:
return None
pty = self._pty
return await asyncio.to_thread(pty.wait)
async def terminate(self, *, grace: float = 5.0) -> int | None:
"""SIGTERM → wait up to `grace` seconds → SIGKILL ladder."""
if self._pty is None:
return None
pty = self._pty
if pty.isalive():
with contextlib.suppress(OSError):
pty.kill(signal.SIGTERM)
deadline = asyncio.get_running_loop().time() + grace
while pty.isalive() and asyncio.get_running_loop().time() < deadline:
await asyncio.sleep(0.05)
if pty.isalive():
with contextlib.suppress(OSError):
pty.kill(signal.SIGKILL)
return await self._reap()
async def kill(self) -> int | None:
"""Immediate SIGKILL with no grace period."""
if self._pty is None:
return None
pty = self._pty
if pty.isalive():
with contextlib.suppress(OSError):
pty.kill(signal.SIGKILL)
return await self._reap()
async def _reap(self) -> int | None:
pty = self._pty
if pty is None:
return None
exit_status = await asyncio.to_thread(pty.wait)
self._drain_stop.set()
thread = self._drain_thread
if thread is not None and thread.is_alive():
await asyncio.to_thread(thread.join, 1.0)
with contextlib.suppress(OSError):
pty.close(force=True)
return exit_status
async def aclose(self) -> int | None:
"""Idempotent shutdown — terminate if alive, otherwise reap."""
if self._pty is None:
return None
if self._pty.isalive():
return await self.terminate()
return await self._reap()
async def __aenter__(self) -> Self:
await self.start()
return self
async def __aexit__(self, _exc_type: object, _exc: object, _tb: object) -> None:
await self.aclose()
__all__: Iterable[str] = (
"PtyClaudeProcess",
"PtyOutputCallback",
"PtyProcessOptions",
"build_argv",
"build_env",
)
View File
+320
View File
@@ -0,0 +1,320 @@
"""Per-turn orchestration.
`TurnManager` glues the lower layers into the per-turn loop: send a user
prompt into the PTY, tail the JSONL until the model finishes, hand back
typed events along the way, and synthesize a `ResultMessage` at the turn
boundary.
Turn-end detection:
- An `assistant` record with `stop_reason ∈ {end_turn, max_tokens,
stop_sequence, refusal}` closes the turn. By default we return as soon
as this terminal record is yielded.
- `stop_reason="tool_use"` does **not** close the turn. In TUI mode claude
orchestrates the tool-use loop itself; we keep streaming and wait for
the next terminal `assistant`.
- Setting `wait_for_turn_duration=True` adds one extra wait: after the
terminal assistant we keep iterating until a
`system.subtype=turn_duration` heartbeat arrives.
JSONL has no native `result` record, so we synthesize one from the
terminal assistant's `usage` plus the `durationMs` carried by the
`turn_duration` heartbeat (when available).
"""
from __future__ import annotations
import asyncio
import contextlib
from collections.abc import AsyncIterator, Callable, Iterable
from typing import TYPE_CHECKING, Any, Self
from claude_code_api.errors import (
BackendError,
MessageParseError,
ProcessError,
SessionError,
classify_pty_failure,
)
from claude_code_api.events import AssistantMessage, Event, ResultMessage, SystemMessage
from claude_code_api.normalizer import normalize
from claude_code_api.watcher import JsonlRecord, JsonlWatcher
if TYPE_CHECKING:
from claude_code_api.pty import PtyClaudeProcess
_TERMINAL_STOP_REASONS: frozenset[str] = frozenset(
{"end_turn", "max_tokens", "stop_sequence", "refusal"}
)
_DEFAULT_FILE_WAIT_TIMEOUT = 30.0
_DEFAULT_TURN_DURATION_TIMEOUT = 5.0
_DEFAULT_STARTUP_DELAY = 1.0
ParseErrorCallback = Callable[[MessageParseError, JsonlRecord], None]
class TurnManager:
"""Drive one turn at a time over a long-lived PTY + JSONL pair.
The manager does NOT own the lifetime of the watcher's start offset —
`JsonlWatcher` tracks that internally, so re-using the same watcher
across turns naturally picks up where the previous turn left off. The
manager DOES own the PTY lifecycle in the common case (`async with` /
`aclose()`); callers that want to share a PTY across managers can
construct with `owns_pty=False`.
Usage:
pty = PtyClaudeProcess(opts)
path = resolve_jsonl_path(pty.cwd, pty.session_id)
watcher = JsonlWatcher(path)
async with TurnManager(pty, watcher) as tm:
async for event in tm.send_user_message("say hi"):
print(event)
"""
def __init__(
self,
pty: PtyClaudeProcess,
watcher: JsonlWatcher,
*,
wait_for_turn_duration: bool = False,
include_meta_user: bool = False,
file_wait_timeout: float | None = _DEFAULT_FILE_WAIT_TIMEOUT,
turn_duration_timeout: float | None = _DEFAULT_TURN_DURATION_TIMEOUT,
startup_delay: float = _DEFAULT_STARTUP_DELAY,
on_parse_error: ParseErrorCallback | None = None,
owns_pty: bool = True,
) -> None:
if file_wait_timeout is not None and file_wait_timeout < 0:
msg = f"file_wait_timeout must be non-negative, got {file_wait_timeout!r}"
raise ValueError(msg)
if turn_duration_timeout is not None and turn_duration_timeout < 0:
msg = f"turn_duration_timeout must be non-negative, got {turn_duration_timeout!r}" # noqa: E501
raise ValueError(msg)
if startup_delay < 0:
msg = f"startup_delay must be non-negative, got {startup_delay!r}"
raise ValueError(msg)
self._pty = pty
self._watcher = watcher
self._wait_for_turn_duration = wait_for_turn_duration
self._include_meta_user = include_meta_user
self._file_wait_timeout = file_wait_timeout
self._turn_duration_timeout = turn_duration_timeout
self._startup_delay = startup_delay
self._on_parse_error = on_parse_error
self._owns_pty = owns_pty
self._started = False
self._turn_count = 0
self._turn_in_progress = False
@property
def pty(self) -> PtyClaudeProcess:
return self._pty
@property
def watcher(self) -> JsonlWatcher:
return self._watcher
@property
def turn_count(self) -> int:
"""Number of turns completed (or in progress) so far."""
return self._turn_count
async def start(self) -> None:
"""Spawn the PTY and let claude's TUI settle. Idempotent."""
if self._started:
return
await self._pty.start()
if self._startup_delay > 0:
await asyncio.sleep(self._startup_delay)
self._started = True
async def send_user_message(self, text: str) -> AsyncIterator[Event]:
"""Send `text` as a user prompt and stream events until turn-end.
Async generator: yields every typed event normalized from new JSONL
records (user/assistant/system) and, as its final value, a
synthesized `ResultMessage`.
"""
if not self._started:
msg = "TurnManager.send_user_message() called before start()"
raise RuntimeError(msg)
if self._turn_in_progress:
msg = "send_user_message() called while a turn is in progress"
raise RuntimeError(msg)
self._turn_in_progress = True
self._turn_count += 1
try:
try:
await self._pty.write(text)
except OSError as exc:
raise self._classify_pty_failure(
fallback_message="claude process not accepting input"
) from exc
if not self._watcher.path.exists():
try:
await self._watcher.wait_for_file(timeout=self._file_wait_timeout)
except TimeoutError as exc:
raise self._classify_pty_failure(
fallback_cls=SessionError,
fallback_message=(
f"JSONL file did not appear within "
f"{self._file_wait_timeout}s: {self._watcher.path}"
),
) from exc
terminal_assistant: AssistantMessage | None = None
terminal_seen_at: float | None = None
poll = self._watcher.poll_interval
loop = asyncio.get_running_loop()
while True:
records = await self._watcher.read_once()
if not records:
if (
terminal_assistant is not None
and terminal_seen_at is not None
and self._turn_duration_timeout is not None
and loop.time() - terminal_seen_at > self._turn_duration_timeout
):
yield self._synthesize_result(terminal_assistant, None)
return
if terminal_assistant is None and not self._pty_is_alive():
raise self._classify_pty_failure(
fallback_message=(
"claude process exited before a terminal "
"assistant message was emitted"
)
)
await asyncio.sleep(poll)
continue
for rec in records:
try:
event = normalize(
rec, include_meta_user=self._include_meta_user
)
except MessageParseError as exc:
if self._on_parse_error is not None:
with contextlib.suppress(Exception):
self._on_parse_error(exc, rec)
continue
if event is None:
continue
yield event
if isinstance(event, AssistantMessage):
if event.stop_reason in _TERMINAL_STOP_REASONS:
terminal_assistant = event
terminal_seen_at = loop.time()
if not self._wait_for_turn_duration:
yield self._synthesize_result(terminal_assistant, None)
return
elif (
isinstance(event, SystemMessage)
and event.subtype == "turn_duration"
and terminal_assistant is not None
):
duration_ms = _extract_duration_ms(event.data)
yield self._synthesize_result(terminal_assistant, duration_ms)
return
finally:
self._turn_in_progress = False
def _pty_is_alive(self) -> bool:
"""Best-effort liveness check. Test fakes may lack `is_alive()`."""
is_alive = getattr(self._pty, "is_alive", None)
if is_alive is None:
return True
try:
return bool(is_alive())
except Exception:
return True
def _captured_pty_output(self) -> bytes:
"""Best-effort snapshot of the PTY drain buffer."""
captured = getattr(self._pty, "captured_output", None)
if captured is None:
return b""
try:
value = captured()
except Exception:
return b""
if isinstance(value, bytearray):
return bytes(value)
return value if isinstance(value, bytes) else b""
def _classify_pty_failure(
self,
*,
fallback_cls: type[BackendError] = ProcessError,
fallback_message: str = "claude process failed",
) -> BackendError:
"""Build the typed exception that fits the current PTY state."""
captured = self._captured_pty_output()
cls = classify_pty_failure(captured) or fallback_cls
if issubclass(cls, ProcessError):
exit_code = getattr(self._pty, "_pty", None)
real_exit: int | None = None
inner = getattr(exit_code, "exitstatus", None)
if isinstance(inner, int):
real_exit = inner
stderr_text = (
captured.decode("utf-8", errors="replace") if captured else None
)
return cls(fallback_message, exit_code=real_exit, stderr=stderr_text)
return cls(fallback_message)
def _synthesize_result(
self, assistant: AssistantMessage, duration_ms: int | None
) -> ResultMessage:
return ResultMessage(
subtype="success",
duration_ms=duration_ms if duration_ms is not None else 0,
num_turns=self._turn_count,
session_id=self._pty.session_id,
is_error=False,
stop_reason=assistant.stop_reason,
usage=assistant.usage,
)
async def aclose(self) -> None:
"""Shut down — terminate the PTY if we own it."""
if self._owns_pty:
await self._pty.aclose()
async def __aenter__(self) -> Self:
await self.start()
return self
async def __aexit__(self, _exc_type: object, _exc: object, _tb: object) -> None:
await self.aclose()
def _extract_duration_ms(data: dict[str, Any]) -> int | None:
"""Pull a turn-duration value out of a `system.subtype=turn_duration` record."""
for key in ("durationMs", "duration_ms"):
value = data.get(key)
if isinstance(value, int):
return value
if isinstance(value, float):
return int(value)
nested = data.get("data")
if isinstance(nested, dict):
for key in ("durationMs", "duration_ms"):
value = nested.get(key)
if isinstance(value, int):
return value
if isinstance(value, float):
return int(value)
return None
__all__: Iterable[str] = ("ParseErrorCallback", "TurnManager")
+179
View File
@@ -0,0 +1,179 @@
"""Polling tail of a `claude` JSONL session file.
`JsonlWatcher` watches a single `<session_id>.jsonl` and yields each appended
record as a parsed `dict`. It is intentionally dumb about semantics — turning
records into Anthropic events lives in `normalizer.py`, orchestrating turns
lives in `turn.py`.
Guarantees:
- the file may not exist yet when `tail()` starts — the watcher waits;
- bytes are read incrementally from a tracked offset, so reopening on every
poll is cheap (no full re-scan);
- a record split across two polls is held in an internal byte buffer until
the trailing newline arrives;
- a malformed JSON line is delegated to an optional callback and otherwise
dropped — one bad record must not stall the stream.
"""
from __future__ import annotations
import asyncio
import contextlib
import errno
import json
from collections.abc import AsyncIterator, Callable, Iterable
from pathlib import Path
from typing import TYPE_CHECKING, Any
if TYPE_CHECKING:
import os
JsonlRecord = dict[str, Any]
ParseErrorCallback = Callable[[bytes, json.JSONDecodeError], None]
_DEFAULT_POLL_INTERVAL = 0.1
_DEFAULT_READ_CHUNK = 65536
class JsonlWatcher:
"""Tail one JSONL file. One watcher per session.
Usage:
watcher = JsonlWatcher(path)
async for record in watcher.tail():
handle(record)
`tail()` is an unbounded async iterator — it never returns on its own.
Stop it by cancelling the consuming task (or by breaking out of the
`async for`, which propagates `GeneratorExit` and lets the watcher
clean up its internal buffer).
"""
def __init__(
self,
path: str | os.PathLike[str],
*,
poll_interval: float = _DEFAULT_POLL_INTERVAL,
start_offset: int = 0,
on_parse_error: ParseErrorCallback | None = None,
read_chunk: int = _DEFAULT_READ_CHUNK,
) -> None:
if poll_interval <= 0:
msg = f"poll_interval must be positive, got {poll_interval!r}"
raise ValueError(msg)
if start_offset < 0:
msg = f"start_offset must be non-negative, got {start_offset!r}"
raise ValueError(msg)
if read_chunk <= 0:
msg = f"read_chunk must be positive, got {read_chunk!r}"
raise ValueError(msg)
self._path = Path(path)
self._poll_interval = poll_interval
self._on_parse_error = on_parse_error
self._read_chunk = read_chunk
self._offset = start_offset
self._line_buffer = bytearray()
@property
def path(self) -> Path:
return self._path
@property
def offset(self) -> int:
"""Byte offset at which the next read will resume."""
return self._offset
@property
def poll_interval(self) -> float:
return self._poll_interval
async def wait_for_file(self, *, timeout: float | None = None) -> None:
"""Block until `path` exists.
Polls at the configured interval. Raises `TimeoutError` if the file
does not appear before `timeout` seconds elapse. `timeout=None`
means wait forever (callers should rely on task cancellation).
"""
if timeout is not None and timeout < 0:
msg = f"timeout must be non-negative, got {timeout!r}"
raise ValueError(msg)
loop = asyncio.get_running_loop()
deadline = None if timeout is None else loop.time() + timeout
while not self._path.exists():
if deadline is not None and loop.time() >= deadline:
msg = f"JSONL file did not appear within {timeout}s: {self._path}"
raise TimeoutError(msg)
await asyncio.sleep(self._poll_interval)
async def read_once(self) -> list[JsonlRecord]:
"""Single non-blocking pass: drain all currently available records.
Returns `[]` when the file does not exist or no complete record has
been appended since the last call. Updates internal offset and the
partial-line buffer either way.
"""
return await asyncio.to_thread(self._read_available)
async def tail(self) -> AsyncIterator[JsonlRecord]:
"""Yield records as they appear. Runs until cancelled."""
while True:
if not self._path.exists():
await asyncio.sleep(self._poll_interval)
continue
records = await asyncio.to_thread(self._read_available)
if not records:
await asyncio.sleep(self._poll_interval)
continue
for rec in records:
yield rec
def _read_available(self) -> list[JsonlRecord]:
try:
stat = self._path.stat()
except FileNotFoundError:
return []
size = stat.st_size
if size < self._offset:
self._offset = 0
self._line_buffer.clear()
if size == self._offset:
return []
records: list[JsonlRecord] = []
try:
with self._path.open("rb") as f:
f.seek(self._offset)
while True:
chunk = f.read(self._read_chunk)
if not chunk:
break
self._offset += len(chunk)
self._line_buffer.extend(chunk)
self._drain_buffer_into(records)
except OSError as exc:
if exc.errno == errno.ENOENT:
return records
raise
return records
def _drain_buffer_into(self, records: list[JsonlRecord]) -> None:
while True:
nl = self._line_buffer.find(b"\n")
if nl == -1:
return
line = bytes(self._line_buffer[:nl])
del self._line_buffer[: nl + 1]
if not line.strip():
continue
try:
records.append(json.loads(line))
except json.JSONDecodeError as exc:
cb = self._on_parse_error
if cb is not None:
with contextlib.suppress(Exception):
cb(line, exc)
__all__: Iterable[str] = ("JsonlRecord", "JsonlWatcher", "ParseErrorCallback")