feat: vibed out some slop over here also
This commit is contained in:
@@ -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",
|
||||
]
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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"]
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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"]
|
||||
@@ -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",
|
||||
)
|
||||
@@ -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",
|
||||
)
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
Reference in New Issue
Block a user