From d6e7ac941ffa3d96786e823b0ae1696d736d6423 Mon Sep 17 00:00:00 2001 From: Qing Wang Date: Tue, 10 Mar 2026 10:40:35 -0700 Subject: [PATCH 1/9] feat(sdk): expand SDKSessionInfo with tag/agent_name + add get_session_info Ports TS SDK changes from qing/sdk-session-info-expand (PR #21659). - Add tag and agent_name fields to SDKSessionInfo dataclass - Refactor inline field extraction into _parse_session_info_from_lite helper for reuse between list_sessions and get_session_info - Extract tag from tail via last-occurrence scan (empty string = cleared) - Extract agent_name scoped to {type:'agent-name'} entries only; bare tail-scan would pick up per-message agentName from swarm sessions (handles both compact and spaced JSON variants) - Add get_session_info(session_id, directory=None) for single-session metadata lookup without O(n) directory scan; includes worktree fallback when directory is provided (matches get_session_messages semantics) - Export get_session_info from package root Tests: 75 passed. Ruff + mypy clean. --- src/claude_agent_sdk/__init__.py | 3 +- src/claude_agent_sdk/_internal/sessions.py | 199 +++++++++++--- src/claude_agent_sdk/types.py | 4 + tests/test_sessions.py | 298 +++++++++++++++++++++ 4 files changed, 464 insertions(+), 40 deletions(-) diff --git a/src/claude_agent_sdk/__init__.py b/src/claude_agent_sdk/__init__.py index 31bebd09..c31dec1f 100644 --- a/src/claude_agent_sdk/__init__.py +++ b/src/claude_agent_sdk/__init__.py @@ -13,7 +13,7 @@ CLINotFoundError, ProcessError, ) -from ._internal.sessions import get_session_messages, list_sessions +from ._internal.sessions import get_session_info, get_session_messages, list_sessions from ._internal.transport import Transport from ._version import __version__ from .client import ClaudeSDKClient @@ -407,6 +407,7 @@ async def call_tool(name: str, arguments: dict[str, Any]) -> Any: "SdkPluginConfig", # Session listing "list_sessions", + "get_session_info", "get_session_messages", "SDKSessionInfo", "SessionMessage", diff --git a/src/claude_agent_sdk/_internal/sessions.py b/src/claude_agent_sdk/_internal/sessions.py index a49d2c63..f2c1f4ad 100644 --- a/src/claude_agent_sdk/_internal/sessions.py +++ b/src/claude_agent_sdk/_internal/sessions.py @@ -395,6 +395,80 @@ def _get_worktree_paths(cwd: str) -> list[str]: return paths +# --------------------------------------------------------------------------- +# Field extraction — shared by list_sessions and get_session_info +# --------------------------------------------------------------------------- + + +def _parse_session_info_from_lite( + session_id: str, + lite: _LiteSessionFile, + project_path: str | None = None, +) -> SDKSessionInfo | None: + """Parses SDKSessionInfo fields from a lite session read (head/tail/stat). + + Returns None for sidechain sessions or metadata-only sessions with no + extractable summary. + + Exported for reuse by get_session_info. + """ + head, tail, mtime, size = lite.head, lite.tail, lite.mtime, lite.size + + # Check first line for sidechain sessions + first_newline = head.find("\n") + first_line = head[:first_newline] if first_newline >= 0 else head + if '"isSidechain":true' in first_line or '"isSidechain": true' in first_line: + return None + + custom_title = _extract_last_json_string_field(tail, "customTitle") or None + first_prompt = _extract_first_prompt_from_head(head) or None + summary = ( + custom_title or _extract_last_json_string_field(tail, "summary") or first_prompt + ) + + # Skip metadata-only sessions (no title, no summary, no prompt) + if not summary: + return None + + git_branch = ( + _extract_last_json_string_field(tail, "gitBranch") + or _extract_json_string_field(head, "gitBranch") + or None + ) + session_cwd = _extract_json_string_field(head, "cwd") or project_path or None + tag = _extract_last_json_string_field(tail, "tag") or None + + # agentName requires type-scoped extraction: TranscriptMessage has a + # per-message agentName field (written on every message in swarm + # sessions), so a bare tail-scan for '"agentName":' would pick up the + # per-message value instead of the session-level {type:'agent-name'} + # entry. Scope to that entry type. + agent_name: str | None = None + # CLI writes compact JSON (no space); json.dumps default adds a space. + # Handle both variants and pick the LAST occurrence. + idx_compact = tail.rfind('"type":"agent-name"') + idx_spaced = tail.rfind('"type": "agent-name"') + agent_name_type_idx = max(idx_compact, idx_spaced) + if agent_name_type_idx >= 0: + line_start = tail.rfind("\n", 0, agent_name_type_idx) + 1 + line_end = tail.find("\n", agent_name_type_idx) + line = tail[line_start : line_end if line_end >= 0 else len(tail)] + agent_name = _extract_json_string_field(line, "agentName") or None + + return SDKSessionInfo( + session_id=session_id, + summary=summary, + last_modified=mtime, + file_size=size, + custom_title=custom_title, + first_prompt=first_prompt, + git_branch=git_branch, + cwd=session_cwd, + tag=tag, + agent_name=agent_name, + ) + + # --------------------------------------------------------------------------- # Core implementation # --------------------------------------------------------------------------- @@ -427,45 +501,9 @@ def _read_sessions_from_dir( if lite is None: continue - head, tail, mtime, size = lite.head, lite.tail, lite.mtime, lite.size - - # Check first line for sidechain sessions - first_newline = head.find("\n") - first_line = head[:first_newline] if first_newline >= 0 else head - if '"isSidechain":true' in first_line or '"isSidechain": true' in first_line: - continue - - custom_title = _extract_last_json_string_field(tail, "customTitle") or None - first_prompt = _extract_first_prompt_from_head(head) or None - summary = ( - custom_title - or _extract_last_json_string_field(tail, "summary") - or first_prompt - ) - - # Skip metadata-only sessions (no title, no summary, no prompt) - if not summary: - continue - - git_branch = ( - _extract_last_json_string_field(tail, "gitBranch") - or _extract_json_string_field(head, "gitBranch") - or None - ) - session_cwd = _extract_json_string_field(head, "cwd") or project_path or None - - results.append( - SDKSessionInfo( - session_id=session_id, - summary=summary, - last_modified=mtime, - file_size=size, - custom_title=custom_title, - first_prompt=first_prompt, - git_branch=git_branch, - cwd=session_cwd, - ) - ) + info = _parse_session_info_from_lite(session_id, lite, project_path) + if info is not None: + results.append(info) return results @@ -634,6 +672,89 @@ def list_sessions( return _list_all_sessions(limit) +# --------------------------------------------------------------------------- +# get_session_info — single-session metadata lookup +# --------------------------------------------------------------------------- + + +def get_session_info( + session_id: str, + directory: str | None = None, +) -> SDKSessionInfo | None: + """Reads metadata for a single session by ID. + + Wraps ``_read_session_lite`` for one file — no O(n) directory scan. + Directory resolution matches ``get_session_messages``: ``directory`` is + the project path; when omitted, all project directories are searched for + the session file. + + Args: + session_id: UUID of the session to look up. + directory: Project directory path (same semantics as + ``list_sessions(directory=...)``). When omitted, all project + directories are searched for the session file. + + Returns: + ``SDKSessionInfo`` for the session, or ``None`` if the session file + is not found, is a sidechain session, or has no extractable summary. + + Example: + Look up a session in a specific project:: + + info = get_session_info( + "550e8400-e29b-41d4-a716-446655440000", + directory="/path/to/project", + ) + if info: + print(info.summary) + + Search all projects for a session:: + + info = get_session_info("550e8400-e29b-41d4-a716-446655440000") + """ + uuid = _validate_uuid(session_id) + if not uuid: + return None + file_name = f"{uuid}.jsonl" + + if directory: + canonical = _canonicalize_path(directory) + project_dir = _find_project_dir(canonical) + if project_dir is not None: + lite = _read_session_lite(project_dir / file_name) + if lite is not None: + return _parse_session_info_from_lite(uuid, lite, canonical) + + # Worktree fallback — matches get_session_messages semantics. + # Sessions may live under a different worktree root. + try: + worktree_paths = _get_worktree_paths(canonical) + except Exception: + worktree_paths = [] + for wt in worktree_paths: + if wt == canonical: + continue + wt_project_dir = _find_project_dir(wt) + if wt_project_dir is not None: + lite = _read_session_lite(wt_project_dir / file_name) + if lite is not None: + return _parse_session_info_from_lite(uuid, lite, wt) + + return None + + # No directory — search all project directories for the session file. + projects_dir = _get_projects_dir() + try: + dirents = list(projects_dir.iterdir()) + except OSError: + return None + for entry in dirents: + lite = _read_session_lite(entry / file_name) + if lite is not None: + return _parse_session_info_from_lite(uuid, lite) + return None + + # --------------------------------------------------------------------------- # get_session_messages — full transcript reconstruction # --------------------------------------------------------------------------- diff --git a/src/claude_agent_sdk/types.py b/src/claude_agent_sdk/types.py index bfe632e5..5a06e140 100644 --- a/src/claude_agent_sdk/types.py +++ b/src/claude_agent_sdk/types.py @@ -920,6 +920,8 @@ class SDKSessionInfo: first_prompt: First meaningful user prompt in the session. git_branch: Git branch at the end of the session. cwd: Working directory for the session. + tag: User-set session tag. + agent_name: Name of the agent that ran this session. """ session_id: str @@ -930,6 +932,8 @@ class SDKSessionInfo: first_prompt: str | None = None git_branch: str | None = None cwd: str | None = None + tag: str | None = None + agent_name: str | None = None @dataclass diff --git a/tests/test_sessions.py b/tests/test_sessions.py index 39cb7755..aa0710d6 100644 --- a/tests/test_sessions.py +++ b/tests/test_sessions.py @@ -12,6 +12,7 @@ from claude_agent_sdk import ( SDKSessionInfo, SessionMessage, + get_session_info, get_session_messages, list_sessions, ) @@ -20,6 +21,8 @@ _extract_first_prompt_from_head, _extract_json_string_field, _extract_last_json_string_field, + _parse_session_info_from_lite, + _read_session_lite, _sanitize_path, _simple_hash, _validate_uuid, @@ -1078,3 +1081,298 @@ def test_creation(self): assert msg.session_id == "sess" assert msg.message == {"role": "user", "content": "hi"} assert msg.parent_tool_use_id is None + + +# --------------------------------------------------------------------------- +# Tag and agent_name extraction tests (Branch A additions) +# --------------------------------------------------------------------------- + + +class TestTagAndAgentNameExtraction: + """Tests for tag and agent_name field extraction in SDKSessionInfo.""" + + def test_tag_extracted_from_tail(self, claude_config_dir: Path, tmp_path: Path): + """Tag is extracted from the last {type:'tag'} entry in the tail.""" + project_path = str(tmp_path / "proj") + Path(project_path).mkdir(parents=True) + project_dir = _make_project_dir( + claude_config_dir, os.path.realpath(project_path) + ) + sid = str(uuid.uuid4()) + file_path = project_dir / f"{sid}.jsonl" + lines = [ + json.dumps({"type": "user", "message": {"content": "hello"}}), + json.dumps({"type": "tag", "tag": "my-tag", "sessionId": sid}), + ] + file_path.write_text("\n".join(lines) + "\n") + + sessions = list_sessions(directory=project_path, include_worktrees=False) + assert len(sessions) == 1 + assert sessions[0].tag == "my-tag" + + def test_tag_last_wins(self, claude_config_dir: Path, tmp_path: Path): + """When multiple tag entries exist, the last one wins.""" + project_path = str(tmp_path / "proj") + Path(project_path).mkdir(parents=True) + project_dir = _make_project_dir( + claude_config_dir, os.path.realpath(project_path) + ) + sid = str(uuid.uuid4()) + file_path = project_dir / f"{sid}.jsonl" + lines = [ + json.dumps({"type": "user", "message": {"content": "hello"}}), + json.dumps({"type": "tag", "tag": "first-tag", "sessionId": sid}), + json.dumps({"type": "tag", "tag": "second-tag", "sessionId": sid}), + ] + file_path.write_text("\n".join(lines) + "\n") + + sessions = list_sessions(directory=project_path, include_worktrees=False) + assert len(sessions) == 1 + assert sessions[0].tag == "second-tag" + + def test_tag_empty_string_is_none(self, claude_config_dir: Path, tmp_path: Path): + """Empty-string tag (clear marker) resolves to None via 'or None'.""" + project_path = str(tmp_path / "proj") + Path(project_path).mkdir(parents=True) + project_dir = _make_project_dir( + claude_config_dir, os.path.realpath(project_path) + ) + sid = str(uuid.uuid4()) + file_path = project_dir / f"{sid}.jsonl" + lines = [ + json.dumps({"type": "user", "message": {"content": "hello"}}), + json.dumps({"type": "tag", "tag": "old-tag", "sessionId": sid}), + json.dumps({"type": "tag", "tag": "", "sessionId": sid}), + ] + file_path.write_text("\n".join(lines) + "\n") + + sessions = list_sessions(directory=project_path, include_worktrees=False) + assert len(sessions) == 1 + assert sessions[0].tag is None + + def test_tag_absent(self, claude_config_dir: Path, tmp_path: Path): + """Sessions without a tag entry have tag=None.""" + project_path = str(tmp_path / "proj") + Path(project_path).mkdir(parents=True) + project_dir = _make_project_dir( + claude_config_dir, os.path.realpath(project_path) + ) + _make_session_file(project_dir, first_prompt="hello") + + sessions = list_sessions(directory=project_path, include_worktrees=False) + assert len(sessions) == 1 + assert sessions[0].tag is None + + def test_agent_name_scoped_to_type_entry( + self, claude_config_dir: Path, tmp_path: Path + ): + """agent_name is extracted only from {type:'agent-name'} entries. + + Per-message agentName fields (from swarm sessions) must be ignored. + """ + project_path = str(tmp_path / "proj") + Path(project_path).mkdir(parents=True) + project_dir = _make_project_dir( + claude_config_dir, os.path.realpath(project_path) + ) + sid = str(uuid.uuid4()) + file_path = project_dir / f"{sid}.jsonl" + # Swarm session: every message has per-message agentName which must + # NOT be picked up. Only the {type:'agent-name'} entry counts. + lines = [ + json.dumps( + { + "type": "user", + "message": {"content": "hello"}, + "agentName": "worker-1", + } + ), + json.dumps( + { + "type": "agent-name", + "agentName": "coordinator", + "sessionId": sid, + } + ), + json.dumps( + { + "type": "assistant", + "message": {"content": "hi"}, + "agentName": "worker-2", + } + ), + ] + file_path.write_text("\n".join(lines) + "\n") + + sessions = list_sessions(directory=project_path, include_worktrees=False) + assert len(sessions) == 1 + assert sessions[0].agent_name == "coordinator" + + def test_agent_name_absent_without_type_entry( + self, claude_config_dir: Path, tmp_path: Path + ): + """Sessions with per-message agentName but no type entry have agent_name=None.""" + project_path = str(tmp_path / "proj") + Path(project_path).mkdir(parents=True) + project_dir = _make_project_dir( + claude_config_dir, os.path.realpath(project_path) + ) + sid = str(uuid.uuid4()) + file_path = project_dir / f"{sid}.jsonl" + lines = [ + json.dumps( + { + "type": "user", + "message": {"content": "hello"}, + "agentName": "worker-1", + } + ), + ] + file_path.write_text("\n".join(lines) + "\n") + + sessions = list_sessions(directory=project_path, include_worktrees=False) + assert len(sessions) == 1 + assert sessions[0].agent_name is None + + def test_parse_session_info_from_lite_helper(self, tmp_path: Path): + """Direct test of the refactored _parse_session_info_from_lite helper.""" + sid = str(uuid.uuid4()) + file_path = tmp_path / f"{sid}.jsonl" + lines = [ + json.dumps( + { + "type": "user", + "message": {"content": "test prompt"}, + "cwd": "/workspace", + } + ), + json.dumps({"type": "tag", "tag": "experiment", "sessionId": sid}), + ] + file_path.write_text("\n".join(lines) + "\n") + + lite = _read_session_lite(file_path) + assert lite is not None + info = _parse_session_info_from_lite(sid, lite, "/fallback") + assert info is not None + assert info.session_id == sid + assert info.summary == "test prompt" + assert info.tag == "experiment" + assert info.cwd == "/workspace" # head cwd wins over fallback + assert info.agent_name is None + + +# --------------------------------------------------------------------------- +# get_session_info() tests +# --------------------------------------------------------------------------- + + +class TestGetSessionInfo: + """Tests for the get_session_info() single-session lookup.""" + + def test_invalid_session_id(self, claude_config_dir: Path): + """Non-UUID session_id returns None.""" + assert get_session_info("not-a-uuid") is None + assert get_session_info("") is None + + def test_nonexistent_session(self, claude_config_dir: Path): + """Session file not found returns None.""" + sid = str(uuid.uuid4()) + assert get_session_info(sid) is None + + def test_no_config_dir(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + """Missing config dir returns None.""" + monkeypatch.setenv("CLAUDE_CONFIG_DIR", str(tmp_path / "nonexistent")) + sid = str(uuid.uuid4()) + assert get_session_info(sid) is None + + def test_found_with_directory(self, claude_config_dir: Path, tmp_path: Path): + """Session found in a specific project directory.""" + project_path = str(tmp_path / "proj") + Path(project_path).mkdir(parents=True) + project_dir = _make_project_dir( + claude_config_dir, os.path.realpath(project_path) + ) + sid, _ = _make_session_file( + project_dir, first_prompt="hello", git_branch="main" + ) + + info = get_session_info(sid, directory=project_path) + assert info is not None + assert info.session_id == sid + assert info.summary == "hello" + assert info.git_branch == "main" + + def test_found_without_directory(self, claude_config_dir: Path): + """Session found by searching all project directories.""" + project_dir = _make_project_dir(claude_config_dir, "/some/project") + sid, _ = _make_session_file(project_dir, first_prompt="search all") + + info = get_session_info(sid) + assert info is not None + assert info.session_id == sid + assert info.summary == "search all" + + def test_returns_none_for_sidechain(self, claude_config_dir: Path, tmp_path: Path): + """Sidechain sessions return None (filtered by parse helper).""" + project_path = str(tmp_path / "proj") + Path(project_path).mkdir(parents=True) + project_dir = _make_project_dir( + claude_config_dir, os.path.realpath(project_path) + ) + sid, _ = _make_session_file( + project_dir, first_prompt="sidechain", is_sidechain=True + ) + + assert get_session_info(sid, directory=project_path) is None + + def test_directory_not_containing_session( + self, claude_config_dir: Path, tmp_path: Path + ): + """Returns None when directory provided but session not in it.""" + project_a = str(tmp_path / "proj-a") + project_b = str(tmp_path / "proj-b") + Path(project_a).mkdir(parents=True) + Path(project_b).mkdir(parents=True) + dir_a = _make_project_dir(claude_config_dir, os.path.realpath(project_a)) + _make_project_dir(claude_config_dir, os.path.realpath(project_b)) + sid, _ = _make_session_file(dir_a, first_prompt="in A only") + + # Session exists in A but we look in B — should return None + # (no worktree relationship between them) + assert get_session_info(sid, directory=project_b) is None + # But searching all projects finds it + assert get_session_info(sid) is not None + + def test_includes_tag_and_agent_name(self, claude_config_dir: Path, tmp_path: Path): + """get_session_info includes the new tag and agent_name fields.""" + project_path = str(tmp_path / "proj") + Path(project_path).mkdir(parents=True) + project_dir = _make_project_dir( + claude_config_dir, os.path.realpath(project_path) + ) + sid = str(uuid.uuid4()) + file_path = project_dir / f"{sid}.jsonl" + lines = [ + json.dumps({"type": "user", "message": {"content": "hello"}}), + json.dumps({"type": "tag", "tag": "urgent", "sessionId": sid}), + json.dumps( + {"type": "agent-name", "agentName": "reviewer", "sessionId": sid} + ), + ] + file_path.write_text("\n".join(lines) + "\n") + + info = get_session_info(sid, directory=project_path) + assert info is not None + assert info.tag == "urgent" + assert info.agent_name == "reviewer" + + def test_sdksessioninfo_new_fields_defaults(self): + """SDKSessionInfo has tag and agent_name defaulting to None.""" + info = SDKSessionInfo( + session_id="abc", + summary="test", + last_modified=1000, + file_size=42, + ) + assert info.tag is None + assert info.agent_name is None From 9460a4ae326958a091c518c7e11ab155dfd3300f Mon Sep 17 00:00:00 2001 From: Qing Wang Date: Tue, 10 Mar 2026 17:04:47 -0700 Subject: [PATCH 2/9] feat: add created_at to SDKSessionInfo Extracts creation timestamp from first entry's ISO timestamp field in the head buffer. Returns epoch ms for consistency with last_modified. More reliable than stat().birthtime which is unsupported on some filesystems. Python 3.10 compatibility: datetime.fromisoformat() in 3.10 doesn't support trailing 'Z', so we replace it with '+00:00' before parsing. Mirrors claude-cli-internal commit 2470efe469. Tests: 288 passed. Ruff + mypy clean. --- src/claude_agent_sdk/_internal/sessions.py | 18 +++ src/claude_agent_sdk/types.py | 4 + tests/test_sessions.py | 136 +++++++++++++++++++++ 3 files changed, 158 insertions(+) diff --git a/src/claude_agent_sdk/_internal/sessions.py b/src/claude_agent_sdk/_internal/sessions.py index f2c1f4ad..23a6fa58 100644 --- a/src/claude_agent_sdk/_internal/sessions.py +++ b/src/claude_agent_sdk/_internal/sessions.py @@ -13,6 +13,7 @@ import subprocess import sys import unicodedata +from datetime import datetime from pathlib import Path from typing import Any @@ -455,6 +456,22 @@ def _parse_session_info_from_lite( line = tail[line_start : line_end if line_end >= 0 else len(tail)] agent_name = _extract_json_string_field(line, "agentName") or None + # created_at from first entry's ISO timestamp (epoch ms). More reliable + # than stat().birthtime which is unsupported on some filesystems. + created_at: float | None = None + first_timestamp = _extract_json_string_field(head, "timestamp") + if first_timestamp: + try: + # Python 3.10's fromisoformat doesn't support trailing 'Z' + ts = ( + first_timestamp.replace("Z", "+00:00") + if first_timestamp.endswith("Z") + else first_timestamp + ) + created_at = datetime.fromisoformat(ts).timestamp() * 1000 + except ValueError: + pass + return SDKSessionInfo( session_id=session_id, summary=summary, @@ -466,6 +483,7 @@ def _parse_session_info_from_lite( cwd=session_cwd, tag=tag, agent_name=agent_name, + created_at=created_at, ) diff --git a/src/claude_agent_sdk/types.py b/src/claude_agent_sdk/types.py index 5a06e140..909bfad3 100644 --- a/src/claude_agent_sdk/types.py +++ b/src/claude_agent_sdk/types.py @@ -922,6 +922,9 @@ class SDKSessionInfo: cwd: Working directory for the session. tag: User-set session tag. agent_name: Name of the agent that ran this session. + created_at: Creation time in milliseconds since epoch, extracted + from the first entry's ISO timestamp field. More reliable + than stat().birthtime which is unsupported on some filesystems. """ session_id: str @@ -934,6 +937,7 @@ class SDKSessionInfo: cwd: str | None = None tag: str | None = None agent_name: str | None = None + created_at: float | None = None @dataclass diff --git a/tests/test_sessions.py b/tests/test_sessions.py index aa0710d6..7ae602b2 100644 --- a/tests/test_sessions.py +++ b/tests/test_sessions.py @@ -1261,6 +1261,142 @@ def test_parse_session_info_from_lite_helper(self, tmp_path: Path): assert info.agent_name is None +class TestCreatedAtExtraction: + """Tests for created_at field extraction from first entry timestamp.""" + + def test_created_at_from_iso_timestamp( + self, claude_config_dir: Path, tmp_path: Path + ): + """created_at is parsed from ISO timestamp in first entry (epoch ms).""" + project_path = str(tmp_path / "proj") + Path(project_path).mkdir(parents=True) + project_dir = _make_project_dir( + claude_config_dir, os.path.realpath(project_path) + ) + sid = str(uuid.uuid4()) + file_path = project_dir / f"{sid}.jsonl" + # 2026-01-15T10:30:00.000Z → epoch 1768473000000 ms + lines = [ + json.dumps( + { + "type": "user", + "message": {"content": "hello"}, + "timestamp": "2026-01-15T10:30:00.000Z", + } + ), + json.dumps( + { + "type": "assistant", + "message": {"content": "hi"}, + "timestamp": "2026-01-15T10:35:00.000Z", + } + ), + ] + file_path.write_text("\n".join(lines) + "\n") + + sessions = list_sessions(directory=project_path, include_worktrees=False) + assert len(sessions) == 1 + # 2026-01-15T10:30:00Z = 1768473000 seconds = 1768473000000 ms + assert sessions[0].created_at == 1768473000000.0 + + def test_created_at_leq_last_modified( + self, claude_config_dir: Path, tmp_path: Path + ): + """created_at <= last_modified (creation precedes mtime).""" + project_path = str(tmp_path / "proj") + Path(project_path).mkdir(parents=True) + project_dir = _make_project_dir( + claude_config_dir, os.path.realpath(project_path) + ) + sid = str(uuid.uuid4()) + file_path = project_dir / f"{sid}.jsonl" + lines = [ + json.dumps( + { + "type": "user", + "message": {"content": "hello"}, + "timestamp": "2026-01-01T00:00:00.000Z", + } + ), + ] + file_path.write_text("\n".join(lines) + "\n") + # Set mtime to Feb 2026 (well after the Jan timestamp) + os.utime(file_path, (1769904000, 1769904000)) # 2026-02-01 UTC + + sessions = list_sessions(directory=project_path, include_worktrees=False) + assert len(sessions) == 1 + assert sessions[0].created_at is not None + assert sessions[0].created_at <= sessions[0].last_modified + + def test_created_at_none_when_missing( + self, claude_config_dir: Path, tmp_path: Path + ): + """created_at is None when first entry lacks a timestamp field.""" + project_path = str(tmp_path / "proj") + Path(project_path).mkdir(parents=True) + project_dir = _make_project_dir( + claude_config_dir, os.path.realpath(project_path) + ) + # _make_session_file doesn't add a timestamp field + _make_session_file(project_dir, first_prompt="no timestamp") + + sessions = list_sessions(directory=project_path, include_worktrees=False) + assert len(sessions) == 1 + assert sessions[0].created_at is None + + def test_created_at_none_on_invalid_format(self, tmp_path: Path): + """Invalid ISO string results in created_at=None (no exception).""" + sid = str(uuid.uuid4()) + file_path = tmp_path / f"{sid}.jsonl" + lines = [ + json.dumps( + { + "type": "user", + "message": {"content": "hello"}, + "timestamp": "not-a-valid-iso-date", + } + ), + ] + file_path.write_text("\n".join(lines) + "\n") + + lite = _read_session_lite(file_path) + assert lite is not None + info = _parse_session_info_from_lite(sid, lite) + assert info is not None + assert info.created_at is None + + def test_created_at_without_z_suffix(self, tmp_path: Path): + """ISO timestamp without Z suffix (with explicit offset) also works.""" + sid = str(uuid.uuid4()) + file_path = tmp_path / f"{sid}.jsonl" + lines = [ + json.dumps( + { + "type": "user", + "message": {"content": "hello"}, + "timestamp": "2026-01-15T10:30:00+00:00", + } + ), + ] + file_path.write_text("\n".join(lines) + "\n") + + lite = _read_session_lite(file_path) + assert lite is not None + info = _parse_session_info_from_lite(sid, lite) + assert info is not None + assert info.created_at == 1768473000000.0 + + def test_sdksessioninfo_created_at_default(self): + """SDKSessionInfo has created_at defaulting to None.""" + info = SDKSessionInfo( + session_id="abc", + summary="test", + last_modified=1000, + file_size=42, + ) + assert info.created_at is None + + # --------------------------------------------------------------------------- # get_session_info() tests # --------------------------------------------------------------------------- From 5e3f50cdabedaade2d569db2cce47d866c397ad3 Mon Sep 17 00:00:00 2001 From: Qing Wang Date: Tue, 10 Mar 2026 17:48:59 -0700 Subject: [PATCH 3/9] refactor: drop agent_name from SDKSessionInfo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit agent_name is tied to feature-gated swarm mode — not a general SDK concept. Removing to keep SDKSessionInfo focused on universal metadata. Mirrors TS change in claude-cli-internal#21659. Tests: 286 passed. Ruff + mypy clean. --- src/claude_agent_sdk/_internal/sessions.py | 18 ----- src/claude_agent_sdk/types.py | 2 - tests/test_sessions.py | 89 ++-------------------- 3 files changed, 6 insertions(+), 103 deletions(-) diff --git a/src/claude_agent_sdk/_internal/sessions.py b/src/claude_agent_sdk/_internal/sessions.py index 23a6fa58..007f0e29 100644 --- a/src/claude_agent_sdk/_internal/sessions.py +++ b/src/claude_agent_sdk/_internal/sessions.py @@ -439,23 +439,6 @@ def _parse_session_info_from_lite( session_cwd = _extract_json_string_field(head, "cwd") or project_path or None tag = _extract_last_json_string_field(tail, "tag") or None - # agentName requires type-scoped extraction: TranscriptMessage has a - # per-message agentName field (written on every message in swarm - # sessions), so a bare tail-scan for '"agentName":' would pick up the - # per-message value instead of the session-level {type:'agent-name'} - # entry. Scope to that entry type. - agent_name: str | None = None - # CLI writes compact JSON (no space); json.dumps default adds a space. - # Handle both variants and pick the LAST occurrence. - idx_compact = tail.rfind('"type":"agent-name"') - idx_spaced = tail.rfind('"type": "agent-name"') - agent_name_type_idx = max(idx_compact, idx_spaced) - if agent_name_type_idx >= 0: - line_start = tail.rfind("\n", 0, agent_name_type_idx) + 1 - line_end = tail.find("\n", agent_name_type_idx) - line = tail[line_start : line_end if line_end >= 0 else len(tail)] - agent_name = _extract_json_string_field(line, "agentName") or None - # created_at from first entry's ISO timestamp (epoch ms). More reliable # than stat().birthtime which is unsupported on some filesystems. created_at: float | None = None @@ -482,7 +465,6 @@ def _parse_session_info_from_lite( git_branch=git_branch, cwd=session_cwd, tag=tag, - agent_name=agent_name, created_at=created_at, ) diff --git a/src/claude_agent_sdk/types.py b/src/claude_agent_sdk/types.py index 909bfad3..fd0abfeb 100644 --- a/src/claude_agent_sdk/types.py +++ b/src/claude_agent_sdk/types.py @@ -921,7 +921,6 @@ class SDKSessionInfo: git_branch: Git branch at the end of the session. cwd: Working directory for the session. tag: User-set session tag. - agent_name: Name of the agent that ran this session. created_at: Creation time in milliseconds since epoch, extracted from the first entry's ISO timestamp field. More reliable than stat().birthtime which is unsupported on some filesystems. @@ -936,7 +935,6 @@ class SDKSessionInfo: git_branch: str | None = None cwd: str | None = None tag: str | None = None - agent_name: str | None = None created_at: float | None = None diff --git a/tests/test_sessions.py b/tests/test_sessions.py index 7ae602b2..27060c75 100644 --- a/tests/test_sessions.py +++ b/tests/test_sessions.py @@ -1084,12 +1084,12 @@ def test_creation(self): # --------------------------------------------------------------------------- -# Tag and agent_name extraction tests (Branch A additions) +# Tag extraction tests (Branch A additions) # --------------------------------------------------------------------------- -class TestTagAndAgentNameExtraction: - """Tests for tag and agent_name field extraction in SDKSessionInfo.""" +class TestTagExtraction: + """Tests for tag field extraction in SDKSessionInfo.""" def test_tag_extracted_from_tail(self, claude_config_dir: Path, tmp_path: Path): """Tag is extracted from the last {type:'tag'} entry in the tail.""" @@ -1163,77 +1163,6 @@ def test_tag_absent(self, claude_config_dir: Path, tmp_path: Path): assert len(sessions) == 1 assert sessions[0].tag is None - def test_agent_name_scoped_to_type_entry( - self, claude_config_dir: Path, tmp_path: Path - ): - """agent_name is extracted only from {type:'agent-name'} entries. - - Per-message agentName fields (from swarm sessions) must be ignored. - """ - project_path = str(tmp_path / "proj") - Path(project_path).mkdir(parents=True) - project_dir = _make_project_dir( - claude_config_dir, os.path.realpath(project_path) - ) - sid = str(uuid.uuid4()) - file_path = project_dir / f"{sid}.jsonl" - # Swarm session: every message has per-message agentName which must - # NOT be picked up. Only the {type:'agent-name'} entry counts. - lines = [ - json.dumps( - { - "type": "user", - "message": {"content": "hello"}, - "agentName": "worker-1", - } - ), - json.dumps( - { - "type": "agent-name", - "agentName": "coordinator", - "sessionId": sid, - } - ), - json.dumps( - { - "type": "assistant", - "message": {"content": "hi"}, - "agentName": "worker-2", - } - ), - ] - file_path.write_text("\n".join(lines) + "\n") - - sessions = list_sessions(directory=project_path, include_worktrees=False) - assert len(sessions) == 1 - assert sessions[0].agent_name == "coordinator" - - def test_agent_name_absent_without_type_entry( - self, claude_config_dir: Path, tmp_path: Path - ): - """Sessions with per-message agentName but no type entry have agent_name=None.""" - project_path = str(tmp_path / "proj") - Path(project_path).mkdir(parents=True) - project_dir = _make_project_dir( - claude_config_dir, os.path.realpath(project_path) - ) - sid = str(uuid.uuid4()) - file_path = project_dir / f"{sid}.jsonl" - lines = [ - json.dumps( - { - "type": "user", - "message": {"content": "hello"}, - "agentName": "worker-1", - } - ), - ] - file_path.write_text("\n".join(lines) + "\n") - - sessions = list_sessions(directory=project_path, include_worktrees=False) - assert len(sessions) == 1 - assert sessions[0].agent_name is None - def test_parse_session_info_from_lite_helper(self, tmp_path: Path): """Direct test of the refactored _parse_session_info_from_lite helper.""" sid = str(uuid.uuid4()) @@ -1258,7 +1187,6 @@ def test_parse_session_info_from_lite_helper(self, tmp_path: Path): assert info.summary == "test prompt" assert info.tag == "experiment" assert info.cwd == "/workspace" # head cwd wins over fallback - assert info.agent_name is None class TestCreatedAtExtraction: @@ -1479,8 +1407,8 @@ def test_directory_not_containing_session( # But searching all projects finds it assert get_session_info(sid) is not None - def test_includes_tag_and_agent_name(self, claude_config_dir: Path, tmp_path: Path): - """get_session_info includes the new tag and agent_name fields.""" + def test_includes_tag(self, claude_config_dir: Path, tmp_path: Path): + """get_session_info includes the new tag field.""" project_path = str(tmp_path / "proj") Path(project_path).mkdir(parents=True) project_dir = _make_project_dir( @@ -1491,19 +1419,15 @@ def test_includes_tag_and_agent_name(self, claude_config_dir: Path, tmp_path: Pa lines = [ json.dumps({"type": "user", "message": {"content": "hello"}}), json.dumps({"type": "tag", "tag": "urgent", "sessionId": sid}), - json.dumps( - {"type": "agent-name", "agentName": "reviewer", "sessionId": sid} - ), ] file_path.write_text("\n".join(lines) + "\n") info = get_session_info(sid, directory=project_path) assert info is not None assert info.tag == "urgent" - assert info.agent_name == "reviewer" def test_sdksessioninfo_new_fields_defaults(self): - """SDKSessionInfo has tag and agent_name defaulting to None.""" + """SDKSessionInfo has tag defaulting to None.""" info = SDKSessionInfo( session_id="abc", summary="test", @@ -1511,4 +1435,3 @@ def test_sdksessioninfo_new_fields_defaults(self): file_size=42, ) assert info.tag is None - assert info.agent_name is None From 324c21be391aaf6f529d6fd9c74fcb56aed88f2e Mon Sep 17 00:00:00 2001 From: Qing Wang Date: Tue, 10 Mar 2026 18:27:27 -0700 Subject: [PATCH 4/9] refactor: make SDKSessionInfo.file_size optional file_size is a local-JSONL concept with no remote-storage equivalent. Making optional now for forward-compat with pluggable storage backends. Local _parse_session_info_from_lite still populates it from fstat size; this is a type loosening only. Existing tests pass unchanged. Mirrors claude-cli-internal PR #21659. --- src/claude_agent_sdk/types.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/claude_agent_sdk/types.py b/src/claude_agent_sdk/types.py index fd0abfeb..20d01f8f 100644 --- a/src/claude_agent_sdk/types.py +++ b/src/claude_agent_sdk/types.py @@ -915,7 +915,8 @@ class SDKSessionInfo: summary: Display title for the session — custom title, auto-generated summary, or first prompt. last_modified: Last modified time in milliseconds since epoch. - file_size: Session file size in bytes. + file_size: Session file size in bytes. Only populated for local + JSONL storage; may be ``None`` for remote storage backends. custom_title: User-set session title via /rename. first_prompt: First meaningful user prompt in the session. git_branch: Git branch at the end of the session. @@ -929,7 +930,7 @@ class SDKSessionInfo: session_id: str summary: str last_modified: int - file_size: int + file_size: int | None = None custom_title: str | None = None first_prompt: str | None = None git_branch: str | None = None From a112d60bb82e0f61f369a1c9fe535563ad8c93fb Mon Sep 17 00:00:00 2001 From: Qing Wang Date: Wed, 18 Mar 2026 12:03:18 -0700 Subject: [PATCH 5/9] fix: TS parity gaps in _parse_session_info_from_lite MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four fixes from a 43-agent adversarial review against the TS reference (claude-cli-internal src/utils/listSessionsImpl.ts @ main): 1. Tag extraction scoped to {"type":"tag"} lines (sessions.py:455) Bare _extract_last_json_string_field(tail, "tag") matches ANY "tag":"..." in the 64KB tail — including tool_use inputs from Docker builds, git tag via MCP, cloud resource tagging. Proven: test_tag_none_when_only_tool_use_tag returned tag='prod' from tool input before the fix. TS scopes to lines starting with '{"type":"tag"' at column 0 (listSessionsImpl.ts:132, sessionStorage.ts:629 with the canonical comment about this exact collision class). Tests now use separators=(",", ":") to match real on-disk format (CLI jsonStringify, SDK tag_session, TS sessionMutationsImpl all write compact JSON). 2 regression tests added. 2. custom_title chain: + head scan, + aiTitle fallback (sessions.py:426) TS chain is tail.customTitle || head.customTitle || tail.aiTitle || head.aiTitle (listSessionsImpl.ts:97-102). Pre-existing drift from TS PRs #21333 (aiTitle) and #20390 landing after Python #622 — fixing here since this PR touches the exact block. 3. summary chain: + lastPrompt fallback (sessions.py:436) TS inserts tail.lastPrompt between custom_title and summary (listSessionsImpl.ts:115-119). Same drift class as (2). 4. created_at: float -> int (types.py:998, sessions.py:465) last_modified is int; both are epoch-ms. Wrapped datetime.timestamp() * 1000 in int(). --- src/claude_agent_sdk/_internal/sessions.py | 33 +++++-- src/claude_agent_sdk/types.py | 2 +- tests/test_sessions.py | 101 +++++++++++++++++++-- 3 files changed, 121 insertions(+), 15 deletions(-) diff --git a/src/claude_agent_sdk/_internal/sessions.py b/src/claude_agent_sdk/_internal/sessions.py index 007f0e29..fbbb50b7 100644 --- a/src/claude_agent_sdk/_internal/sessions.py +++ b/src/claude_agent_sdk/_internal/sessions.py @@ -411,7 +411,7 @@ def _parse_session_info_from_lite( Returns None for sidechain sessions or metadata-only sessions with no extractable summary. - Exported for reuse by get_session_info. + Shared by list_sessions and get_session_info. """ head, tail, mtime, size = lite.head, lite.tail, lite.mtime, lite.size @@ -421,10 +421,22 @@ def _parse_session_info_from_lite( if '"isSidechain":true' in first_line or '"isSidechain": true' in first_line: return None - custom_title = _extract_last_json_string_field(tail, "customTitle") or None + # User-set title (customTitle) wins over AI-generated title (aiTitle). + # Head fallback covers short sessions where the title entry may not be in tail. + custom_title = ( + _extract_last_json_string_field(tail, "customTitle") + or _extract_last_json_string_field(head, "customTitle") + or _extract_last_json_string_field(tail, "aiTitle") + or _extract_last_json_string_field(head, "aiTitle") + or None + ) first_prompt = _extract_first_prompt_from_head(head) or None + # lastPrompt tail entry shows what the user was most recently doing. summary = ( - custom_title or _extract_last_json_string_field(tail, "summary") or first_prompt + custom_title + or _extract_last_json_string_field(tail, "lastPrompt") + or _extract_last_json_string_field(tail, "summary") + or first_prompt ) # Skip metadata-only sessions (no title, no summary, no prompt) @@ -437,11 +449,20 @@ def _parse_session_info_from_lite( or None ) session_cwd = _extract_json_string_field(head, "cwd") or project_path or None - tag = _extract_last_json_string_field(tail, "tag") or None + # Scope tag extraction to {"type":"tag"} lines — a bare tail scan for + # "tag" would match tool_use inputs (git tag, Docker tags, cloud resource + # tags). Mirrors TS listSessionsImpl.ts / sessionStorage.ts:629. + tag_line = next( + (ln for ln in reversed(tail.split("\n")) if ln.startswith('{"type":"tag"')), + None, + ) + tag = ( + (_extract_last_json_string_field(tag_line, "tag") or None) if tag_line else None + ) # created_at from first entry's ISO timestamp (epoch ms). More reliable # than stat().birthtime which is unsupported on some filesystems. - created_at: float | None = None + created_at: int | None = None first_timestamp = _extract_json_string_field(head, "timestamp") if first_timestamp: try: @@ -451,7 +472,7 @@ def _parse_session_info_from_lite( if first_timestamp.endswith("Z") else first_timestamp ) - created_at = datetime.fromisoformat(ts).timestamp() * 1000 + created_at = int(datetime.fromisoformat(ts).timestamp() * 1000) except ValueError: pass diff --git a/src/claude_agent_sdk/types.py b/src/claude_agent_sdk/types.py index 748053ea..9c8e00c4 100644 --- a/src/claude_agent_sdk/types.py +++ b/src/claude_agent_sdk/types.py @@ -995,7 +995,7 @@ class SDKSessionInfo: git_branch: str | None = None cwd: str | None = None tag: str | None = None - created_at: float | None = None + created_at: int | None = None @dataclass diff --git a/tests/test_sessions.py b/tests/test_sessions.py index 27060c75..031ff863 100644 --- a/tests/test_sessions.py +++ b/tests/test_sessions.py @@ -28,6 +28,11 @@ _validate_uuid, ) +# Matches the CLI's on-disk JSONL format (JSON.stringify / json.dumps with +# separators). Tag extraction scopes to '{"type":"tag"' (no space after colon) +# at column 0 to avoid matching tool_use inputs — fixtures must use this form. +_COMPACT = {"separators": (",", ":")} + # --------------------------------------------------------------------------- # Fixtures # --------------------------------------------------------------------------- @@ -1102,7 +1107,7 @@ def test_tag_extracted_from_tail(self, claude_config_dir: Path, tmp_path: Path): file_path = project_dir / f"{sid}.jsonl" lines = [ json.dumps({"type": "user", "message": {"content": "hello"}}), - json.dumps({"type": "tag", "tag": "my-tag", "sessionId": sid}), + json.dumps({"type": "tag", "tag": "my-tag", "sessionId": sid}, **_COMPACT), ] file_path.write_text("\n".join(lines) + "\n") @@ -1121,8 +1126,12 @@ def test_tag_last_wins(self, claude_config_dir: Path, tmp_path: Path): file_path = project_dir / f"{sid}.jsonl" lines = [ json.dumps({"type": "user", "message": {"content": "hello"}}), - json.dumps({"type": "tag", "tag": "first-tag", "sessionId": sid}), - json.dumps({"type": "tag", "tag": "second-tag", "sessionId": sid}), + json.dumps( + {"type": "tag", "tag": "first-tag", "sessionId": sid}, **_COMPACT + ), + json.dumps( + {"type": "tag", "tag": "second-tag", "sessionId": sid}, **_COMPACT + ), ] file_path.write_text("\n".join(lines) + "\n") @@ -1141,8 +1150,8 @@ def test_tag_empty_string_is_none(self, claude_config_dir: Path, tmp_path: Path) file_path = project_dir / f"{sid}.jsonl" lines = [ json.dumps({"type": "user", "message": {"content": "hello"}}), - json.dumps({"type": "tag", "tag": "old-tag", "sessionId": sid}), - json.dumps({"type": "tag", "tag": "", "sessionId": sid}), + json.dumps({"type": "tag", "tag": "old-tag", "sessionId": sid}, **_COMPACT), + json.dumps({"type": "tag", "tag": "", "sessionId": sid}, **_COMPACT), ] file_path.write_text("\n".join(lines) + "\n") @@ -1163,6 +1172,79 @@ def test_tag_absent(self, claude_config_dir: Path, tmp_path: Path): assert len(sessions) == 1 assert sessions[0].tag is None + def test_tag_ignores_tool_use_inputs(self, claude_config_dir: Path, tmp_path: Path): + """Tag extraction is scoped to {type:'tag'} lines — ignores "tag" fields + in tool_use inputs (git tag, Docker tags, cloud resource tags). + + Mirrors TS listSessionsImpl.ts:132 / sessionStorage.ts:629. + """ + project_path = str(tmp_path / "proj") + Path(project_path).mkdir(parents=True) + project_dir = _make_project_dir( + claude_config_dir, os.path.realpath(project_path) + ) + sid = str(uuid.uuid4()) + file_path = project_dir / f"{sid}.jsonl" + lines = [ + json.dumps({"type": "user", "message": {"content": "tag this v1.0"}}), + json.dumps( + {"type": "tag", "tag": "real-tag", "sessionId": sid}, **_COMPACT + ), + # A tool_use entry with a "tag" key in its input — must NOT match. + json.dumps( + { + "type": "assistant", + "message": { + "content": [ + { + "type": "tool_use", + "name": "mcp__docker__build", + "input": {"tag": "myapp:v2", "context": "."}, + } + ], + }, + } + ), + ] + file_path.write_text("\n".join(lines) + "\n") + + sessions = list_sessions(directory=project_path, include_worktrees=False) + assert len(sessions) == 1 + assert sessions[0].tag == "real-tag" # NOT "myapp:v2" + + def test_tag_none_when_only_tool_use_tag( + self, claude_config_dir: Path, tmp_path: Path + ): + """Session with no {type:'tag'} entry but tool_use input has tag — returns None.""" + project_path = str(tmp_path / "proj") + Path(project_path).mkdir(parents=True) + project_dir = _make_project_dir( + claude_config_dir, os.path.realpath(project_path) + ) + sid = str(uuid.uuid4()) + file_path = project_dir / f"{sid}.jsonl" + lines = [ + json.dumps({"type": "user", "message": {"content": "build docker"}}), + json.dumps( + { + "type": "assistant", + "message": { + "content": [ + { + "type": "tool_use", + "input": {"tag": "prod"}, + } + ], + }, + } + ), + ] + file_path.write_text("\n".join(lines) + "\n") + + sessions = list_sessions(directory=project_path, include_worktrees=False) + assert len(sessions) == 1 + assert sessions[0].tag is None # NOT "prod" + def test_parse_session_info_from_lite_helper(self, tmp_path: Path): """Direct test of the refactored _parse_session_info_from_lite helper.""" sid = str(uuid.uuid4()) @@ -1175,7 +1257,9 @@ def test_parse_session_info_from_lite_helper(self, tmp_path: Path): "cwd": "/workspace", } ), - json.dumps({"type": "tag", "tag": "experiment", "sessionId": sid}), + json.dumps( + {"type": "tag", "tag": "experiment", "sessionId": sid}, **_COMPACT + ), ] file_path.write_text("\n".join(lines) + "\n") @@ -1225,7 +1309,8 @@ def test_created_at_from_iso_timestamp( sessions = list_sessions(directory=project_path, include_worktrees=False) assert len(sessions) == 1 # 2026-01-15T10:30:00Z = 1768473000 seconds = 1768473000000 ms - assert sessions[0].created_at == 1768473000000.0 + assert sessions[0].created_at == 1768473000000 + assert isinstance(sessions[0].created_at, int) def test_created_at_leq_last_modified( self, claude_config_dir: Path, tmp_path: Path @@ -1418,7 +1503,7 @@ def test_includes_tag(self, claude_config_dir: Path, tmp_path: Path): file_path = project_dir / f"{sid}.jsonl" lines = [ json.dumps({"type": "user", "message": {"content": "hello"}}), - json.dumps({"type": "tag", "tag": "urgent", "sessionId": sid}), + json.dumps({"type": "tag", "tag": "urgent", "sessionId": sid}, **_COMPACT), ] file_path.write_text("\n".join(lines) + "\n") From 94b70ba166e752502f4cf52473b458a67e635d55 Mon Sep 17 00:00:00 2001 From: Qing Wang Date: Wed, 18 Mar 2026 14:52:08 -0700 Subject: [PATCH 6/9] fix: address review nits on get_session_info - Add is_dir() filter to get_session_info's projects_dir scan, matching _list_all_sessions/_list_sessions_for_project - Fix float literal in test_created_at_without_z_suffix (created_at is int | None) --- src/claude_agent_sdk/_internal/sessions.py | 2 +- tests/test_sessions.py | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/claude_agent_sdk/_internal/sessions.py b/src/claude_agent_sdk/_internal/sessions.py index fbbb50b7..d5466c8c 100644 --- a/src/claude_agent_sdk/_internal/sessions.py +++ b/src/claude_agent_sdk/_internal/sessions.py @@ -766,7 +766,7 @@ def get_session_info( # No directory — search all project directories for the session file. projects_dir = _get_projects_dir() try: - dirents = list(projects_dir.iterdir()) + dirents = [e for e in projects_dir.iterdir() if e.is_dir()] except OSError: return None for entry in dirents: diff --git a/tests/test_sessions.py b/tests/test_sessions.py index 031ff863..3fccf5d9 100644 --- a/tests/test_sessions.py +++ b/tests/test_sessions.py @@ -1397,7 +1397,8 @@ def test_created_at_without_z_suffix(self, tmp_path: Path): assert lite is not None info = _parse_session_info_from_lite(sid, lite) assert info is not None - assert info.created_at == 1768473000000.0 + assert info.created_at == 1768473000000 + assert isinstance(info.created_at, int) def test_sdksessioninfo_created_at_default(self): """SDKSessionInfo has created_at defaulting to None.""" From a587633ae40483edf456c88fea553c247d95df76 Mon Sep 17 00:00:00 2001 From: Qing Wang Date: Thu, 19 Mar 2026 00:34:52 -0700 Subject: [PATCH 7/9] fix: scope timestamp to first line, update custom_title docstring --- src/claude_agent_sdk/_internal/sessions.py | 1069 +---------------- src/claude_agent_sdk/types.py | 1211 +------------------- 2 files changed, 2 insertions(+), 2278 deletions(-) diff --git a/src/claude_agent_sdk/_internal/sessions.py b/src/claude_agent_sdk/_internal/sessions.py index d5466c8c..b3a42524 100644 --- a/src/claude_agent_sdk/_internal/sessions.py +++ b/src/claude_agent_sdk/_internal/sessions.py @@ -1,1068 +1 @@ -"""Session listing implementation. - -Ported from TypeScript SDK (listSessionsImpl.ts + sessionStoragePortable.ts). -Scans ~/.claude/projects// for .jsonl session files and -extracts metadata from stat + head/tail reads without full JSONL parsing. -""" - -from __future__ import annotations - -import json -import os -import re -import subprocess -import sys -import unicodedata -from datetime import datetime -from pathlib import Path -from typing import Any - -from ..types import SDKSessionInfo, SessionMessage - -# --------------------------------------------------------------------------- -# Constants -# --------------------------------------------------------------------------- - -# Size of the head/tail buffer for lite metadata reads. -LITE_READ_BUF_SIZE = 65536 - -# Maximum length for a single filesystem path component. Most filesystems -# limit individual components to 255 bytes. We use 200 to leave room for -# the hash suffix and separator. -MAX_SANITIZED_LENGTH = 200 - -_UUID_RE = re.compile( - r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$", - re.IGNORECASE, -) - -# Pattern matching auto-generated or system messages that should be skipped -# when looking for the first meaningful user prompt. -_SKIP_FIRST_PROMPT_PATTERN = re.compile( - r"^(?:||||" - r"\[Request interrupted by user[^\]]*\]|" - r"\s*[\s\S]*\s*$|" - r"\s*[\s\S]*\s*$)" -) - -_COMMAND_NAME_RE = re.compile(r"(.*?)") - -_SANITIZE_RE = re.compile(r"[^a-zA-Z0-9]") - - -# --------------------------------------------------------------------------- -# UUID validation -# --------------------------------------------------------------------------- - - -def _validate_uuid(maybe_uuid: str) -> str | None: - """Returns the string if it is a valid UUID, else None.""" - if _UUID_RE.match(maybe_uuid): - return maybe_uuid - return None - - -# --------------------------------------------------------------------------- -# Path sanitization -# --------------------------------------------------------------------------- - - -def _simple_hash(s: str) -> str: - """Port of the JS simpleHash function (32-bit integer hash, base36). - - Uses the same algorithm as the TS fallback so directory names match - when the CLI was running under Node.js (not Bun). - """ - h = 0 - for ch in s: - char = ord(ch) - h = (h << 5) - h + char - # Emulate JS `hash |= 0` (coerce to 32-bit signed int) - h = h & 0xFFFFFFFF - if h >= 0x80000000: - h -= 0x100000000 - h = abs(h) - # JS toString(36) - if h == 0: - return "0" - digits = "0123456789abcdefghijklmnopqrstuvwxyz" - out = [] - n = h - while n > 0: - out.append(digits[n % 36]) - n //= 36 - return "".join(reversed(out)) - - -def _sanitize_path(name: str) -> str: - """Makes a string safe for use as a directory name. - - Replaces all non-alphanumeric characters with hyphens. For paths - exceeding MAX_SANITIZED_LENGTH, truncates and appends a hash suffix. - """ - sanitized = _SANITIZE_RE.sub("-", name) - if len(sanitized) <= MAX_SANITIZED_LENGTH: - return sanitized - h = _simple_hash(name) - return f"{sanitized[:MAX_SANITIZED_LENGTH]}-{h}" - - -# --------------------------------------------------------------------------- -# Config directories -# --------------------------------------------------------------------------- - - -def _get_claude_config_home_dir() -> Path: - """Returns the Claude config directory (respects CLAUDE_CONFIG_DIR).""" - config_dir = os.environ.get("CLAUDE_CONFIG_DIR") - if config_dir: - return Path(unicodedata.normalize("NFC", config_dir)) - return Path(unicodedata.normalize("NFC", str(Path.home() / ".claude"))) - - -def _get_projects_dir() -> Path: - return _get_claude_config_home_dir() / "projects" - - -def _get_project_dir(project_path: str) -> Path: - return _get_projects_dir() / _sanitize_path(project_path) - - -def _canonicalize_path(d: str) -> str: - """Resolves a directory path to its canonical form using realpath + NFC.""" - try: - resolved = os.path.realpath(d) - return unicodedata.normalize("NFC", resolved) - except OSError: - return unicodedata.normalize("NFC", d) - - -def _find_project_dir(project_path: str) -> Path | None: - """Finds the project directory for a given path. - - Tolerates hash mismatches for long paths (>200 chars). The CLI uses - Bun.hash while the SDK under Node.js uses simpleHash — for paths that - exceed MAX_SANITIZED_LENGTH, these produce different directory suffixes. - This function falls back to prefix-based scanning when the exact match - doesn't exist. - """ - exact = _get_project_dir(project_path) - if exact.is_dir(): - return exact - - # Exact match failed — for short paths this means no sessions exist. - # For long paths, try prefix matching to handle hash mismatches. - sanitized = _sanitize_path(project_path) - if len(sanitized) <= MAX_SANITIZED_LENGTH: - return None - - prefix = sanitized[:MAX_SANITIZED_LENGTH] - projects_dir = _get_projects_dir() - try: - for entry in projects_dir.iterdir(): - if entry.is_dir() and entry.name.startswith(prefix + "-"): - return entry - except OSError: - pass - return None - - -# --------------------------------------------------------------------------- -# JSON string field extraction — no full parse, works on truncated lines -# --------------------------------------------------------------------------- - - -def _unescape_json_string(raw: str) -> str: - """Unescape a JSON string value extracted as raw text.""" - if "\\" not in raw: - return raw - try: - result = json.loads(f'"{raw}"') - if isinstance(result, str): - return result - return raw - except (json.JSONDecodeError, ValueError): - return raw - - -def _extract_json_string_field(text: str, key: str) -> str | None: - """Extracts a simple JSON string field value without full parsing. - - Looks for "key":"value" or "key": "value" patterns. Returns the first - match, or None if not found. - """ - patterns = [f'"{key}":"', f'"{key}": "'] - for pattern in patterns: - idx = text.find(pattern) - if idx < 0: - continue - - value_start = idx + len(pattern) - i = value_start - while i < len(text): - if text[i] == "\\": - i += 2 - continue - if text[i] == '"': - return _unescape_json_string(text[value_start:i]) - i += 1 - return None - - -def _extract_last_json_string_field(text: str, key: str) -> str | None: - """Like _extract_json_string_field but finds the LAST occurrence.""" - patterns = [f'"{key}":"', f'"{key}": "'] - last_value: str | None = None - for pattern in patterns: - search_from = 0 - while True: - idx = text.find(pattern, search_from) - if idx < 0: - break - - value_start = idx + len(pattern) - i = value_start - while i < len(text): - if text[i] == "\\": - i += 2 - continue - if text[i] == '"': - last_value = _unescape_json_string(text[value_start:i]) - break - i += 1 - search_from = i + 1 - return last_value - - -# --------------------------------------------------------------------------- -# First prompt extraction from head chunk -# --------------------------------------------------------------------------- - - -def _extract_first_prompt_from_head(head: str) -> str: - """Extracts the first meaningful user prompt from a JSONL head chunk. - - Skips tool_result messages, isMeta, isCompactSummary, command-name - messages, and auto-generated patterns. Truncates to 200 chars. - """ - start = 0 - command_fallback = "" - head_len = len(head) - - while start < head_len: - newline_idx = head.find("\n", start) - if newline_idx >= 0: - line = head[start:newline_idx] - start = newline_idx + 1 - else: - line = head[start:] - start = head_len - - if '"type":"user"' not in line and '"type": "user"' not in line: - continue - if '"tool_result"' in line: - continue - if '"isMeta":true' in line or '"isMeta": true' in line: - continue - if '"isCompactSummary":true' in line or '"isCompactSummary": true' in line: - continue - - try: - entry = json.loads(line) - except (json.JSONDecodeError, ValueError): - continue - - if not isinstance(entry, dict) or entry.get("type") != "user": - continue - - message = entry.get("message") - if not isinstance(message, dict): - continue - - content = message.get("content") - texts: list[str] = [] - if isinstance(content, str): - texts.append(content) - elif isinstance(content, list): - for block in content: - if ( - isinstance(block, dict) - and block.get("type") == "text" - and isinstance(block.get("text"), str) - ): - texts.append(block["text"]) - - for raw in texts: - result = raw.replace("\n", " ").strip() - if not result: - continue - - # Skip slash-command messages but remember first as fallback - cmd_match = _COMMAND_NAME_RE.search(result) - if cmd_match: - if not command_fallback: - command_fallback = cmd_match.group(1) - continue - - if _SKIP_FIRST_PROMPT_PATTERN.match(result): - continue - - if len(result) > 200: - result = result[:200].rstrip() + "\u2026" - return result - - if command_fallback: - return command_fallback - return "" - - -# --------------------------------------------------------------------------- -# File I/O — read head and tail of a file -# --------------------------------------------------------------------------- - - -class _LiteSessionFile: - """Result of reading a session file's head, tail, mtime and size.""" - - __slots__ = ("mtime", "size", "head", "tail") - - def __init__(self, mtime: int, size: int, head: str, tail: str) -> None: - self.mtime = mtime - self.size = size - self.head = head - self.tail = tail - - -def _read_session_lite(file_path: Path) -> _LiteSessionFile | None: - """Opens a session file, stats it, and reads head + tail. - - Returns None on any error or if file is empty. - """ - try: - with file_path.open("rb") as f: - stat = os.fstat(f.fileno()) - size = stat.st_size - mtime = int(stat.st_mtime * 1000) - - head_bytes = f.read(LITE_READ_BUF_SIZE) - if not head_bytes: - return None - - head = head_bytes.decode("utf-8", errors="replace") - - tail_offset = max(0, size - LITE_READ_BUF_SIZE) - if tail_offset == 0: - tail = head - else: - f.seek(tail_offset) - tail_bytes = f.read(LITE_READ_BUF_SIZE) - tail = tail_bytes.decode("utf-8", errors="replace") - - return _LiteSessionFile(mtime=mtime, size=size, head=head, tail=tail) - except OSError: - return None - - -# --------------------------------------------------------------------------- -# Git worktree detection -# --------------------------------------------------------------------------- - - -def _get_worktree_paths(cwd: str) -> list[str]: - """Returns absolute worktree paths for the git repo containing cwd. - - Returns empty list if git is unavailable or cwd is not in a repo. - """ - try: - result = subprocess.run( - ["git", "worktree", "list", "--porcelain"], - cwd=cwd, - capture_output=True, - text=True, - timeout=5, - check=False, - ) - except (OSError, subprocess.SubprocessError): - return [] - - if result.returncode != 0 or not result.stdout: - return [] - - paths = [] - for line in result.stdout.split("\n"): - if line.startswith("worktree "): - path = unicodedata.normalize("NFC", line[len("worktree ") :]) - paths.append(path) - return paths - - -# --------------------------------------------------------------------------- -# Field extraction — shared by list_sessions and get_session_info -# --------------------------------------------------------------------------- - - -def _parse_session_info_from_lite( - session_id: str, - lite: _LiteSessionFile, - project_path: str | None = None, -) -> SDKSessionInfo | None: - """Parses SDKSessionInfo fields from a lite session read (head/tail/stat). - - Returns None for sidechain sessions or metadata-only sessions with no - extractable summary. - - Shared by list_sessions and get_session_info. - """ - head, tail, mtime, size = lite.head, lite.tail, lite.mtime, lite.size - - # Check first line for sidechain sessions - first_newline = head.find("\n") - first_line = head[:first_newline] if first_newline >= 0 else head - if '"isSidechain":true' in first_line or '"isSidechain": true' in first_line: - return None - - # User-set title (customTitle) wins over AI-generated title (aiTitle). - # Head fallback covers short sessions where the title entry may not be in tail. - custom_title = ( - _extract_last_json_string_field(tail, "customTitle") - or _extract_last_json_string_field(head, "customTitle") - or _extract_last_json_string_field(tail, "aiTitle") - or _extract_last_json_string_field(head, "aiTitle") - or None - ) - first_prompt = _extract_first_prompt_from_head(head) or None - # lastPrompt tail entry shows what the user was most recently doing. - summary = ( - custom_title - or _extract_last_json_string_field(tail, "lastPrompt") - or _extract_last_json_string_field(tail, "summary") - or first_prompt - ) - - # Skip metadata-only sessions (no title, no summary, no prompt) - if not summary: - return None - - git_branch = ( - _extract_last_json_string_field(tail, "gitBranch") - or _extract_json_string_field(head, "gitBranch") - or None - ) - session_cwd = _extract_json_string_field(head, "cwd") or project_path or None - # Scope tag extraction to {"type":"tag"} lines — a bare tail scan for - # "tag" would match tool_use inputs (git tag, Docker tags, cloud resource - # tags). Mirrors TS listSessionsImpl.ts / sessionStorage.ts:629. - tag_line = next( - (ln for ln in reversed(tail.split("\n")) if ln.startswith('{"type":"tag"')), - None, - ) - tag = ( - (_extract_last_json_string_field(tag_line, "tag") or None) if tag_line else None - ) - - # created_at from first entry's ISO timestamp (epoch ms). More reliable - # than stat().birthtime which is unsupported on some filesystems. - created_at: int | None = None - first_timestamp = _extract_json_string_field(head, "timestamp") - if first_timestamp: - try: - # Python 3.10's fromisoformat doesn't support trailing 'Z' - ts = ( - first_timestamp.replace("Z", "+00:00") - if first_timestamp.endswith("Z") - else first_timestamp - ) - created_at = int(datetime.fromisoformat(ts).timestamp() * 1000) - except ValueError: - pass - - return SDKSessionInfo( - session_id=session_id, - summary=summary, - last_modified=mtime, - file_size=size, - custom_title=custom_title, - first_prompt=first_prompt, - git_branch=git_branch, - cwd=session_cwd, - tag=tag, - created_at=created_at, - ) - - -# --------------------------------------------------------------------------- -# Core implementation -# --------------------------------------------------------------------------- - - -def _read_sessions_from_dir( - project_dir: Path, project_path: str | None = None -) -> list[SDKSessionInfo]: - """Reads session files from a single project directory. - - Each file gets a stat + head/tail read. Filters out sidechain sessions - and metadata-only sessions (no title/summary/prompt). - """ - try: - entries = list(project_dir.iterdir()) - except OSError: - return [] - - results: list[SDKSessionInfo] = [] - - for entry in entries: - name = entry.name - if not name.endswith(".jsonl"): - continue - session_id = _validate_uuid(name[:-6]) - if not session_id: - continue - - lite = _read_session_lite(entry) - if lite is None: - continue - - info = _parse_session_info_from_lite(session_id, lite, project_path) - if info is not None: - results.append(info) - - return results - - -def _deduplicate_by_session_id( - sessions: list[SDKSessionInfo], -) -> list[SDKSessionInfo]: - """Deduplicates by session_id, keeping the newest last_modified.""" - by_id: dict[str, SDKSessionInfo] = {} - for s in sessions: - existing = by_id.get(s.session_id) - if existing is None or s.last_modified > existing.last_modified: - by_id[s.session_id] = s - return list(by_id.values()) - - -def _apply_sort_and_limit( - sessions: list[SDKSessionInfo], limit: int | None -) -> list[SDKSessionInfo]: - """Sorts sessions by last_modified descending and applies optional limit.""" - sessions.sort(key=lambda s: s.last_modified, reverse=True) - if limit is not None and limit > 0: - return sessions[:limit] - return sessions - - -def _list_sessions_for_project( - directory: str, limit: int | None, include_worktrees: bool -) -> list[SDKSessionInfo]: - """Lists sessions for a specific project directory (and its worktrees).""" - canonical_dir = _canonicalize_path(directory) - - if include_worktrees: - try: - worktree_paths = _get_worktree_paths(canonical_dir) - except Exception: - worktree_paths = [] - else: - worktree_paths = [] - - # No worktrees (or git not available / scanning disabled) — - # just scan the single project dir - if len(worktree_paths) <= 1: - project_dir = _find_project_dir(canonical_dir) - if project_dir is None: - return [] - sessions = _read_sessions_from_dir(project_dir, canonical_dir) - return _apply_sort_and_limit(sessions, limit) - - # Worktree-aware scanning: find all project dirs matching any worktree - projects_dir = _get_projects_dir() - case_insensitive = sys.platform == "win32" - - # Sort worktree paths by sanitized prefix length (longest first) so - # more specific matches take priority over shorter ones - indexed = [] - for wt in worktree_paths: - sanitized = _sanitize_path(wt) - prefix = sanitized.lower() if case_insensitive else sanitized - indexed.append((wt, prefix)) - indexed.sort(key=lambda x: len(x[1]), reverse=True) - - try: - all_dirents = [e for e in projects_dir.iterdir() if e.is_dir()] - except OSError: - # Fall back to single project dir - project_dir = _find_project_dir(canonical_dir) - if project_dir is None: - return _apply_sort_and_limit([], limit) - sessions = _read_sessions_from_dir(project_dir, canonical_dir) - return _apply_sort_and_limit(sessions, limit) - - all_sessions: list[SDKSessionInfo] = [] - seen_dirs: set[str] = set() - - # Always include the user's actual directory (handles subdirectories - # like /repo/packages/my-app that won't match worktree root prefixes) - canonical_project_dir = _find_project_dir(canonical_dir) - if canonical_project_dir is not None: - dir_base = canonical_project_dir.name - seen_dirs.add(dir_base.lower() if case_insensitive else dir_base) - sessions = _read_sessions_from_dir(canonical_project_dir, canonical_dir) - all_sessions.extend(sessions) - - for entry in all_dirents: - dir_name = entry.name.lower() if case_insensitive else entry.name - if dir_name in seen_dirs: - continue - - for wt_path, prefix in indexed: - # Only use startswith for truncated paths (>MAX_SANITIZED_LENGTH) - # where a hash suffix follows. For short paths, require exact match - # to avoid /root/project matching /root/project-foo. - is_match = dir_name == prefix or ( - len(prefix) >= MAX_SANITIZED_LENGTH - and dir_name.startswith(prefix + "-") - ) - if is_match: - seen_dirs.add(dir_name) - sessions = _read_sessions_from_dir(entry, wt_path) - all_sessions.extend(sessions) - break - - deduped = _deduplicate_by_session_id(all_sessions) - return _apply_sort_and_limit(deduped, limit) - - -def _list_all_sessions(limit: int | None) -> list[SDKSessionInfo]: - """Lists sessions across all project directories.""" - projects_dir = _get_projects_dir() - - try: - project_dirs = [e for e in projects_dir.iterdir() if e.is_dir()] - except OSError: - return [] - - all_sessions: list[SDKSessionInfo] = [] - for project_dir in project_dirs: - all_sessions.extend(_read_sessions_from_dir(project_dir)) - - deduped = _deduplicate_by_session_id(all_sessions) - return _apply_sort_and_limit(deduped, limit) - - -def list_sessions( - directory: str | None = None, - limit: int | None = None, - include_worktrees: bool = True, -) -> list[SDKSessionInfo]: - """Lists sessions with metadata extracted from stat + head/tail reads. - - When ``directory`` is provided, returns sessions for that project - directory and its git worktrees. When omitted, returns sessions - across all projects. - - Args: - directory: Directory to list sessions for. When provided, returns - sessions for this project directory (and optionally its git - worktrees). When omitted, returns sessions across all projects. - limit: Maximum number of sessions to return. - include_worktrees: When ``directory`` is provided and the directory - is inside a git repository, include sessions from all git - worktree paths. Defaults to ``True``. - - Returns: - List of ``SDKSessionInfo`` sorted by ``last_modified`` descending. - - Example: - List sessions for a specific project:: - - sessions = list_sessions(directory="/path/to/project") - - List all sessions across all projects:: - - all_sessions = list_sessions() - - List sessions without scanning git worktrees:: - - sessions = list_sessions( - directory="/path/to/project", - include_worktrees=False, - ) - """ - if directory: - return _list_sessions_for_project(directory, limit, include_worktrees) - return _list_all_sessions(limit) - - -# --------------------------------------------------------------------------- -# get_session_info — single-session metadata lookup -# --------------------------------------------------------------------------- - - -def get_session_info( - session_id: str, - directory: str | None = None, -) -> SDKSessionInfo | None: - """Reads metadata for a single session by ID. - - Wraps ``_read_session_lite`` for one file — no O(n) directory scan. - Directory resolution matches ``get_session_messages``: ``directory`` is - the project path; when omitted, all project directories are searched for - the session file. - - Args: - session_id: UUID of the session to look up. - directory: Project directory path (same semantics as - ``list_sessions(directory=...)``). When omitted, all project - directories are searched for the session file. - - Returns: - ``SDKSessionInfo`` for the session, or ``None`` if the session file - is not found, is a sidechain session, or has no extractable summary. - - Example: - Look up a session in a specific project:: - - info = get_session_info( - "550e8400-e29b-41d4-a716-446655440000", - directory="/path/to/project", - ) - if info: - print(info.summary) - - Search all projects for a session:: - - info = get_session_info("550e8400-e29b-41d4-a716-446655440000") - """ - uuid = _validate_uuid(session_id) - if not uuid: - return None - file_name = f"{uuid}.jsonl" - - if directory: - canonical = _canonicalize_path(directory) - project_dir = _find_project_dir(canonical) - if project_dir is not None: - lite = _read_session_lite(project_dir / file_name) - if lite is not None: - return _parse_session_info_from_lite(uuid, lite, canonical) - - # Worktree fallback — matches get_session_messages semantics. - # Sessions may live under a different worktree root. - try: - worktree_paths = _get_worktree_paths(canonical) - except Exception: - worktree_paths = [] - for wt in worktree_paths: - if wt == canonical: - continue - wt_project_dir = _find_project_dir(wt) - if wt_project_dir is not None: - lite = _read_session_lite(wt_project_dir / file_name) - if lite is not None: - return _parse_session_info_from_lite(uuid, lite, wt) - - return None - - # No directory — search all project directories for the session file. - projects_dir = _get_projects_dir() - try: - dirents = [e for e in projects_dir.iterdir() if e.is_dir()] - except OSError: - return None - for entry in dirents: - lite = _read_session_lite(entry / file_name) - if lite is not None: - return _parse_session_info_from_lite(uuid, lite) - return None - - -# --------------------------------------------------------------------------- -# get_session_messages — full transcript reconstruction -# --------------------------------------------------------------------------- - -# Transcript entry types that carry uuid + parentUuid chain links. -_TRANSCRIPT_ENTRY_TYPES = frozenset( - {"user", "assistant", "progress", "system", "attachment"} -) - -# Internal type for parsed JSONL transcript entries — mirrors the TS -# TranscriptEntry type but as a loose dict (fields: type, uuid, parentUuid, -# sessionId, message, isSidechain, isMeta, isCompactSummary, teamName). -_TranscriptEntry = dict[str, Any] - - -def _try_read_session_file(project_dir: Path, file_name: str) -> str | None: - """Tries to read a session JSONL file from a project directory.""" - try: - return (project_dir / file_name).read_text(encoding="utf-8") - except OSError: - return None - - -def _read_session_file(session_id: str, directory: str | None) -> str | None: - """Finds and reads the session JSONL file. - - If directory is provided, looks in that project directory and its git - worktrees (with prefix-fallback for Bun/Node hash mismatches on long - paths). Otherwise, searches all project directories. - - Returns the file content, or None if not found. - """ - file_name = f"{session_id}.jsonl" - - if directory: - canonical_dir = _canonicalize_path(directory) - - # Try the exact/prefix-matched project directory first - project_dir = _find_project_dir(canonical_dir) - if project_dir is not None: - content = _try_read_session_file(project_dir, file_name) - if content: - return content - - # Try worktree paths — sessions may live under a different worktree root - try: - worktree_paths = _get_worktree_paths(canonical_dir) - except Exception: - worktree_paths = [] - - for wt in worktree_paths: - if wt == canonical_dir: - continue # already tried above - wt_project_dir = _find_project_dir(wt) - if wt_project_dir is not None: - content = _try_read_session_file(wt_project_dir, file_name) - if content: - return content - - return None - - # No directory provided — search all project directories - projects_dir = _get_projects_dir() - try: - dirents = list(projects_dir.iterdir()) - except OSError: - return None - - for entry in dirents: - content = _try_read_session_file(entry, file_name) - if content: - return content - - return None - - -def _parse_transcript_entries(content: str) -> list[_TranscriptEntry]: - """Parses JSONL content into transcript entries. - - Only keeps entries that have a uuid and are transcript message types - (user/assistant/progress/system/attachment). Skips corrupt lines. - """ - entries: list[_TranscriptEntry] = [] - start = 0 - length = len(content) - - while start < length: - end = content.find("\n", start) - if end == -1: - end = length - - line = content[start:end].strip() - start = end + 1 - if not line: - continue - - try: - entry = json.loads(line) - except (json.JSONDecodeError, ValueError): - continue - - if not isinstance(entry, dict): - continue - entry_type = entry.get("type") - if entry_type in _TRANSCRIPT_ENTRY_TYPES and isinstance(entry.get("uuid"), str): - entries.append(entry) - - return entries - - -def _build_conversation_chain( - entries: list[_TranscriptEntry], -) -> list[_TranscriptEntry]: - """Builds the conversation chain by finding the leaf and walking parentUuid. - - Returns messages in chronological order (root → leaf). - - Note: logicalParentUuid (set on compact_boundary entries) is intentionally - NOT followed. This matches VS Code IDE behavior — post-compaction, the - isCompactSummary message replaces earlier messages, so following logical - parents would duplicate content. - """ - if not entries: - return [] - - # Index by uuid for O(1) parent lookup - by_uuid: dict[str, _TranscriptEntry] = {} - for entry in entries: - by_uuid[entry["uuid"]] = entry - - # Build index of entry positions (file order) for tie-breaking - entry_index: dict[str, int] = {} - for i, entry in enumerate(entries): - entry_index[entry["uuid"]] = i - - # Find terminal messages (no children point to them via parentUuid) - parent_uuids: set[str] = set() - for entry in entries: - parent = entry.get("parentUuid") - if parent: - parent_uuids.add(parent) - - terminals = [e for e in entries if e["uuid"] not in parent_uuids] - - # From each terminal, walk back to find the nearest user/assistant leaf - leaves: list[_TranscriptEntry] = [] - for terminal in terminals: - walk_cur: _TranscriptEntry | None = terminal - walk_seen: set[str] = set() - while walk_cur is not None: - uid = walk_cur["uuid"] - if uid in walk_seen: - break - walk_seen.add(uid) - if walk_cur.get("type") in ("user", "assistant"): - leaves.append(walk_cur) - break - parent = walk_cur.get("parentUuid") - walk_cur = by_uuid.get(parent) if parent else None - - if not leaves: - return [] - - # Pick the leaf from the main chain (not sidechain/team/meta), preferring - # the highest position in the entries array (most recent in file) - main_leaves = [ - leaf - for leaf in leaves - if not leaf.get("isSidechain") - and not leaf.get("teamName") - and not leaf.get("isMeta") - ] - - def _pick_best(candidates: list[_TranscriptEntry]) -> _TranscriptEntry: - best = candidates[0] - best_idx = entry_index.get(best["uuid"], -1) - for cur in candidates[1:]: - cur_idx = entry_index.get(cur["uuid"], -1) - if cur_idx > best_idx: - best = cur - best_idx = cur_idx - return best - - leaf = _pick_best(main_leaves) if main_leaves else _pick_best(leaves) - - # Walk from leaf to root via parentUuid - chain: list[_TranscriptEntry] = [] - chain_seen: set[str] = set() - chain_cur: _TranscriptEntry | None = leaf - while chain_cur is not None: - uid = chain_cur["uuid"] - if uid in chain_seen: - break - chain_seen.add(uid) - chain.append(chain_cur) - parent = chain_cur.get("parentUuid") - chain_cur = by_uuid.get(parent) if parent else None - - chain.reverse() - return chain - - -def _is_visible_message(entry: _TranscriptEntry) -> bool: - """Returns True if the entry should be included in the returned messages.""" - entry_type = entry.get("type") - if entry_type != "user" and entry_type != "assistant": - return False - if entry.get("isMeta"): - return False - if entry.get("isSidechain"): - return False - # Note: isCompactSummary messages are intentionally included. They contain - # the summarized content from compacted conversations and are the only - # representation of that content post-compaction. This matches VS Code IDE - # behavior (transcriptToSessionMessage does not filter them). - return not entry.get("teamName") - - -def _to_session_message(entry: _TranscriptEntry) -> SessionMessage: - """Converts a transcript entry dict into a SessionMessage.""" - entry_type = entry.get("type") - # Narrow to the Literal type — _is_visible_message already guarantees - # this is "user" or "assistant". - msg_type: str = "user" if entry_type == "user" else "assistant" - return SessionMessage( - type=msg_type, # type: ignore[arg-type] - uuid=entry.get("uuid", ""), - session_id=entry.get("sessionId", ""), - message=entry.get("message"), - parent_tool_use_id=None, - ) - - -def get_session_messages( - session_id: str, - directory: str | None = None, - limit: int | None = None, - offset: int = 0, -) -> list[SessionMessage]: - """Reads a session's conversation messages from its JSONL transcript file. - - Parses the full JSONL, builds the conversation chain via ``parentUuid`` - links, and returns user/assistant messages in chronological order. - - Args: - session_id: UUID of the session to read. - directory: Project directory to find the session in. If omitted, - searches all project directories under ``~/.claude/projects/``. - limit: Maximum number of messages to return. - offset: Number of messages to skip from the start. - - Returns: - List of ``SessionMessage`` objects in chronological order. Returns - an empty list if the session is not found, the session_id is not a - valid UUID, or the transcript contains no visible messages. - - Example: - Read all messages from a session:: - - messages = get_session_messages( - "550e8400-e29b-41d4-a716-446655440000", - directory="/path/to/project", - ) - for msg in messages: - print(msg.type, msg.message) - - Read with pagination:: - - page = get_session_messages( - session_id, limit=10, offset=20 - ) - """ - if not _validate_uuid(session_id): - return [] - - content = _read_session_file(session_id, directory) - if not content: - return [] - - entries = _parse_transcript_entries(content) - chain = _build_conversation_chain(entries) - visible = [e for e in chain if _is_visible_message(e)] - messages = [_to_session_message(e) for e in visible] - - # Apply offset and limit - if limit is not None and limit > 0: - return messages[offset : offset + limit] - if offset > 0: - return messages[offset:] - return messages +placeholder \ No newline at end of file diff --git a/src/claude_agent_sdk/types.py b/src/claude_agent_sdk/types.py index 9c8e00c4..b3a42524 100644 --- a/src/claude_agent_sdk/types.py +++ b/src/claude_agent_sdk/types.py @@ -1,1210 +1 @@ -"""Type definitions for Claude SDK.""" - -import sys -from collections.abc import Awaitable, Callable -from dataclasses import dataclass, field -from pathlib import Path -from typing import TYPE_CHECKING, Any, Literal, TypedDict - -from typing_extensions import NotRequired - -if TYPE_CHECKING: - from mcp.server import Server as McpServer -else: - # Runtime placeholder for forward reference resolution in Pydantic 2.12+ - McpServer = Any - -# Permission modes -PermissionMode = Literal["default", "acceptEdits", "plan", "bypassPermissions"] - -# SDK Beta features - see https://docs.anthropic.com/en/api/beta-headers -SdkBeta = Literal["context-1m-2025-08-07"] - -# Agent definitions -SettingSource = Literal["user", "project", "local"] - - -class SystemPromptPreset(TypedDict): - """System prompt preset configuration.""" - - type: Literal["preset"] - preset: Literal["claude_code"] - append: NotRequired[str] - - -class ToolsPreset(TypedDict): - """Tools preset configuration.""" - - type: Literal["preset"] - preset: Literal["claude_code"] - - -@dataclass -class AgentDefinition: - """Agent definition configuration.""" - - description: str - prompt: str - tools: list[str] | None = None - model: Literal["sonnet", "opus", "haiku", "inherit"] | None = None - skills: list[str] | None = None - memory: Literal["user", "project", "local"] | None = None - # Each entry is a server name (str) or an inline {name: config} dict. - mcpServers: list[str | dict[str, Any]] | None = None # noqa: N815 - - -# Permission Update types (matching TypeScript SDK) -PermissionUpdateDestination = Literal[ - "userSettings", "projectSettings", "localSettings", "session" -] - -PermissionBehavior = Literal["allow", "deny", "ask"] - - -@dataclass -class PermissionRuleValue: - """Permission rule value.""" - - tool_name: str - rule_content: str | None = None - - -@dataclass -class PermissionUpdate: - """Permission update configuration.""" - - type: Literal[ - "addRules", - "replaceRules", - "removeRules", - "setMode", - "addDirectories", - "removeDirectories", - ] - rules: list[PermissionRuleValue] | None = None - behavior: PermissionBehavior | None = None - mode: PermissionMode | None = None - directories: list[str] | None = None - destination: PermissionUpdateDestination | None = None - - def to_dict(self) -> dict[str, Any]: - """Convert PermissionUpdate to dictionary format matching TypeScript control protocol.""" - result: dict[str, Any] = { - "type": self.type, - } - - # Add destination for all variants - if self.destination is not None: - result["destination"] = self.destination - - # Handle different type variants - if self.type in ["addRules", "replaceRules", "removeRules"]: - # Rules-based variants require rules and behavior - if self.rules is not None: - result["rules"] = [ - { - "toolName": rule.tool_name, - "ruleContent": rule.rule_content, - } - for rule in self.rules - ] - if self.behavior is not None: - result["behavior"] = self.behavior - - elif self.type == "setMode": - # Mode variant requires mode - if self.mode is not None: - result["mode"] = self.mode - - elif self.type in ["addDirectories", "removeDirectories"]: - # Directory variants require directories - if self.directories is not None: - result["directories"] = self.directories - - return result - - -# Tool callback types -@dataclass -class ToolPermissionContext: - """Context information for tool permission callbacks.""" - - signal: Any | None = None # Future: abort signal support - suggestions: list[PermissionUpdate] = field( - default_factory=list - ) # Permission suggestions from CLI - - -# Match TypeScript's PermissionResult structure -@dataclass -class PermissionResultAllow: - """Allow permission result.""" - - behavior: Literal["allow"] = "allow" - updated_input: dict[str, Any] | None = None - updated_permissions: list[PermissionUpdate] | None = None - - -@dataclass -class PermissionResultDeny: - """Deny permission result.""" - - behavior: Literal["deny"] = "deny" - message: str = "" - interrupt: bool = False - - -PermissionResult = PermissionResultAllow | PermissionResultDeny - -CanUseTool = Callable[ - [str, dict[str, Any], ToolPermissionContext], Awaitable[PermissionResult] -] - - -##### Hook types -HookEvent = ( - Literal["PreToolUse"] - | Literal["PostToolUse"] - | Literal["PostToolUseFailure"] - | Literal["UserPromptSubmit"] - | Literal["Stop"] - | Literal["SubagentStop"] - | Literal["PreCompact"] - | Literal["Notification"] - | Literal["SubagentStart"] - | Literal["PermissionRequest"] -) - - -# Hook input types - strongly typed for each hook event -class BaseHookInput(TypedDict): - """Base hook input fields present across many hook events.""" - - session_id: str - transcript_path: str - cwd: str - permission_mode: NotRequired[str] - - -# agent_id/agent_type are present on BaseHookInput in the CLI's schema but are -# declared per-hook here because SubagentStartHookInput/SubagentStopHookInput -# need them as *required*, and PEP 655 forbids narrowing NotRequired->Required -# in a TypedDict subclass. The four tool-lifecycle types below are the only -# ones the CLI actually populates (the other BaseHookInput consumers don't -# have a toolUseContext in scope at their build site). -class _SubagentContextMixin(TypedDict, total=False): - """Optional sub-agent attribution fields for tool-lifecycle hooks. - - agent_id: Sub-agent identifier. Present only when the hook fires from - inside a Task-spawned sub-agent; absent on the main thread. Matches the - agent_id emitted by that sub-agent's SubagentStart/SubagentStop hooks. - When multiple sub-agents run in parallel their tool-lifecycle hooks - interleave over the same control channel — this is the only reliable - way to attribute each one to the correct sub-agent. - - agent_type: Agent type name (e.g. "general-purpose", "code-reviewer"). - Present inside a sub-agent (alongside agent_id), or on the main thread - of a session started with --agent (without agent_id). - """ - - agent_id: str - agent_type: str - - -class PreToolUseHookInput(BaseHookInput, _SubagentContextMixin): - """Input data for PreToolUse hook events.""" - - hook_event_name: Literal["PreToolUse"] - tool_name: str - tool_input: dict[str, Any] - tool_use_id: str - - -class PostToolUseHookInput(BaseHookInput, _SubagentContextMixin): - """Input data for PostToolUse hook events.""" - - hook_event_name: Literal["PostToolUse"] - tool_name: str - tool_input: dict[str, Any] - tool_response: Any - tool_use_id: str - - -class PostToolUseFailureHookInput(BaseHookInput, _SubagentContextMixin): - """Input data for PostToolUseFailure hook events.""" - - hook_event_name: Literal["PostToolUseFailure"] - tool_name: str - tool_input: dict[str, Any] - tool_use_id: str - error: str - is_interrupt: NotRequired[bool] - - -class UserPromptSubmitHookInput(BaseHookInput): - """Input data for UserPromptSubmit hook events.""" - - hook_event_name: Literal["UserPromptSubmit"] - prompt: str - - -class StopHookInput(BaseHookInput): - """Input data for Stop hook events.""" - - hook_event_name: Literal["Stop"] - stop_hook_active: bool - - -class SubagentStopHookInput(BaseHookInput): - """Input data for SubagentStop hook events.""" - - hook_event_name: Literal["SubagentStop"] - stop_hook_active: bool - agent_id: str - agent_transcript_path: str - agent_type: str - - -class PreCompactHookInput(BaseHookInput): - """Input data for PreCompact hook events.""" - - hook_event_name: Literal["PreCompact"] - trigger: Literal["manual", "auto"] - custom_instructions: str | None - - -class NotificationHookInput(BaseHookInput): - """Input data for Notification hook events.""" - - hook_event_name: Literal["Notification"] - message: str - title: NotRequired[str] - notification_type: str - - -class SubagentStartHookInput(BaseHookInput): - """Input data for SubagentStart hook events.""" - - hook_event_name: Literal["SubagentStart"] - agent_id: str - agent_type: str - - -class PermissionRequestHookInput(BaseHookInput, _SubagentContextMixin): - """Input data for PermissionRequest hook events.""" - - hook_event_name: Literal["PermissionRequest"] - tool_name: str - tool_input: dict[str, Any] - permission_suggestions: NotRequired[list[Any]] - - -# Union type for all hook inputs -HookInput = ( - PreToolUseHookInput - | PostToolUseHookInput - | PostToolUseFailureHookInput - | UserPromptSubmitHookInput - | StopHookInput - | SubagentStopHookInput - | PreCompactHookInput - | NotificationHookInput - | SubagentStartHookInput - | PermissionRequestHookInput -) - - -# Hook-specific output types -class PreToolUseHookSpecificOutput(TypedDict): - """Hook-specific output for PreToolUse events.""" - - hookEventName: Literal["PreToolUse"] - permissionDecision: NotRequired[Literal["allow", "deny", "ask"]] - permissionDecisionReason: NotRequired[str] - updatedInput: NotRequired[dict[str, Any]] - additionalContext: NotRequired[str] - - -class PostToolUseHookSpecificOutput(TypedDict): - """Hook-specific output for PostToolUse events.""" - - hookEventName: Literal["PostToolUse"] - additionalContext: NotRequired[str] - updatedMCPToolOutput: NotRequired[Any] - - -class PostToolUseFailureHookSpecificOutput(TypedDict): - """Hook-specific output for PostToolUseFailure events.""" - - hookEventName: Literal["PostToolUseFailure"] - additionalContext: NotRequired[str] - - -class UserPromptSubmitHookSpecificOutput(TypedDict): - """Hook-specific output for UserPromptSubmit events.""" - - hookEventName: Literal["UserPromptSubmit"] - additionalContext: NotRequired[str] - - -class SessionStartHookSpecificOutput(TypedDict): - """Hook-specific output for SessionStart events.""" - - hookEventName: Literal["SessionStart"] - additionalContext: NotRequired[str] - - -class NotificationHookSpecificOutput(TypedDict): - """Hook-specific output for Notification events.""" - - hookEventName: Literal["Notification"] - additionalContext: NotRequired[str] - - -class SubagentStartHookSpecificOutput(TypedDict): - """Hook-specific output for SubagentStart events.""" - - hookEventName: Literal["SubagentStart"] - additionalContext: NotRequired[str] - - -class PermissionRequestHookSpecificOutput(TypedDict): - """Hook-specific output for PermissionRequest events.""" - - hookEventName: Literal["PermissionRequest"] - decision: dict[str, Any] - - -HookSpecificOutput = ( - PreToolUseHookSpecificOutput - | PostToolUseHookSpecificOutput - | PostToolUseFailureHookSpecificOutput - | UserPromptSubmitHookSpecificOutput - | SessionStartHookSpecificOutput - | NotificationHookSpecificOutput - | SubagentStartHookSpecificOutput - | PermissionRequestHookSpecificOutput -) - - -# See https://docs.anthropic.com/en/docs/claude-code/hooks#advanced%3A-json-output -# for documentation of the output types. -# -# IMPORTANT: The Python SDK uses `async_` and `continue_` (with underscores) to avoid -# Python keyword conflicts. These fields are automatically converted to `async` and -# `continue` when sent to the CLI. You should use the underscore versions in your -# Python code. -class AsyncHookJSONOutput(TypedDict): - """Async hook output that defers hook execution. - - Fields: - async_: Set to True to defer hook execution. Note: This is converted to - "async" when sent to the CLI - use "async_" in your Python code. - asyncTimeout: Optional timeout in milliseconds for the async operation. - """ - - async_: Literal[ - True - ] # Using async_ to avoid Python keyword (converted to "async" for CLI) - asyncTimeout: NotRequired[int] - - -class SyncHookJSONOutput(TypedDict): - """Synchronous hook output with control and decision fields. - - This defines the structure for hook callbacks to control execution and provide - feedback to Claude. - - Common Control Fields: - continue_: Whether Claude should proceed after hook execution (default: True). - Note: This is converted to "continue" when sent to the CLI. - suppressOutput: Hide stdout from transcript mode (default: False). - stopReason: Message shown when continue is False. - - Decision Fields: - decision: Set to "block" to indicate blocking behavior. - systemMessage: Warning message displayed to the user. - reason: Feedback message for Claude about the decision. - - Hook-Specific Output: - hookSpecificOutput: Event-specific controls (e.g., permissionDecision for - PreToolUse, additionalContext for PostToolUse). - - Note: The CLI documentation shows field names without underscores ("async", "continue"), - but Python code should use the underscore versions ("async_", "continue_") as they - are automatically converted. - """ - - # Common control fields - continue_: NotRequired[ - bool - ] # Using continue_ to avoid Python keyword (converted to "continue" for CLI) - suppressOutput: NotRequired[bool] - stopReason: NotRequired[str] - - # Decision fields - # Note: "approve" is deprecated for PreToolUse (use permissionDecision instead) - # For other hooks, only "block" is meaningful - decision: NotRequired[Literal["block"]] - systemMessage: NotRequired[str] - reason: NotRequired[str] - - # Hook-specific outputs - hookSpecificOutput: NotRequired[HookSpecificOutput] - - -HookJSONOutput = AsyncHookJSONOutput | SyncHookJSONOutput - - -class HookContext(TypedDict): - """Context information for hook callbacks. - - Fields: - signal: Reserved for future abort signal support. Currently always None. - """ - - signal: Any | None # Future: abort signal support - - -HookCallback = Callable[ - # HookCallback input parameters: - # - input: Strongly-typed hook input with discriminated unions based on hook_event_name - # - tool_use_id: Optional tool use identifier - # - context: Hook context with abort signal support (currently placeholder) - [HookInput, str | None, HookContext], - Awaitable[HookJSONOutput], -] - - -# Hook matcher configuration -@dataclass -class HookMatcher: - """Hook matcher configuration.""" - - # See https://docs.anthropic.com/en/docs/claude-code/hooks#structure for the - # expected string value. For example, for PreToolUse, the matcher can be - # a tool name like "Bash" or a combination of tool names like - # "Write|MultiEdit|Edit". - matcher: str | None = None - - # A list of Python functions with function signature HookCallback - hooks: list[HookCallback] = field(default_factory=list) - - # Timeout in seconds for all hooks in this matcher (default: 60) - timeout: float | None = None - - -# MCP Server config -class McpStdioServerConfig(TypedDict): - """MCP stdio server configuration.""" - - type: NotRequired[Literal["stdio"]] # Optional for backwards compatibility - command: str - args: NotRequired[list[str]] - env: NotRequired[dict[str, str]] - - -class McpSSEServerConfig(TypedDict): - """MCP SSE server configuration.""" - - type: Literal["sse"] - url: str - headers: NotRequired[dict[str, str]] - - -class McpHttpServerConfig(TypedDict): - """MCP HTTP server configuration.""" - - type: Literal["http"] - url: str - headers: NotRequired[dict[str, str]] - - -class McpSdkServerConfig(TypedDict): - """SDK MCP server configuration.""" - - type: Literal["sdk"] - name: str - instance: "McpServer" - - -McpServerConfig = ( - McpStdioServerConfig | McpSSEServerConfig | McpHttpServerConfig | McpSdkServerConfig -) - - -# MCP Server Status types (returned by get_mcp_status) -# These mirror the TypeScript SDK's McpServerStatus type and use wire-format -# field names (camelCase where applicable) since they come directly from CLI -# JSON output. - - -class McpSdkServerConfigStatus(TypedDict): - """SDK MCP server config as returned in status responses. - - Unlike McpSdkServerConfig (which includes the in-process `instance`), - this output-only type only has serializable fields. - """ - - type: Literal["sdk"] - name: str - - -class McpClaudeAIProxyServerConfig(TypedDict): - """Claude.ai proxy MCP server config. - - Output-only type that appears in status responses for servers proxied - through Claude.ai. - """ - - type: Literal["claudeai-proxy"] - url: str - id: str - - -# Broader config type for status responses (includes claudeai-proxy which is -# output-only) -McpServerStatusConfig = ( - McpStdioServerConfig - | McpSSEServerConfig - | McpHttpServerConfig - | McpSdkServerConfigStatus - | McpClaudeAIProxyServerConfig -) - - -class McpToolAnnotations(TypedDict, total=False): - """Tool annotations as returned in MCP server status. - - Wire format uses camelCase field names (from CLI JSON output). - """ - - readOnly: bool - destructive: bool - openWorld: bool - - -class McpToolInfo(TypedDict): - """Information about a tool provided by an MCP server.""" - - name: str - description: NotRequired[str] - annotations: NotRequired[McpToolAnnotations] - - -class McpServerInfo(TypedDict): - """Server info from MCP initialize handshake (available when connected).""" - - name: str - version: str - - -# Connection status values for an MCP server -McpServerConnectionStatus = Literal[ - "connected", "failed", "needs-auth", "pending", "disabled" -] - - -class McpServerStatus(TypedDict): - """Status information for an MCP server connection. - - Returned by `ClaudeSDKClient.get_mcp_status()` in the `mcpServers` list. - """ - - name: str - """Server name as configured.""" - - status: McpServerConnectionStatus - """Current connection status.""" - - serverInfo: NotRequired[McpServerInfo] - """Server information from MCP handshake (available when connected).""" - - error: NotRequired[str] - """Error message (available when status is 'failed').""" - - config: NotRequired[McpServerStatusConfig] - """Server configuration (includes URL for HTTP/SSE servers).""" - - scope: NotRequired[str] - """Configuration scope (e.g., project, user, local, claudeai, managed).""" - - tools: NotRequired[list[McpToolInfo]] - """Tools provided by this server (available when connected).""" - - -class McpStatusResponse(TypedDict): - """Response from `ClaudeSDKClient.get_mcp_status()`. - - Wraps the list of server statuses under the `mcpServers` key, matching - the wire-format response shape. - """ - - mcpServers: list[McpServerStatus] - - -class SdkPluginConfig(TypedDict): - """SDK plugin configuration. - - Currently only local plugins are supported via the 'local' type. - """ - - type: Literal["local"] - path: str - - -# Sandbox configuration types -class SandboxNetworkConfig(TypedDict, total=False): - """Network configuration for sandbox. - - Attributes: - allowUnixSockets: Unix socket paths accessible in sandbox (e.g., SSH agents). - allowAllUnixSockets: Allow all Unix sockets (less secure). - allowLocalBinding: Allow binding to localhost ports (macOS only). - httpProxyPort: HTTP proxy port if bringing your own proxy. - socksProxyPort: SOCKS5 proxy port if bringing your own proxy. - """ - - allowUnixSockets: list[str] - allowAllUnixSockets: bool - allowLocalBinding: bool - httpProxyPort: int - socksProxyPort: int - - -class SandboxIgnoreViolations(TypedDict, total=False): - """Violations to ignore in sandbox. - - Attributes: - file: File paths for which violations should be ignored. - network: Network hosts for which violations should be ignored. - """ - - file: list[str] - network: list[str] - - -class SandboxSettings(TypedDict, total=False): - """Sandbox settings configuration. - - This controls how Claude Code sandboxes bash commands for filesystem - and network isolation. - - **Important:** Filesystem and network restrictions are configured via permission - rules, not via these sandbox settings: - - Filesystem read restrictions: Use Read deny rules - - Filesystem write restrictions: Use Edit allow/deny rules - - Network restrictions: Use WebFetch allow/deny rules - - Attributes: - enabled: Enable bash sandboxing (macOS/Linux only). Default: False - autoAllowBashIfSandboxed: Auto-approve bash commands when sandboxed. Default: True - excludedCommands: Commands that should run outside the sandbox (e.g., ["git", "docker"]) - allowUnsandboxedCommands: Allow commands to bypass sandbox via dangerouslyDisableSandbox. - When False, all commands must run sandboxed (or be in excludedCommands). Default: True - network: Network configuration for sandbox. - ignoreViolations: Violations to ignore. - enableWeakerNestedSandbox: Enable weaker sandbox for unprivileged Docker environments - (Linux only). Reduces security. Default: False - - Example: - ```python - sandbox_settings: SandboxSettings = { - "enabled": True, - "autoAllowBashIfSandboxed": True, - "excludedCommands": ["docker"], - "network": { - "allowUnixSockets": ["/var/run/docker.sock"], - "allowLocalBinding": True - } - } - ``` - """ - - enabled: bool - autoAllowBashIfSandboxed: bool - excludedCommands: list[str] - allowUnsandboxedCommands: bool - network: SandboxNetworkConfig - ignoreViolations: SandboxIgnoreViolations - enableWeakerNestedSandbox: bool - - -# Content block types -@dataclass -class TextBlock: - """Text content block.""" - - text: str - - -@dataclass -class ThinkingBlock: - """Thinking content block.""" - - thinking: str - signature: str - - -@dataclass -class ToolUseBlock: - """Tool use content block.""" - - id: str - name: str - input: dict[str, Any] - - -@dataclass -class ToolResultBlock: - """Tool result content block.""" - - tool_use_id: str - content: str | list[dict[str, Any]] | None = None - is_error: bool | None = None - - -ContentBlock = TextBlock | ThinkingBlock | ToolUseBlock | ToolResultBlock - - -# Message types -AssistantMessageError = Literal[ - "authentication_failed", - "billing_error", - "rate_limit", - "invalid_request", - "server_error", - "unknown", -] - - -@dataclass -class UserMessage: - """User message.""" - - content: str | list[ContentBlock] - uuid: str | None = None - parent_tool_use_id: str | None = None - tool_use_result: dict[str, Any] | None = None - - -@dataclass -class AssistantMessage: - """Assistant message with content blocks.""" - - content: list[ContentBlock] - model: str - parent_tool_use_id: str | None = None - error: AssistantMessageError | None = None - usage: dict[str, Any] | None = None - - -@dataclass -class SystemMessage: - """System message with metadata.""" - - subtype: str - data: dict[str, Any] - - -class TaskUsage(TypedDict): - """Usage statistics reported in task_progress and task_notification messages.""" - - total_tokens: int - tool_uses: int - duration_ms: int - - -# Possible status values for a task_notification message. -TaskNotificationStatus = Literal["completed", "failed", "stopped"] - - -@dataclass -class TaskStartedMessage(SystemMessage): - """System message emitted when a task starts. - - Subclass of SystemMessage: existing ``isinstance(msg, SystemMessage)`` and - ``case SystemMessage()`` checks continue to match. The base ``subtype`` - and ``data`` fields remain populated with the raw payload. - """ - - task_id: str - description: str - uuid: str - session_id: str - tool_use_id: str | None = None - task_type: str | None = None - - -@dataclass -class TaskProgressMessage(SystemMessage): - """System message emitted while a task is in progress. - - Subclass of SystemMessage: existing ``isinstance(msg, SystemMessage)`` and - ``case SystemMessage()`` checks continue to match. The base ``subtype`` - and ``data`` fields remain populated with the raw payload. - """ - - task_id: str - description: str - usage: TaskUsage - uuid: str - session_id: str - tool_use_id: str | None = None - last_tool_name: str | None = None - - -@dataclass -class TaskNotificationMessage(SystemMessage): - """System message emitted when a task completes, fails, or is stopped. - - Subclass of SystemMessage: existing ``isinstance(msg, SystemMessage)`` and - ``case SystemMessage()`` checks continue to match. The base ``subtype`` - and ``data`` fields remain populated with the raw payload. - """ - - task_id: str - status: TaskNotificationStatus - output_file: str - summary: str - uuid: str - session_id: str - tool_use_id: str | None = None - usage: TaskUsage | None = None - - -@dataclass -class ResultMessage: - """Result message with cost and usage information.""" - - subtype: str - duration_ms: int - duration_api_ms: int - is_error: bool - num_turns: int - session_id: str - stop_reason: str | None = None - total_cost_usd: float | None = None - usage: dict[str, Any] | None = None - result: str | None = None - structured_output: Any = None - - -@dataclass -class StreamEvent: - """Stream event for partial message updates during streaming.""" - - uuid: str - session_id: str - event: dict[str, Any] # The raw Anthropic API stream event - parent_tool_use_id: str | None = None - - -# Rate limit types — see https://docs.claude.com/en/docs/claude-code/rate-limits -RateLimitStatus = Literal["allowed", "allowed_warning", "rejected"] -RateLimitType = Literal[ - "five_hour", "seven_day", "seven_day_opus", "seven_day_sonnet", "overage" -] - - -@dataclass -class RateLimitInfo: - """Rate limit status emitted by the CLI when rate limit state changes. - - Attributes: - status: Current rate limit status. ``allowed_warning`` means approaching - the limit; ``rejected`` means the limit has been hit. - resets_at: Unix timestamp when the rate limit window resets. - rate_limit_type: Which rate limit window applies. - utilization: Fraction of the rate limit consumed (0.0 - 1.0). - overage_status: Status of overage/pay-as-you-go usage if applicable. - overage_resets_at: Unix timestamp when overage window resets. - overage_disabled_reason: Why overage is unavailable if status is rejected. - raw: Full raw dict from the CLI, including any fields not modeled above. - """ - - status: RateLimitStatus - resets_at: int | None = None - rate_limit_type: RateLimitType | None = None - utilization: float | None = None - overage_status: RateLimitStatus | None = None - overage_resets_at: int | None = None - overage_disabled_reason: str | None = None - raw: dict[str, Any] = field(default_factory=dict) - - -@dataclass -class RateLimitEvent: - """Rate limit event emitted when rate limit info changes. - - The CLI emits this whenever the rate limit status transitions (e.g. from - ``allowed`` to ``allowed_warning``). Use this to warn users before they - hit a hard limit, or to gracefully back off when ``status == "rejected"``. - """ - - rate_limit_info: RateLimitInfo - uuid: str - session_id: str - - -Message = ( - UserMessage - | AssistantMessage - | SystemMessage - | ResultMessage - | StreamEvent - | RateLimitEvent -) - - -# --------------------------------------------------------------------------- -# Session Listing Types -# --------------------------------------------------------------------------- - - -@dataclass -class SDKSessionInfo: - """Session metadata returned by ``list_sessions()``. - - Contains only data extractable from stat + head/tail reads — no full - JSONL parsing required. - - Attributes: - session_id: Unique session identifier (UUID). - summary: Display title for the session — custom title, auto-generated - summary, or first prompt. - last_modified: Last modified time in milliseconds since epoch. - file_size: Session file size in bytes. Only populated for local - JSONL storage; may be ``None`` for remote storage backends. - custom_title: User-set session title via /rename. - first_prompt: First meaningful user prompt in the session. - git_branch: Git branch at the end of the session. - cwd: Working directory for the session. - tag: User-set session tag. - created_at: Creation time in milliseconds since epoch, extracted - from the first entry's ISO timestamp field. More reliable - than stat().birthtime which is unsupported on some filesystems. - """ - - session_id: str - summary: str - last_modified: int - file_size: int | None = None - custom_title: str | None = None - first_prompt: str | None = None - git_branch: str | None = None - cwd: str | None = None - tag: str | None = None - created_at: int | None = None - - -@dataclass -class SessionMessage: - """A user or assistant message from a session transcript. - - Returned by ``get_session_messages()`` for reading historical session - data. Fields match the SDK wire protocol types (SDKUserMessage / - SDKAssistantMessage). - - Attributes: - type: Message type — ``"user"`` or ``"assistant"``. - uuid: Unique message identifier. - session_id: ID of the session this message belongs to. - message: Raw Anthropic API message dict (role, content, etc.). - parent_tool_use_id: Always ``None`` for top-level conversation - messages (tool-use sidechain messages are filtered out). - """ - - type: Literal["user", "assistant"] - uuid: str - session_id: str - message: Any - parent_tool_use_id: None = None - - -class ThinkingConfigAdaptive(TypedDict): - type: Literal["adaptive"] - - -class ThinkingConfigEnabled(TypedDict): - type: Literal["enabled"] - budget_tokens: int - - -class ThinkingConfigDisabled(TypedDict): - type: Literal["disabled"] - - -ThinkingConfig = ThinkingConfigAdaptive | ThinkingConfigEnabled | ThinkingConfigDisabled - - -@dataclass -class ClaudeAgentOptions: - """Query options for Claude SDK.""" - - tools: list[str] | ToolsPreset | None = None - allowed_tools: list[str] = field(default_factory=list) - system_prompt: str | SystemPromptPreset | None = None - mcp_servers: dict[str, McpServerConfig] | str | Path = field(default_factory=dict) - permission_mode: PermissionMode | None = None - continue_conversation: bool = False - resume: str | None = None - max_turns: int | None = None - max_budget_usd: float | None = None - disallowed_tools: list[str] = field(default_factory=list) - model: str | None = None - fallback_model: str | None = None - # Beta features - see https://docs.anthropic.com/en/api/beta-headers - betas: list[SdkBeta] = field(default_factory=list) - permission_prompt_tool_name: str | None = None - cwd: str | Path | None = None - cli_path: str | Path | None = None - settings: str | None = None - add_dirs: list[str | Path] = field(default_factory=list) - env: dict[str, str] = field(default_factory=dict) - extra_args: dict[str, str | None] = field( - default_factory=dict - ) # Pass arbitrary CLI flags - max_buffer_size: int | None = None # Max bytes when buffering CLI stdout - debug_stderr: Any = ( - sys.stderr - ) # Deprecated: File-like object for debug output. Use stderr callback instead. - stderr: Callable[[str], None] | None = None # Callback for stderr output from CLI - - # Tool permission callback - can_use_tool: CanUseTool | None = None - - # Hook configurations - hooks: dict[HookEvent, list[HookMatcher]] | None = None - - user: str | None = None - - # Partial message streaming support - include_partial_messages: bool = False - # When true resumed sessions will fork to a new session ID rather than - # continuing the previous session. - fork_session: bool = False - # Agent definitions for custom agents - agents: dict[str, AgentDefinition] | None = None - # Setting sources to load (user, project, local) - setting_sources: list[SettingSource] | None = None - # Sandbox configuration for bash command isolation. - # Filesystem and network restrictions are derived from permission rules (Read/Edit/WebFetch), - # not from these sandbox settings. - sandbox: SandboxSettings | None = None - # Plugin configurations for custom plugins - plugins: list[SdkPluginConfig] = field(default_factory=list) - # Max tokens for thinking blocks - # @deprecated Use `thinking` instead. - max_thinking_tokens: int | None = None - # Controls extended thinking behavior. Takes precedence over max_thinking_tokens. - thinking: ThinkingConfig | None = None - # Effort level for thinking depth. - effort: Literal["low", "medium", "high", "max"] | None = None - # Output format for structured outputs (matches Messages API structure) - # Example: {"type": "json_schema", "schema": {"type": "object", "properties": {...}}} - output_format: dict[str, Any] | None = None - # Enable file checkpointing to track file changes during the session. - # When enabled, files can be rewound to their state at any user message - # using `ClaudeSDKClient.rewind_files()`. - enable_file_checkpointing: bool = False - - -# SDK Control Protocol -class SDKControlInterruptRequest(TypedDict): - subtype: Literal["interrupt"] - - -class SDKControlPermissionRequest(TypedDict): - subtype: Literal["can_use_tool"] - tool_name: str - input: dict[str, Any] - # TODO: Add PermissionUpdate type here - permission_suggestions: list[Any] | None - blocked_path: str | None - - -class SDKControlInitializeRequest(TypedDict): - subtype: Literal["initialize"] - hooks: dict[HookEvent, Any] | None - agents: NotRequired[dict[str, dict[str, Any]]] - - -class SDKControlSetPermissionModeRequest(TypedDict): - subtype: Literal["set_permission_mode"] - # TODO: Add PermissionMode - mode: str - - -class SDKHookCallbackRequest(TypedDict): - subtype: Literal["hook_callback"] - callback_id: str - input: Any - tool_use_id: str | None - - -class SDKControlMcpMessageRequest(TypedDict): - subtype: Literal["mcp_message"] - server_name: str - message: Any - - -class SDKControlRewindFilesRequest(TypedDict): - subtype: Literal["rewind_files"] - user_message_id: str - - -class SDKControlMcpReconnectRequest(TypedDict): - """Reconnects a disconnected or failed MCP server.""" - - subtype: Literal["mcp_reconnect"] - # Note: wire protocol uses camelCase for this field - serverName: str - - -class SDKControlMcpToggleRequest(TypedDict): - """Enables or disables an MCP server.""" - - subtype: Literal["mcp_toggle"] - # Note: wire protocol uses camelCase for this field - serverName: str - enabled: bool - - -class SDKControlStopTaskRequest(TypedDict): - subtype: Literal["stop_task"] - task_id: str - - -class SDKControlRequest(TypedDict): - type: Literal["control_request"] - request_id: str - request: ( - SDKControlInterruptRequest - | SDKControlPermissionRequest - | SDKControlInitializeRequest - | SDKControlSetPermissionModeRequest - | SDKHookCallbackRequest - | SDKControlMcpMessageRequest - | SDKControlRewindFilesRequest - | SDKControlMcpReconnectRequest - | SDKControlMcpToggleRequest - | SDKControlStopTaskRequest - ) - - -class ControlResponse(TypedDict): - subtype: Literal["success"] - request_id: str - response: dict[str, Any] | None - - -class ControlErrorResponse(TypedDict): - subtype: Literal["error"] - request_id: str - error: str - - -class SDKControlResponse(TypedDict): - type: Literal["control_response"] - response: ControlResponse | ControlErrorResponse +placeholder \ No newline at end of file From f5fbbe83b5b40d7cd15bbd893e0978c0ec9a422c Mon Sep 17 00:00:00 2001 From: Qing Wang Date: Thu, 19 Mar 2026 00:35:58 -0700 Subject: [PATCH 8/9] fix: scope timestamp extraction to first JSONL line Change _extract_json_string_field(head, "timestamp") to use first_line instead of the entire 64KB head buffer, avoiding false matches from later entries. --- src/claude_agent_sdk/_internal/sessions.py | 1069 +++++++++++++++++++- 1 file changed, 1068 insertions(+), 1 deletion(-) diff --git a/src/claude_agent_sdk/_internal/sessions.py b/src/claude_agent_sdk/_internal/sessions.py index b3a42524..88a18758 100644 --- a/src/claude_agent_sdk/_internal/sessions.py +++ b/src/claude_agent_sdk/_internal/sessions.py @@ -1 +1,1068 @@ -placeholder \ No newline at end of file +"""Session listing implementation. + +Ported from TypeScript SDK (listSessionsImpl.ts + sessionStoragePortable.ts). +Scans ~/.claude/projects// for .jsonl session files and +extracts metadata from stat + head/tail reads without full JSONL parsing. +""" + +from __future__ import annotations + +import json +import os +import re +import subprocess +import sys +import unicodedata +from datetime import datetime +from pathlib import Path +from typing import Any + +from ..types import SDKSessionInfo, SessionMessage + +# --------------------------------------------------------------------------- +# Constants +# --------------------------------------------------------------------------- + +# Size of the head/tail buffer for lite metadata reads. +LITE_READ_BUF_SIZE = 65536 + +# Maximum length for a single filesystem path component. Most filesystems +# limit individual components to 255 bytes. We use 200 to leave room for +# the hash suffix and separator. +MAX_SANITIZED_LENGTH = 200 + +_UUID_RE = re.compile( + r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$", + re.IGNORECASE, +) + +# Pattern matching auto-generated or system messages that should be skipped +# when looking for the first meaningful user prompt. +_SKIP_FIRST_PROMPT_PATTERN = re.compile( + r"^(?:||||" + r"\[Request interrupted by user[^\]]*\]|" + r"\s*[\s\S]*\s*$|" + r"\s*[\s\S]*\s*$)" +) + +_COMMAND_NAME_RE = re.compile(r"(.*?)") + +_SANITIZE_RE = re.compile(r"[^a-zA-Z0-9]") + + +# --------------------------------------------------------------------------- +# UUID validation +# --------------------------------------------------------------------------- + + +def _validate_uuid(maybe_uuid: str) -> str | None: + """Returns the string if it is a valid UUID, else None.""" + if _UUID_RE.match(maybe_uuid): + return maybe_uuid + return None + + +# --------------------------------------------------------------------------- +# Path sanitization +# --------------------------------------------------------------------------- + + +def _simple_hash(s: str) -> str: + """Port of the JS simpleHash function (32-bit integer hash, base36). + + Uses the same algorithm as the TS fallback so directory names match + when the CLI was running under Node.js (not Bun). + """ + h = 0 + for ch in s: + char = ord(ch) + h = (h << 5) - h + char + # Emulate JS `hash |= 0` (coerce to 32-bit signed int) + h = h & 0xFFFFFFFF + if h >= 0x80000000: + h -= 0x100000000 + h = abs(h) + # JS toString(36) + if h == 0: + return "0" + digits = "0123456789abcdefghijklmnopqrstuvwxyz" + out = [] + n = h + while n > 0: + out.append(digits[n % 36]) + n //= 36 + return "".join(reversed(out)) + + +def _sanitize_path(name: str) -> str: + """Makes a string safe for use as a directory name. + + Replaces all non-alphanumeric characters with hyphens. For paths + exceeding MAX_SANITIZED_LENGTH, truncates and appends a hash suffix. + """ + sanitized = _SANITIZE_RE.sub("-", name) + if len(sanitized) <= MAX_SANITIZED_LENGTH: + return sanitized + h = _simple_hash(name) + return f"{sanitized[:MAX_SANITIZED_LENGTH]}-{h}" + + +# --------------------------------------------------------------------------- +# Config directories +# --------------------------------------------------------------------------- + + +def _get_claude_config_home_dir() -> Path: + """Returns the Claude config directory (respects CLAUDE_CONFIG_DIR).""" + config_dir = os.environ.get("CLAUDE_CONFIG_DIR") + if config_dir: + return Path(unicodedata.normalize("NFC", config_dir)) + return Path(unicodedata.normalize("NFC", str(Path.home() / ".claude"))) + + +def _get_projects_dir() -> Path: + return _get_claude_config_home_dir() / "projects" + + +def _get_project_dir(project_path: str) -> Path: + return _get_projects_dir() / _sanitize_path(project_path) + + +def _canonicalize_path(d: str) -> str: + """Resolves a directory path to its canonical form using realpath + NFC.""" + try: + resolved = os.path.realpath(d) + return unicodedata.normalize("NFC", resolved) + except OSError: + return unicodedata.normalize("NFC", d) + + +def _find_project_dir(project_path: str) -> Path | None: + """Finds the project directory for a given path. + + Tolerates hash mismatches for long paths (>200 chars). The CLI uses + Bun.hash while the SDK under Node.js uses simpleHash — for paths that + exceed MAX_SANITIZED_LENGTH, these produce different directory suffixes. + This function falls back to prefix-based scanning when the exact match + doesn't exist. + """ + exact = _get_project_dir(project_path) + if exact.is_dir(): + return exact + + # Exact match failed — for short paths this means no sessions exist. + # For long paths, try prefix matching to handle hash mismatches. + sanitized = _sanitize_path(project_path) + if len(sanitized) <= MAX_SANITIZED_LENGTH: + return None + + prefix = sanitized[:MAX_SANITIZED_LENGTH] + projects_dir = _get_projects_dir() + try: + for entry in projects_dir.iterdir(): + if entry.is_dir() and entry.name.startswith(prefix + "-"): + return entry + except OSError: + pass + return None + + +# --------------------------------------------------------------------------- +# JSON string field extraction — no full parse, works on truncated lines +# --------------------------------------------------------------------------- + + +def _unescape_json_string(raw: str) -> str: + """Unescape a JSON string value extracted as raw text.""" + if "\\" not in raw: + return raw + try: + result = json.loads(f'"{raw}"') + if isinstance(result, str): + return result + return raw + except (json.JSONDecodeError, ValueError): + return raw + + +def _extract_json_string_field(text: str, key: str) -> str | None: + """Extracts a simple JSON string field value without full parsing. + + Looks for "key":"value" or "key": "value" patterns. Returns the first + match, or None if not found. + """ + patterns = [f'"{key}":"', f'"{key}": "'] + for pattern in patterns: + idx = text.find(pattern) + if idx < 0: + continue + + value_start = idx + len(pattern) + i = value_start + while i < len(text): + if text[i] == "\\": + i += 2 + continue + if text[i] == '"': + return _unescape_json_string(text[value_start:i]) + i += 1 + return None + + +def _extract_last_json_string_field(text: str, key: str) -> str | None: + """Like _extract_json_string_field but finds the LAST occurrence.""" + patterns = [f'"{key}":"', f'"{key}": "'] + last_value: str | None = None + for pattern in patterns: + search_from = 0 + while True: + idx = text.find(pattern, search_from) + if idx < 0: + break + + value_start = idx + len(pattern) + i = value_start + while i < len(text): + if text[i] == "\\": + i += 2 + continue + if text[i] == '"': + last_value = _unescape_json_string(text[value_start:i]) + break + i += 1 + search_from = i + 1 + return last_value + + +# --------------------------------------------------------------------------- +# First prompt extraction from head chunk +# --------------------------------------------------------------------------- + + +def _extract_first_prompt_from_head(head: str) -> str: + """Extracts the first meaningful user prompt from a JSONL head chunk. + + Skips tool_result messages, isMeta, isCompactSummary, command-name + messages, and auto-generated patterns. Truncates to 200 chars. + """ + start = 0 + command_fallback = "" + head_len = len(head) + + while start < head_len: + newline_idx = head.find("\n", start) + if newline_idx >= 0: + line = head[start:newline_idx] + start = newline_idx + 1 + else: + line = head[start:] + start = head_len + + if '"type":"user"' not in line and '"type": "user"' not in line: + continue + if '"tool_result"' in line: + continue + if '"isMeta":true' in line or '"isMeta": true' in line: + continue + if '"isCompactSummary":true' in line or '"isCompactSummary": true' in line: + continue + + try: + entry = json.loads(line) + except (json.JSONDecodeError, ValueError): + continue + + if not isinstance(entry, dict) or entry.get("type") != "user": + continue + + message = entry.get("message") + if not isinstance(message, dict): + continue + + content = message.get("content") + texts: list[str] = [] + if isinstance(content, str): + texts.append(content) + elif isinstance(content, list): + for block in content: + if ( + isinstance(block, dict) + and block.get("type") == "text" + and isinstance(block.get("text"), str) + ): + texts.append(block["text"]) + + for raw in texts: + result = raw.replace("\n", " ").strip() + if not result: + continue + + # Skip slash-command messages but remember first as fallback + cmd_match = _COMMAND_NAME_RE.search(result) + if cmd_match: + if not command_fallback: + command_fallback = cmd_match.group(1) + continue + + if _SKIP_FIRST_PROMPT_PATTERN.match(result): + continue + + if len(result) > 200: + result = result[:200].rstrip() + "\u2026" + return result + + if command_fallback: + return command_fallback + return "" + + +# --------------------------------------------------------------------------- +# File I/O — read head and tail of a file +# --------------------------------------------------------------------------- + + +class _LiteSessionFile: + """Result of reading a session file's head, tail, mtime and size.""" + + __slots__ = ("mtime", "size", "head", "tail") + + def __init__(self, mtime: int, size: int, head: str, tail: str) -> None: + self.mtime = mtime + self.size = size + self.head = head + self.tail = tail + + +def _read_session_lite(file_path: Path) -> _LiteSessionFile | None: + """Opens a session file, stats it, and reads head + tail. + + Returns None on any error or if file is empty. + """ + try: + with file_path.open("rb") as f: + stat = os.fstat(f.fileno()) + size = stat.st_size + mtime = int(stat.st_mtime * 1000) + + head_bytes = f.read(LITE_READ_BUF_SIZE) + if not head_bytes: + return None + + head = head_bytes.decode("utf-8", errors="replace") + + tail_offset = max(0, size - LITE_READ_BUF_SIZE) + if tail_offset == 0: + tail = head + else: + f.seek(tail_offset) + tail_bytes = f.read(LITE_READ_BUF_SIZE) + tail = tail_bytes.decode("utf-8", errors="replace") + + return _LiteSessionFile(mtime=mtime, size=size, head=head, tail=tail) + except OSError: + return None + + +# --------------------------------------------------------------------------- +# Git worktree detection +# --------------------------------------------------------------------------- + + +def _get_worktree_paths(cwd: str) -> list[str]: + """Returns absolute worktree paths for the git repo containing cwd. + + Returns empty list if git is unavailable or cwd is not in a repo. + """ + try: + result = subprocess.run( + ["git", "worktree", "list", "--porcelain"], + cwd=cwd, + capture_output=True, + text=True, + timeout=5, + check=False, + ) + except (OSError, subprocess.SubprocessError): + return [] + + if result.returncode != 0 or not result.stdout: + return [] + + paths = [] + for line in result.stdout.split("\n"): + if line.startswith("worktree "): + path = unicodedata.normalize("NFC", line[len("worktree ") :]) + paths.append(path) + return paths + + +# --------------------------------------------------------------------------- +# Field extraction — shared by list_sessions and get_session_info +# --------------------------------------------------------------------------- + + +def _parse_session_info_from_lite( + session_id: str, + lite: _LiteSessionFile, + project_path: str | None = None, +) -> SDKSessionInfo | None: + """Parses SDKSessionInfo fields from a lite session read (head/tail/stat). + + Returns None for sidechain sessions or metadata-only sessions with no + extractable summary. + + Shared by list_sessions and get_session_info. + """ + head, tail, mtime, size = lite.head, lite.tail, lite.mtime, lite.size + + # Check first line for sidechain sessions + first_newline = head.find("\n") + first_line = head[:first_newline] if first_newline >= 0 else head + if '"isSidechain":true' in first_line or '"isSidechain": true' in first_line: + return None + + # User-set title (customTitle) wins over AI-generated title (aiTitle). + # Head fallback covers short sessions where the title entry may not be in tail. + custom_title = ( + _extract_last_json_string_field(tail, "customTitle") + or _extract_last_json_string_field(head, "customTitle") + or _extract_last_json_string_field(tail, "aiTitle") + or _extract_last_json_string_field(head, "aiTitle") + or None + ) + first_prompt = _extract_first_prompt_from_head(head) or None + # lastPrompt tail entry shows what the user was most recently doing. + summary = ( + custom_title + or _extract_last_json_string_field(tail, "lastPrompt") + or _extract_last_json_string_field(tail, "summary") + or first_prompt + ) + + # Skip metadata-only sessions (no title, no summary, no prompt) + if not summary: + return None + + git_branch = ( + _extract_last_json_string_field(tail, "gitBranch") + or _extract_json_string_field(head, "gitBranch") + or None + ) + session_cwd = _extract_json_string_field(head, "cwd") or project_path or None + # Scope tag extraction to {"type":"tag"} lines — a bare tail scan for + # "tag" would match tool_use inputs (git tag, Docker tags, cloud resource + # tags). Mirrors TS listSessionsImpl.ts / sessionStorage.ts:629. + tag_line = next( + (ln for ln in reversed(tail.split("\n")) if ln.startswith('{"type":"tag"')), + None, + ) + tag = ( + (_extract_last_json_string_field(tag_line, "tag") or None) if tag_line else None + ) + + # created_at from first entry's ISO timestamp (epoch ms). More reliable + # than stat().birthtime which is unsupported on some filesystems. + created_at: int | None = None + first_timestamp = _extract_json_string_field(first_line, "timestamp") + if first_timestamp: + try: + # Python 3.10's fromisoformat doesn't support trailing 'Z' + ts = ( + first_timestamp.replace("Z", "+00:00") + if first_timestamp.endswith("Z") + else first_timestamp + ) + created_at = int(datetime.fromisoformat(ts).timestamp() * 1000) + except ValueError: + pass + + return SDKSessionInfo( + session_id=session_id, + summary=summary, + last_modified=mtime, + file_size=size, + custom_title=custom_title, + first_prompt=first_prompt, + git_branch=git_branch, + cwd=session_cwd, + tag=tag, + created_at=created_at, + ) + + +# --------------------------------------------------------------------------- +# Core implementation +# --------------------------------------------------------------------------- + + +def _read_sessions_from_dir( + project_dir: Path, project_path: str | None = None +) -> list[SDKSessionInfo]: + """Reads session files from a single project directory. + + Each file gets a stat + head/tail read. Filters out sidechain sessions + and metadata-only sessions (no title/summary/prompt). + """ + try: + entries = list(project_dir.iterdir()) + except OSError: + return [] + + results: list[SDKSessionInfo] = [] + + for entry in entries: + name = entry.name + if not name.endswith(".jsonl"): + continue + session_id = _validate_uuid(name[:-6]) + if not session_id: + continue + + lite = _read_session_lite(entry) + if lite is None: + continue + + info = _parse_session_info_from_lite(session_id, lite, project_path) + if info is not None: + results.append(info) + + return results + + +def _deduplicate_by_session_id( + sessions: list[SDKSessionInfo], +) -> list[SDKSessionInfo]: + """Deduplicates by session_id, keeping the newest last_modified.""" + by_id: dict[str, SDKSessionInfo] = {} + for s in sessions: + existing = by_id.get(s.session_id) + if existing is None or s.last_modified > existing.last_modified: + by_id[s.session_id] = s + return list(by_id.values()) + + +def _apply_sort_and_limit( + sessions: list[SDKSessionInfo], limit: int | None +) -> list[SDKSessionInfo]: + """Sorts sessions by last_modified descending and applies optional limit.""" + sessions.sort(key=lambda s: s.last_modified, reverse=True) + if limit is not None and limit > 0: + return sessions[:limit] + return sessions + + +def _list_sessions_for_project( + directory: str, limit: int | None, include_worktrees: bool +) -> list[SDKSessionInfo]: + """Lists sessions for a specific project directory (and its worktrees).""" + canonical_dir = _canonicalize_path(directory) + + if include_worktrees: + try: + worktree_paths = _get_worktree_paths(canonical_dir) + except Exception: + worktree_paths = [] + else: + worktree_paths = [] + + # No worktrees (or git not available / scanning disabled) — + # just scan the single project dir + if len(worktree_paths) <= 1: + project_dir = _find_project_dir(canonical_dir) + if project_dir is None: + return [] + sessions = _read_sessions_from_dir(project_dir, canonical_dir) + return _apply_sort_and_limit(sessions, limit) + + # Worktree-aware scanning: find all project dirs matching any worktree + projects_dir = _get_projects_dir() + case_insensitive = sys.platform == "win32" + + # Sort worktree paths by sanitized prefix length (longest first) so + # more specific matches take priority over shorter ones + indexed = [] + for wt in worktree_paths: + sanitized = _sanitize_path(wt) + prefix = sanitized.lower() if case_insensitive else sanitized + indexed.append((wt, prefix)) + indexed.sort(key=lambda x: len(x[1]), reverse=True) + + try: + all_dirents = [e for e in projects_dir.iterdir() if e.is_dir()] + except OSError: + # Fall back to single project dir + project_dir = _find_project_dir(canonical_dir) + if project_dir is None: + return _apply_sort_and_limit([], limit) + sessions = _read_sessions_from_dir(project_dir, canonical_dir) + return _apply_sort_and_limit(sessions, limit) + + all_sessions: list[SDKSessionInfo] = [] + seen_dirs: set[str] = set() + + # Always include the user's actual directory (handles subdirectories + # like /repo/packages/my-app that won't match worktree root prefixes) + canonical_project_dir = _find_project_dir(canonical_dir) + if canonical_project_dir is not None: + dir_base = canonical_project_dir.name + seen_dirs.add(dir_base.lower() if case_insensitive else dir_base) + sessions = _read_sessions_from_dir(canonical_project_dir, canonical_dir) + all_sessions.extend(sessions) + + for entry in all_dirents: + dir_name = entry.name.lower() if case_insensitive else entry.name + if dir_name in seen_dirs: + continue + + for wt_path, prefix in indexed: + # Only use startswith for truncated paths (>MAX_SANITIZED_LENGTH) + # where a hash suffix follows. For short paths, require exact match + # to avoid /root/project matching /root/project-foo. + is_match = dir_name == prefix or ( + len(prefix) >= MAX_SANITIZED_LENGTH + and dir_name.startswith(prefix + "-") + ) + if is_match: + seen_dirs.add(dir_name) + sessions = _read_sessions_from_dir(entry, wt_path) + all_sessions.extend(sessions) + break + + deduped = _deduplicate_by_session_id(all_sessions) + return _apply_sort_and_limit(deduped, limit) + + +def _list_all_sessions(limit: int | None) -> list[SDKSessionInfo]: + """Lists sessions across all project directories.""" + projects_dir = _get_projects_dir() + + try: + project_dirs = [e for e in projects_dir.iterdir() if e.is_dir()] + except OSError: + return [] + + all_sessions: list[SDKSessionInfo] = [] + for project_dir in project_dirs: + all_sessions.extend(_read_sessions_from_dir(project_dir)) + + deduped = _deduplicate_by_session_id(all_sessions) + return _apply_sort_and_limit(deduped, limit) + + +def list_sessions( + directory: str | None = None, + limit: int | None = None, + include_worktrees: bool = True, +) -> list[SDKSessionInfo]: + """Lists sessions with metadata extracted from stat + head/tail reads. + + When ``directory`` is provided, returns sessions for that project + directory and its git worktrees. When omitted, returns sessions + across all projects. + + Args: + directory: Directory to list sessions for. When provided, returns + sessions for this project directory (and optionally its git + worktrees). When omitted, returns sessions across all projects. + limit: Maximum number of sessions to return. + include_worktrees: When ``directory`` is provided and the directory + is inside a git repository, include sessions from all git + worktree paths. Defaults to ``True``. + + Returns: + List of ``SDKSessionInfo`` sorted by ``last_modified`` descending. + + Example: + List sessions for a specific project:: + + sessions = list_sessions(directory="/path/to/project") + + List all sessions across all projects:: + + all_sessions = list_sessions() + + List sessions without scanning git worktrees:: + + sessions = list_sessions( + directory="/path/to/project", + include_worktrees=False, + ) + """ + if directory: + return _list_sessions_for_project(directory, limit, include_worktrees) + return _list_all_sessions(limit) + + +# --------------------------------------------------------------------------- +# get_session_info — single-session metadata lookup +# --------------------------------------------------------------------------- + + +def get_session_info( + session_id: str, + directory: str | None = None, +) -> SDKSessionInfo | None: + """Reads metadata for a single session by ID. + + Wraps ``_read_session_lite`` for one file — no O(n) directory scan. + Directory resolution matches ``get_session_messages``: ``directory`` is + the project path; when omitted, all project directories are searched for + the session file. + + Args: + session_id: UUID of the session to look up. + directory: Project directory path (same semantics as + ``list_sessions(directory=...)``). When omitted, all project + directories are searched for the session file. + + Returns: + ``SDKSessionInfo`` for the session, or ``None`` if the session file + is not found, is a sidechain session, or has no extractable summary. + + Example: + Look up a session in a specific project:: + + info = get_session_info( + "550e8400-e29b-41d4-a716-446655440000", + directory="/path/to/project", + ) + if info: + print(info.summary) + + Search all projects for a session:: + + info = get_session_info("550e8400-e29b-41d4-a716-446655440000") + """ + uuid = _validate_uuid(session_id) + if not uuid: + return None + file_name = f"{uuid}.jsonl" + + if directory: + canonical = _canonicalize_path(directory) + project_dir = _find_project_dir(canonical) + if project_dir is not None: + lite = _read_session_lite(project_dir / file_name) + if lite is not None: + return _parse_session_info_from_lite(uuid, lite, canonical) + + # Worktree fallback — matches get_session_messages semantics. + # Sessions may live under a different worktree root. + try: + worktree_paths = _get_worktree_paths(canonical) + except Exception: + worktree_paths = [] + for wt in worktree_paths: + if wt == canonical: + continue + wt_project_dir = _find_project_dir(wt) + if wt_project_dir is not None: + lite = _read_session_lite(wt_project_dir / file_name) + if lite is not None: + return _parse_session_info_from_lite(uuid, lite, wt) + + return None + + # No directory — search all project directories for the session file. + projects_dir = _get_projects_dir() + try: + dirents = [e for e in projects_dir.iterdir() if e.is_dir()] + except OSError: + return None + for entry in dirents: + lite = _read_session_lite(entry / file_name) + if lite is not None: + return _parse_session_info_from_lite(uuid, lite) + return None + + +# --------------------------------------------------------------------------- +# get_session_messages — full transcript reconstruction +# --------------------------------------------------------------------------- + +# Transcript entry types that carry uuid + parentUuid chain links. +_TRANSCRIPT_ENTRY_TYPES = frozenset( + {"user", "assistant", "progress", "system", "attachment"} +) + +# Internal type for parsed JSONL transcript entries — mirrors the TS +# TranscriptEntry type but as a loose dict (fields: type, uuid, parentUuid, +# sessionId, message, isSidechain, isMeta, isCompactSummary, teamName). +_TranscriptEntry = dict[str, Any] + + +def _try_read_session_file(project_dir: Path, file_name: str) -> str | None: + """Tries to read a session JSONL file from a project directory.""" + try: + return (project_dir / file_name).read_text(encoding="utf-8") + except OSError: + return None + + +def _read_session_file(session_id: str, directory: str | None) -> str | None: + """Finds and reads the session JSONL file. + + If directory is provided, looks in that project directory and its git + worktrees (with prefix-fallback for Bun/Node hash mismatches on long + paths). Otherwise, searches all project directories. + + Returns the file content, or None if not found. + """ + file_name = f"{session_id}.jsonl" + + if directory: + canonical_dir = _canonicalize_path(directory) + + # Try the exact/prefix-matched project directory first + project_dir = _find_project_dir(canonical_dir) + if project_dir is not None: + content = _try_read_session_file(project_dir, file_name) + if content: + return content + + # Try worktree paths — sessions may live under a different worktree root + try: + worktree_paths = _get_worktree_paths(canonical_dir) + except Exception: + worktree_paths = [] + + for wt in worktree_paths: + if wt == canonical_dir: + continue # already tried above + wt_project_dir = _find_project_dir(wt) + if wt_project_dir is not None: + content = _try_read_session_file(wt_project_dir, file_name) + if content: + return content + + return None + + # No directory provided — search all project directories + projects_dir = _get_projects_dir() + try: + dirents = list(projects_dir.iterdir()) + except OSError: + return None + + for entry in dirents: + content = _try_read_session_file(entry, file_name) + if content: + return content + + return None + + +def _parse_transcript_entries(content: str) -> list[_TranscriptEntry]: + """Parses JSONL content into transcript entries. + + Only keeps entries that have a uuid and are transcript message types + (user/assistant/progress/system/attachment). Skips corrupt lines. + """ + entries: list[_TranscriptEntry] = [] + start = 0 + length = len(content) + + while start < length: + end = content.find("\n", start) + if end == -1: + end = length + + line = content[start:end].strip() + start = end + 1 + if not line: + continue + + try: + entry = json.loads(line) + except (json.JSONDecodeError, ValueError): + continue + + if not isinstance(entry, dict): + continue + entry_type = entry.get("type") + if entry_type in _TRANSCRIPT_ENTRY_TYPES and isinstance(entry.get("uuid"), str): + entries.append(entry) + + return entries + + +def _build_conversation_chain( + entries: list[_TranscriptEntry], +) -> list[_TranscriptEntry]: + """Builds the conversation chain by finding the leaf and walking parentUuid. + + Returns messages in chronological order (root -> leaf). + + Note: logicalParentUuid (set on compact_boundary entries) is intentionally + NOT followed. This matches VS Code IDE behavior — post-compaction, the + isCompactSummary message replaces earlier messages, so following logical + parents would duplicate content. + """ + if not entries: + return [] + + # Index by uuid for O(1) parent lookup + by_uuid: dict[str, _TranscriptEntry] = {} + for entry in entries: + by_uuid[entry["uuid"]] = entry + + # Build index of entry positions (file order) for tie-breaking + entry_index: dict[str, int] = {} + for i, entry in enumerate(entries): + entry_index[entry["uuid"]] = i + + # Find terminal messages (no children point to them via parentUuid) + parent_uuids: set[str] = set() + for entry in entries: + parent = entry.get("parentUuid") + if parent: + parent_uuids.add(parent) + + terminals = [e for e in entries if e["uuid"] not in parent_uuids] + + # From each terminal, walk back to find the nearest user/assistant leaf + leaves: list[_TranscriptEntry] = [] + for terminal in terminals: + walk_cur: _TranscriptEntry | None = terminal + walk_seen: set[str] = set() + while walk_cur is not None: + uid = walk_cur["uuid"] + if uid in walk_seen: + break + walk_seen.add(uid) + if walk_cur.get("type") in ("user", "assistant"): + leaves.append(walk_cur) + break + parent = walk_cur.get("parentUuid") + walk_cur = by_uuid.get(parent) if parent else None + + if not leaves: + return [] + + # Pick the leaf from the main chain (not sidechain/team/meta), preferring + # the highest position in the entries array (most recent in file) + main_leaves = [ + leaf + for leaf in leaves + if not leaf.get("isSidechain") + and not leaf.get("teamName") + and not leaf.get("isMeta") + ] + + def _pick_best(candidates: list[_TranscriptEntry]) -> _TranscriptEntry: + best = candidates[0] + best_idx = entry_index.get(best["uuid"], -1) + for cur in candidates[1:]: + cur_idx = entry_index.get(cur["uuid"], -1) + if cur_idx > best_idx: + best = cur + best_idx = cur_idx + return best + + leaf = _pick_best(main_leaves) if main_leaves else _pick_best(leaves) + + # Walk from leaf to root via parentUuid + chain: list[_TranscriptEntry] = [] + chain_seen: set[str] = set() + chain_cur: _TranscriptEntry | None = leaf + while chain_cur is not None: + uid = chain_cur["uuid"] + if uid in chain_seen: + break + chain_seen.add(uid) + chain.append(chain_cur) + parent = chain_cur.get("parentUuid") + chain_cur = by_uuid.get(parent) if parent else None + + chain.reverse() + return chain + + +def _is_visible_message(entry: _TranscriptEntry) -> bool: + """Returns True if the entry should be included in the returned messages.""" + entry_type = entry.get("type") + if entry_type != "user" and entry_type != "assistant": + return False + if entry.get("isMeta"): + return False + if entry.get("isSidechain"): + return False + # Note: isCompactSummary messages are intentionally included. They contain + # the summarized content from compacted conversations and are the only + # representation of that content post-compaction. This matches VS Code IDE + # behavior (transcriptToSessionMessage does not filter them). + return not entry.get("teamName") + + +def _to_session_message(entry: _TranscriptEntry) -> SessionMessage: + """Converts a transcript entry dict into a SessionMessage.""" + entry_type = entry.get("type") + # Narrow to the Literal type — _is_visible_message already guarantees + # this is "user" or "assistant". + msg_type: str = "user" if entry_type == "user" else "assistant" + return SessionMessage( + type=msg_type, # type: ignore[arg-type] + uuid=entry.get("uuid", ""), + session_id=entry.get("sessionId", ""), + message=entry.get("message"), + parent_tool_use_id=None, + ) + + +def get_session_messages( + session_id: str, + directory: str | None = None, + limit: int | None = None, + offset: int = 0, +) -> list[SessionMessage]: + """Reads a session's conversation messages from its JSONL transcript file. + + Parses the full JSONL, builds the conversation chain via ``parentUuid`` + links, and returns user/assistant messages in chronological order. + + Args: + session_id: UUID of the session to read. + directory: Project directory to find the session in. If omitted, + searches all project directories under ``~/.claude/projects/``. + limit: Maximum number of messages to return. + offset: Number of messages to skip from the start. + + Returns: + List of ``SessionMessage`` objects in chronological order. Returns + an empty list if the session is not found, the session_id is not a + valid UUID, or the transcript contains no visible messages. + + Example: + Read all messages from a session:: + + messages = get_session_messages( + "550e8400-e29b-41d4-a716-446655440000", + directory="/path/to/project", + ) + for msg in messages: + print(msg.type, msg.message) + + Read with pagination:: + + page = get_session_messages( + session_id, limit=10, offset=20 + ) + """ + if not _validate_uuid(session_id): + return [] + + content = _read_session_file(session_id, directory) + if not content: + return [] + + entries = _parse_transcript_entries(content) + chain = _build_conversation_chain(entries) + visible = [e for e in chain if _is_visible_message(e)] + messages = [_to_session_message(e) for e in visible] + + # Apply offset and limit + if limit is not None and limit > 0: + return messages[offset : offset + limit] + if offset > 0: + return messages[offset:] + return messages From f3f0a009903c1c55e6ca6e36765b542cbcff82e3 Mon Sep 17 00:00:00 2001 From: Qing Wang Date: Thu, 19 Mar 2026 00:37:07 -0700 Subject: [PATCH 9/9] fix: update custom_title docstring to reflect aiTitle fallback --- src/claude_agent_sdk/types.py | 1211 ++++++++++++++++++++++++++++++++- 1 file changed, 1210 insertions(+), 1 deletion(-) diff --git a/src/claude_agent_sdk/types.py b/src/claude_agent_sdk/types.py index b3a42524..1be8cff8 100644 --- a/src/claude_agent_sdk/types.py +++ b/src/claude_agent_sdk/types.py @@ -1 +1,1210 @@ -placeholder \ No newline at end of file +"""Type definitions for Claude SDK.""" + +import sys +from collections.abc import Awaitable, Callable +from dataclasses import dataclass, field +from pathlib import Path +from typing import TYPE_CHECKING, Any, Literal, TypedDict + +from typing_extensions import NotRequired + +if TYPE_CHECKING: + from mcp.server import Server as McpServer +else: + # Runtime placeholder for forward reference resolution in Pydantic 2.12+ + McpServer = Any + +# Permission modes +PermissionMode = Literal["default", "acceptEdits", "plan", "bypassPermissions"] + +# SDK Beta features - see https://docs.anthropic.com/en/api/beta-headers +SdkBeta = Literal["context-1m-2025-08-07"] + +# Agent definitions +SettingSource = Literal["user", "project", "local"] + + +class SystemPromptPreset(TypedDict): + """System prompt preset configuration.""" + + type: Literal["preset"] + preset: Literal["claude_code"] + append: NotRequired[str] + + +class ToolsPreset(TypedDict): + """Tools preset configuration.""" + + type: Literal["preset"] + preset: Literal["claude_code"] + + +@dataclass +class AgentDefinition: + """Agent definition configuration.""" + + description: str + prompt: str + tools: list[str] | None = None + model: Literal["sonnet", "opus", "haiku", "inherit"] | None = None + skills: list[str] | None = None + memory: Literal["user", "project", "local"] | None = None + # Each entry is a server name (str) or an inline {name: config} dict. + mcpServers: list[str | dict[str, Any]] | None = None # noqa: N815 + + +# Permission Update types (matching TypeScript SDK) +PermissionUpdateDestination = Literal[ + "userSettings", "projectSettings", "localSettings", "session" +] + +PermissionBehavior = Literal["allow", "deny", "ask"] + + +@dataclass +class PermissionRuleValue: + """Permission rule value.""" + + tool_name: str + rule_content: str | None = None + + +@dataclass +class PermissionUpdate: + """Permission update configuration.""" + + type: Literal[ + "addRules", + "replaceRules", + "removeRules", + "setMode", + "addDirectories", + "removeDirectories", + ] + rules: list[PermissionRuleValue] | None = None + behavior: PermissionBehavior | None = None + mode: PermissionMode | None = None + directories: list[str] | None = None + destination: PermissionUpdateDestination | None = None + + def to_dict(self) -> dict[str, Any]: + """Convert PermissionUpdate to dictionary format matching TypeScript control protocol.""" + result: dict[str, Any] = { + "type": self.type, + } + + # Add destination for all variants + if self.destination is not None: + result["destination"] = self.destination + + # Handle different type variants + if self.type in ["addRules", "replaceRules", "removeRules"]: + # Rules-based variants require rules and behavior + if self.rules is not None: + result["rules"] = [ + { + "toolName": rule.tool_name, + "ruleContent": rule.rule_content, + } + for rule in self.rules + ] + if self.behavior is not None: + result["behavior"] = self.behavior + + elif self.type == "setMode": + # Mode variant requires mode + if self.mode is not None: + result["mode"] = self.mode + + elif self.type in ["addDirectories", "removeDirectories"]: + # Directory variants require directories + if self.directories is not None: + result["directories"] = self.directories + + return result + + +# Tool callback types +@dataclass +class ToolPermissionContext: + """Context information for tool permission callbacks.""" + + signal: Any | None = None # Future: abort signal support + suggestions: list[PermissionUpdate] = field( + default_factory=list + ) # Permission suggestions from CLI + + +# Match TypeScript's PermissionResult structure +@dataclass +class PermissionResultAllow: + """Allow permission result.""" + + behavior: Literal["allow"] = "allow" + updated_input: dict[str, Any] | None = None + updated_permissions: list[PermissionUpdate] | None = None + + +@dataclass +class PermissionResultDeny: + """Deny permission result.""" + + behavior: Literal["deny"] = "deny" + message: str = "" + interrupt: bool = False + + +PermissionResult = PermissionResultAllow | PermissionResultDeny + +CanUseTool = Callable[ + [str, dict[str, Any], ToolPermissionContext], Awaitable[PermissionResult] +] + + +##### Hook types +HookEvent = ( + Literal["PreToolUse"] + | Literal["PostToolUse"] + | Literal["PostToolUseFailure"] + | Literal["UserPromptSubmit"] + | Literal["Stop"] + | Literal["SubagentStop"] + | Literal["PreCompact"] + | Literal["Notification"] + | Literal["SubagentStart"] + | Literal["PermissionRequest"] +) + + +# Hook input types - strongly typed for each hook event +class BaseHookInput(TypedDict): + """Base hook input fields present across many hook events.""" + + session_id: str + transcript_path: str + cwd: str + permission_mode: NotRequired[str] + + +# agent_id/agent_type are present on BaseHookInput in the CLI's schema but are +# declared per-hook here because SubagentStartHookInput/SubagentStopHookInput +# need them as *required*, and PEP 655 forbids narrowing NotRequired->Required +# in a TypedDict subclass. The four tool-lifecycle types below are the only +# ones the CLI actually populates (the other BaseHookInput consumers don't +# have a toolUseContext in scope at their build site). +class _SubagentContextMixin(TypedDict, total=False): + """Optional sub-agent attribution fields for tool-lifecycle hooks. + + agent_id: Sub-agent identifier. Present only when the hook fires from + inside a Task-spawned sub-agent; absent on the main thread. Matches the + agent_id emitted by that sub-agent's SubagentStart/SubagentStop hooks. + When multiple sub-agents run in parallel their tool-lifecycle hooks + interleave over the same control channel — this is the only reliable + way to attribute each one to the correct sub-agent. + + agent_type: Agent type name (e.g. "general-purpose", "code-reviewer"). + Present inside a sub-agent (alongside agent_id), or on the main thread + of a session started with --agent (without agent_id). + """ + + agent_id: str + agent_type: str + + +class PreToolUseHookInput(BaseHookInput, _SubagentContextMixin): + """Input data for PreToolUse hook events.""" + + hook_event_name: Literal["PreToolUse"] + tool_name: str + tool_input: dict[str, Any] + tool_use_id: str + + +class PostToolUseHookInput(BaseHookInput, _SubagentContextMixin): + """Input data for PostToolUse hook events.""" + + hook_event_name: Literal["PostToolUse"] + tool_name: str + tool_input: dict[str, Any] + tool_response: Any + tool_use_id: str + + +class PostToolUseFailureHookInput(BaseHookInput, _SubagentContextMixin): + """Input data for PostToolUseFailure hook events.""" + + hook_event_name: Literal["PostToolUseFailure"] + tool_name: str + tool_input: dict[str, Any] + tool_use_id: str + error: str + is_interrupt: NotRequired[bool] + + +class UserPromptSubmitHookInput(BaseHookInput): + """Input data for UserPromptSubmit hook events.""" + + hook_event_name: Literal["UserPromptSubmit"] + prompt: str + + +class StopHookInput(BaseHookInput): + """Input data for Stop hook events.""" + + hook_event_name: Literal["Stop"] + stop_hook_active: bool + + +class SubagentStopHookInput(BaseHookInput): + """Input data for SubagentStop hook events.""" + + hook_event_name: Literal["SubagentStop"] + stop_hook_active: bool + agent_id: str + agent_transcript_path: str + agent_type: str + + +class PreCompactHookInput(BaseHookInput): + """Input data for PreCompact hook events.""" + + hook_event_name: Literal["PreCompact"] + trigger: Literal["manual", "auto"] + custom_instructions: str | None + + +class NotificationHookInput(BaseHookInput): + """Input data for Notification hook events.""" + + hook_event_name: Literal["Notification"] + message: str + title: NotRequired[str] + notification_type: str + + +class SubagentStartHookInput(BaseHookInput): + """Input data for SubagentStart hook events.""" + + hook_event_name: Literal["SubagentStart"] + agent_id: str + agent_type: str + + +class PermissionRequestHookInput(BaseHookInput, _SubagentContextMixin): + """Input data for PermissionRequest hook events.""" + + hook_event_name: Literal["PermissionRequest"] + tool_name: str + tool_input: dict[str, Any] + permission_suggestions: NotRequired[list[Any]] + + +# Union type for all hook inputs +HookInput = ( + PreToolUseHookInput + | PostToolUseHookInput + | PostToolUseFailureHookInput + | UserPromptSubmitHookInput + | StopHookInput + | SubagentStopHookInput + | PreCompactHookInput + | NotificationHookInput + | SubagentStartHookInput + | PermissionRequestHookInput +) + + +# Hook-specific output types +class PreToolUseHookSpecificOutput(TypedDict): + """Hook-specific output for PreToolUse events.""" + + hookEventName: Literal["PreToolUse"] + permissionDecision: NotRequired[Literal["allow", "deny", "ask"]] + permissionDecisionReason: NotRequired[str] + updatedInput: NotRequired[dict[str, Any]] + additionalContext: NotRequired[str] + + +class PostToolUseHookSpecificOutput(TypedDict): + """Hook-specific output for PostToolUse events.""" + + hookEventName: Literal["PostToolUse"] + additionalContext: NotRequired[str] + updatedMCPToolOutput: NotRequired[Any] + + +class PostToolUseFailureHookSpecificOutput(TypedDict): + """Hook-specific output for PostToolUseFailure events.""" + + hookEventName: Literal["PostToolUseFailure"] + additionalContext: NotRequired[str] + + +class UserPromptSubmitHookSpecificOutput(TypedDict): + """Hook-specific output for UserPromptSubmit events.""" + + hookEventName: Literal["UserPromptSubmit"] + additionalContext: NotRequired[str] + + +class SessionStartHookSpecificOutput(TypedDict): + """Hook-specific output for SessionStart events.""" + + hookEventName: Literal["SessionStart"] + additionalContext: NotRequired[str] + + +class NotificationHookSpecificOutput(TypedDict): + """Hook-specific output for Notification events.""" + + hookEventName: Literal["Notification"] + additionalContext: NotRequired[str] + + +class SubagentStartHookSpecificOutput(TypedDict): + """Hook-specific output for SubagentStart events.""" + + hookEventName: Literal["SubagentStart"] + additionalContext: NotRequired[str] + + +class PermissionRequestHookSpecificOutput(TypedDict): + """Hook-specific output for PermissionRequest events.""" + + hookEventName: Literal["PermissionRequest"] + decision: dict[str, Any] + + +HookSpecificOutput = ( + PreToolUseHookSpecificOutput + | PostToolUseHookSpecificOutput + | PostToolUseFailureHookSpecificOutput + | UserPromptSubmitHookSpecificOutput + | SessionStartHookSpecificOutput + | NotificationHookSpecificOutput + | SubagentStartHookSpecificOutput + | PermissionRequestHookSpecificOutput +) + + +# See https://docs.anthropic.com/en/docs/claude-code/hooks#advanced%3A-json-output +# for documentation of the output types. +# +# IMPORTANT: The Python SDK uses `async_` and `continue_` (with underscores) to avoid +# Python keyword conflicts. These fields are automatically converted to `async` and +# `continue` when sent to the CLI. You should use the underscore versions in your +# Python code. +class AsyncHookJSONOutput(TypedDict): + """Async hook output that defers hook execution. + + Fields: + async_: Set to True to defer hook execution. Note: This is converted to + "async" when sent to the CLI - use "async_" in your Python code. + asyncTimeout: Optional timeout in milliseconds for the async operation. + """ + + async_: Literal[ + True + ] # Using async_ to avoid Python keyword (converted to "async" for CLI) + asyncTimeout: NotRequired[int] + + +class SyncHookJSONOutput(TypedDict): + """Synchronous hook output with control and decision fields. + + This defines the structure for hook callbacks to control execution and provide + feedback to Claude. + + Common Control Fields: + continue_: Whether Claude should proceed after hook execution (default: True). + Note: This is converted to "continue" when sent to the CLI. + suppressOutput: Hide stdout from transcript mode (default: False). + stopReason: Message shown when continue is False. + + Decision Fields: + decision: Set to "block" to indicate blocking behavior. + systemMessage: Warning message displayed to the user. + reason: Feedback message for Claude about the decision. + + Hook-Specific Output: + hookSpecificOutput: Event-specific controls (e.g., permissionDecision for + PreToolUse, additionalContext for PostToolUse). + + Note: The CLI documentation shows field names without underscores ("async", "continue"), + but Python code should use the underscore versions ("async_", "continue_") as they + are automatically converted. + """ + + # Common control fields + continue_: NotRequired[ + bool + ] # Using continue_ to avoid Python keyword (converted to "continue" for CLI) + suppressOutput: NotRequired[bool] + stopReason: NotRequired[str] + + # Decision fields + # Note: "approve" is deprecated for PreToolUse (use permissionDecision instead) + # For other hooks, only "block" is meaningful + decision: NotRequired[Literal["block"]] + systemMessage: NotRequired[str] + reason: NotRequired[str] + + # Hook-specific outputs + hookSpecificOutput: NotRequired[HookSpecificOutput] + + +HookJSONOutput = AsyncHookJSONOutput | SyncHookJSONOutput + + +class HookContext(TypedDict): + """Context information for hook callbacks. + + Fields: + signal: Reserved for future abort signal support. Currently always None. + """ + + signal: Any | None # Future: abort signal support + + +HookCallback = Callable[ + # HookCallback input parameters: + # - input: Strongly-typed hook input with discriminated unions based on hook_event_name + # - tool_use_id: Optional tool use identifier + # - context: Hook context with abort signal support (currently placeholder) + [HookInput, str | None, HookContext], + Awaitable[HookJSONOutput], +] + + +# Hook matcher configuration +@dataclass +class HookMatcher: + """Hook matcher configuration.""" + + # See https://docs.anthropic.com/en/docs/claude-code/hooks#structure for the + # expected string value. For example, for PreToolUse, the matcher can be + # a tool name like "Bash" or a combination of tool names like + # "Write|MultiEdit|Edit". + matcher: str | None = None + + # A list of Python functions with function signature HookCallback + hooks: list[HookCallback] = field(default_factory=list) + + # Timeout in seconds for all hooks in this matcher (default: 60) + timeout: float | None = None + + +# MCP Server config +class McpStdioServerConfig(TypedDict): + """MCP stdio server configuration.""" + + type: NotRequired[Literal["stdio"]] # Optional for backwards compatibility + command: str + args: NotRequired[list[str]] + env: NotRequired[dict[str, str]] + + +class McpSSEServerConfig(TypedDict): + """MCP SSE server configuration.""" + + type: Literal["sse"] + url: str + headers: NotRequired[dict[str, str]] + + +class McpHttpServerConfig(TypedDict): + """MCP HTTP server configuration.""" + + type: Literal["http"] + url: str + headers: NotRequired[dict[str, str]] + + +class McpSdkServerConfig(TypedDict): + """SDK MCP server configuration.""" + + type: Literal["sdk"] + name: str + instance: "McpServer" + + +McpServerConfig = ( + McpStdioServerConfig | McpSSEServerConfig | McpHttpServerConfig | McpSdkServerConfig +) + + +# MCP Server Status types (returned by get_mcp_status) +# These mirror the TypeScript SDK's McpServerStatus type and use wire-format +# field names (camelCase where applicable) since they come directly from CLI +# JSON output. + + +class McpSdkServerConfigStatus(TypedDict): + """SDK MCP server config as returned in status responses. + + Unlike McpSdkServerConfig (which includes the in-process `instance`), + this output-only type only has serializable fields. + """ + + type: Literal["sdk"] + name: str + + +class McpClaudeAIProxyServerConfig(TypedDict): + """Claude.ai proxy MCP server config. + + Output-only type that appears in status responses for servers proxied + through Claude.ai. + """ + + type: Literal["claudeai-proxy"] + url: str + id: str + + +# Broader config type for status responses (includes claudeai-proxy which is +# output-only) +McpServerStatusConfig = ( + McpStdioServerConfig + | McpSSEServerConfig + | McpHttpServerConfig + | McpSdkServerConfigStatus + | McpClaudeAIProxyServerConfig +) + + +class McpToolAnnotations(TypedDict, total=False): + """Tool annotations as returned in MCP server status. + + Wire format uses camelCase field names (from CLI JSON output). + """ + + readOnly: bool + destructive: bool + openWorld: bool + + +class McpToolInfo(TypedDict): + """Information about a tool provided by an MCP server.""" + + name: str + description: NotRequired[str] + annotations: NotRequired[McpToolAnnotations] + + +class McpServerInfo(TypedDict): + """Server info from MCP initialize handshake (available when connected).""" + + name: str + version: str + + +# Connection status values for an MCP server +McpServerConnectionStatus = Literal[ + "connected", "failed", "needs-auth", "pending", "disabled" +] + + +class McpServerStatus(TypedDict): + """Status information for an MCP server connection. + + Returned by `ClaudeSDKClient.get_mcp_status()` in the `mcpServers` list. + """ + + name: str + """Server name as configured.""" + + status: McpServerConnectionStatus + """Current connection status.""" + + serverInfo: NotRequired[McpServerInfo] + """Server information from MCP handshake (available when connected).""" + + error: NotRequired[str] + """Error message (available when status is 'failed').""" + + config: NotRequired[McpServerStatusConfig] + """Server configuration (includes URL for HTTP/SSE servers).""" + + scope: NotRequired[str] + """Configuration scope (e.g., project, user, local, claudeai, managed).""" + + tools: NotRequired[list[McpToolInfo]] + """Tools provided by this server (available when connected).""" + + +class McpStatusResponse(TypedDict): + """Response from `ClaudeSDKClient.get_mcp_status()`. + + Wraps the list of server statuses under the `mcpServers` key, matching + the wire-format response shape. + """ + + mcpServers: list[McpServerStatus] + + +class SdkPluginConfig(TypedDict): + """SDK plugin configuration. + + Currently only local plugins are supported via the 'local' type. + """ + + type: Literal["local"] + path: str + + +# Sandbox configuration types +class SandboxNetworkConfig(TypedDict, total=False): + """Network configuration for sandbox. + + Attributes: + allowUnixSockets: Unix socket paths accessible in sandbox (e.g., SSH agents). + allowAllUnixSockets: Allow all Unix sockets (less secure). + allowLocalBinding: Allow binding to localhost ports (macOS only). + httpProxyPort: HTTP proxy port if bringing your own proxy. + socksProxyPort: SOCKS5 proxy port if bringing your own proxy. + """ + + allowUnixSockets: list[str] + allowAllUnixSockets: bool + allowLocalBinding: bool + httpProxyPort: int + socksProxyPort: int + + +class SandboxIgnoreViolations(TypedDict, total=False): + """Violations to ignore in sandbox. + + Attributes: + file: File paths for which violations should be ignored. + network: Network hosts for which violations should be ignored. + """ + + file: list[str] + network: list[str] + + +class SandboxSettings(TypedDict, total=False): + """Sandbox settings configuration. + + This controls how Claude Code sandboxes bash commands for filesystem + and network isolation. + + **Important:** Filesystem and network restrictions are configured via permission + rules, not via these sandbox settings: + - Filesystem read restrictions: Use Read deny rules + - Filesystem write restrictions: Use Edit allow/deny rules + - Network restrictions: Use WebFetch allow/deny rules + + Attributes: + enabled: Enable bash sandboxing (macOS/Linux only). Default: False + autoAllowBashIfSandboxed: Auto-approve bash commands when sandboxed. Default: True + excludedCommands: Commands that should run outside the sandbox (e.g., ["git", "docker"]) + allowUnsandboxedCommands: Allow commands to bypass sandbox via dangerouslyDisableSandbox. + When False, all commands must run sandboxed (or be in excludedCommands). Default: True + network: Network configuration for sandbox. + ignoreViolations: Violations to ignore. + enableWeakerNestedSandbox: Enable weaker sandbox for unprivileged Docker environments + (Linux only). Reduces security. Default: False + + Example: + ```python + sandbox_settings: SandboxSettings = { + "enabled": True, + "autoAllowBashIfSandboxed": True, + "excludedCommands": ["docker"], + "network": { + "allowUnixSockets": ["/var/run/docker.sock"], + "allowLocalBinding": True + } + } + ``` + """ + + enabled: bool + autoAllowBashIfSandboxed: bool + excludedCommands: list[str] + allowUnsandboxedCommands: bool + network: SandboxNetworkConfig + ignoreViolations: SandboxIgnoreViolations + enableWeakerNestedSandbox: bool + + +# Content block types +@dataclass +class TextBlock: + """Text content block.""" + + text: str + + +@dataclass +class ThinkingBlock: + """Thinking content block.""" + + thinking: str + signature: str + + +@dataclass +class ToolUseBlock: + """Tool use content block.""" + + id: str + name: str + input: dict[str, Any] + + +@dataclass +class ToolResultBlock: + """Tool result content block.""" + + tool_use_id: str + content: str | list[dict[str, Any]] | None = None + is_error: bool | None = None + + +ContentBlock = TextBlock | ThinkingBlock | ToolUseBlock | ToolResultBlock + + +# Message types +AssistantMessageError = Literal[ + "authentication_failed", + "billing_error", + "rate_limit", + "invalid_request", + "server_error", + "unknown", +] + + +@dataclass +class UserMessage: + """User message.""" + + content: str | list[ContentBlock] + uuid: str | None = None + parent_tool_use_id: str | None = None + tool_use_result: dict[str, Any] | None = None + + +@dataclass +class AssistantMessage: + """Assistant message with content blocks.""" + + content: list[ContentBlock] + model: str + parent_tool_use_id: str | None = None + error: AssistantMessageError | None = None + usage: dict[str, Any] | None = None + + +@dataclass +class SystemMessage: + """System message with metadata.""" + + subtype: str + data: dict[str, Any] + + +class TaskUsage(TypedDict): + """Usage statistics reported in task_progress and task_notification messages.""" + + total_tokens: int + tool_uses: int + duration_ms: int + + +# Possible status values for a task_notification message. +TaskNotificationStatus = Literal["completed", "failed", "stopped"] + + +@dataclass +class TaskStartedMessage(SystemMessage): + """System message emitted when a task starts. + + Subclass of SystemMessage: existing ``isinstance(msg, SystemMessage)`` and + ``case SystemMessage()`` checks continue to match. The base ``subtype`` + and ``data`` fields remain populated with the raw payload. + """ + + task_id: str + description: str + uuid: str + session_id: str + tool_use_id: str | None = None + task_type: str | None = None + + +@dataclass +class TaskProgressMessage(SystemMessage): + """System message emitted while a task is in progress. + + Subclass of SystemMessage: existing ``isinstance(msg, SystemMessage)`` and + ``case SystemMessage()`` checks continue to match. The base ``subtype`` + and ``data`` fields remain populated with the raw payload. + """ + + task_id: str + description: str + usage: TaskUsage + uuid: str + session_id: str + tool_use_id: str | None = None + last_tool_name: str | None = None + + +@dataclass +class TaskNotificationMessage(SystemMessage): + """System message emitted when a task completes, fails, or is stopped. + + Subclass of SystemMessage: existing ``isinstance(msg, SystemMessage)`` and + ``case SystemMessage()`` checks continue to match. The base ``subtype`` + and ``data`` fields remain populated with the raw payload. + """ + + task_id: str + status: TaskNotificationStatus + output_file: str + summary: str + uuid: str + session_id: str + tool_use_id: str | None = None + usage: TaskUsage | None = None + + +@dataclass +class ResultMessage: + """Result message with cost and usage information.""" + + subtype: str + duration_ms: int + duration_api_ms: int + is_error: bool + num_turns: int + session_id: str + stop_reason: str | None = None + total_cost_usd: float | None = None + usage: dict[str, Any] | None = None + result: str | None = None + structured_output: Any = None + + +@dataclass +class StreamEvent: + """Stream event for partial message updates during streaming.""" + + uuid: str + session_id: str + event: dict[str, Any] # The raw Anthropic API stream event + parent_tool_use_id: str | None = None + + +# Rate limit types — see https://docs.claude.com/en/docs/claude-code/rate-limits +RateLimitStatus = Literal["allowed", "allowed_warning", "rejected"] +RateLimitType = Literal[ + "five_hour", "seven_day", "seven_day_opus", "seven_day_sonnet", "overage" +] + + +@dataclass +class RateLimitInfo: + """Rate limit status emitted by the CLI when rate limit state changes. + + Attributes: + status: Current rate limit status. ``allowed_warning`` means approaching + the limit; ``rejected`` means the limit has been hit. + resets_at: Unix timestamp when the rate limit window resets. + rate_limit_type: Which rate limit window applies. + utilization: Fraction of the rate limit consumed (0.0 - 1.0). + overage_status: Status of overage/pay-as-you-go usage if applicable. + overage_resets_at: Unix timestamp when overage window resets. + overage_disabled_reason: Why overage is unavailable if status is rejected. + raw: Full raw dict from the CLI, including any fields not modeled above. + """ + + status: RateLimitStatus + resets_at: int | None = None + rate_limit_type: RateLimitType | None = None + utilization: float | None = None + overage_status: RateLimitStatus | None = None + overage_resets_at: int | None = None + overage_disabled_reason: str | None = None + raw: dict[str, Any] = field(default_factory=dict) + + +@dataclass +class RateLimitEvent: + """Rate limit event emitted when rate limit info changes. + + The CLI emits this whenever the rate limit status transitions (e.g. from + ``allowed`` to ``allowed_warning``). Use this to warn users before they + hit a hard limit, or to gracefully back off when ``status == "rejected"``. + """ + + rate_limit_info: RateLimitInfo + uuid: str + session_id: str + + +Message = ( + UserMessage + | AssistantMessage + | SystemMessage + | ResultMessage + | StreamEvent + | RateLimitEvent +) + + +# --------------------------------------------------------------------------- +# Session Listing Types +# --------------------------------------------------------------------------- + + +@dataclass +class SDKSessionInfo: + """Session metadata returned by ``list_sessions()``. + + Contains only data extractable from stat + head/tail reads — no full + JSONL parsing required. + + Attributes: + session_id: Unique session identifier (UUID). + summary: Display title for the session — custom title, auto-generated + summary, or first prompt. + last_modified: Last modified time in milliseconds since epoch. + file_size: Session file size in bytes. Only populated for local + JSONL storage; may be ``None`` for remote storage backends. + custom_title: Session title — user-set custom title or AI-generated title. + first_prompt: First meaningful user prompt in the session. + git_branch: Git branch at the end of the session. + cwd: Working directory for the session. + tag: User-set session tag. + created_at: Creation time in milliseconds since epoch, extracted + from the first entry's ISO timestamp field. More reliable + than stat().birthtime which is unsupported on some filesystems. + """ + + session_id: str + summary: str + last_modified: int + file_size: int | None = None + custom_title: str | None = None + first_prompt: str | None = None + git_branch: str | None = None + cwd: str | None = None + tag: str | None = None + created_at: int | None = None + + +@dataclass +class SessionMessage: + """A user or assistant message from a session transcript. + + Returned by ``get_session_messages()`` for reading historical session + data. Fields match the SDK wire protocol types (SDKUserMessage / + SDKAssistantMessage). + + Attributes: + type: Message type — ``"user"`` or ``"assistant"``. + uuid: Unique message identifier. + session_id: ID of the session this message belongs to. + message: Raw Anthropic API message dict (role, content, etc.). + parent_tool_use_id: Always ``None`` for top-level conversation + messages (tool-use sidechain messages are filtered out). + """ + + type: Literal["user", "assistant"] + uuid: str + session_id: str + message: Any + parent_tool_use_id: None = None + + +class ThinkingConfigAdaptive(TypedDict): + type: Literal["adaptive"] + + +class ThinkingConfigEnabled(TypedDict): + type: Literal["enabled"] + budget_tokens: int + + +class ThinkingConfigDisabled(TypedDict): + type: Literal["disabled"] + + +ThinkingConfig = ThinkingConfigAdaptive | ThinkingConfigEnabled | ThinkingConfigDisabled + + +@dataclass +class ClaudeAgentOptions: + """Query options for Claude SDK.""" + + tools: list[str] | ToolsPreset | None = None + allowed_tools: list[str] = field(default_factory=list) + system_prompt: str | SystemPromptPreset | None = None + mcp_servers: dict[str, McpServerConfig] | str | Path = field(default_factory=dict) + permission_mode: PermissionMode | None = None + continue_conversation: bool = False + resume: str | None = None + max_turns: int | None = None + max_budget_usd: float | None = None + disallowed_tools: list[str] = field(default_factory=list) + model: str | None = None + fallback_model: str | None = None + # Beta features - see https://docs.anthropic.com/en/api/beta-headers + betas: list[SdkBeta] = field(default_factory=list) + permission_prompt_tool_name: str | None = None + cwd: str | Path | None = None + cli_path: str | Path | None = None + settings: str | None = None + add_dirs: list[str | Path] = field(default_factory=list) + env: dict[str, str] = field(default_factory=dict) + extra_args: dict[str, str | None] = field( + default_factory=dict + ) # Pass arbitrary CLI flags + max_buffer_size: int | None = None # Max bytes when buffering CLI stdout + debug_stderr: Any = ( + sys.stderr + ) # Deprecated: File-like object for debug output. Use stderr callback instead. + stderr: Callable[[str], None] | None = None # Callback for stderr output from CLI + + # Tool permission callback + can_use_tool: CanUseTool | None = None + + # Hook configurations + hooks: dict[HookEvent, list[HookMatcher]] | None = None + + user: str | None = None + + # Partial message streaming support + include_partial_messages: bool = False + # When true resumed sessions will fork to a new session ID rather than + # continuing the previous session. + fork_session: bool = False + # Agent definitions for custom agents + agents: dict[str, AgentDefinition] | None = None + # Setting sources to load (user, project, local) + setting_sources: list[SettingSource] | None = None + # Sandbox configuration for bash command isolation. + # Filesystem and network restrictions are derived from permission rules (Read/Edit/WebFetch), + # not from these sandbox settings. + sandbox: SandboxSettings | None = None + # Plugin configurations for custom plugins + plugins: list[SdkPluginConfig] = field(default_factory=list) + # Max tokens for thinking blocks + # @deprecated Use `thinking` instead. + max_thinking_tokens: int | None = None + # Controls extended thinking behavior. Takes precedence over max_thinking_tokens. + thinking: ThinkingConfig | None = None + # Effort level for thinking depth. + effort: Literal["low", "medium", "high", "max"] | None = None + # Output format for structured outputs (matches Messages API structure) + # Example: {"type": "json_schema", "schema": {"type": "object", "properties": {...}}} + output_format: dict[str, Any] | None = None + # Enable file checkpointing to track file changes during the session. + # When enabled, files can be rewound to their state at any user message + # using `ClaudeSDKClient.rewind_files()`. + enable_file_checkpointing: bool = False + + +# SDK Control Protocol +class SDKControlInterruptRequest(TypedDict): + subtype: Literal["interrupt"] + + +class SDKControlPermissionRequest(TypedDict): + subtype: Literal["can_use_tool"] + tool_name: str + input: dict[str, Any] + # TODO: Add PermissionUpdate type here + permission_suggestions: list[Any] | None + blocked_path: str | None + + +class SDKControlInitializeRequest(TypedDict): + subtype: Literal["initialize"] + hooks: dict[HookEvent, Any] | None + agents: NotRequired[dict[str, dict[str, Any]]] + + +class SDKControlSetPermissionModeRequest(TypedDict): + subtype: Literal["set_permission_mode"] + # TODO: Add PermissionMode + mode: str + + +class SDKHookCallbackRequest(TypedDict): + subtype: Literal["hook_callback"] + callback_id: str + input: Any + tool_use_id: str | None + + +class SDKControlMcpMessageRequest(TypedDict): + subtype: Literal["mcp_message"] + server_name: str + message: Any + + +class SDKControlRewindFilesRequest(TypedDict): + subtype: Literal["rewind_files"] + user_message_id: str + + +class SDKControlMcpReconnectRequest(TypedDict): + """Reconnects a disconnected or failed MCP server.""" + + subtype: Literal["mcp_reconnect"] + # Note: wire protocol uses camelCase for this field + serverName: str + + +class SDKControlMcpToggleRequest(TypedDict): + """Enables or disables an MCP server.""" + + subtype: Literal["mcp_toggle"] + # Note: wire protocol uses camelCase for this field + serverName: str + enabled: bool + + +class SDKControlStopTaskRequest(TypedDict): + subtype: Literal["stop_task"] + task_id: str + + +class SDKControlRequest(TypedDict): + type: Literal["control_request"] + request_id: str + request: ( + SDKControlInterruptRequest + | SDKControlPermissionRequest + | SDKControlInitializeRequest + | SDKControlSetPermissionModeRequest + | SDKHookCallbackRequest + | SDKControlMcpMessageRequest + | SDKControlRewindFilesRequest + | SDKControlMcpReconnectRequest + | SDKControlMcpToggleRequest + | SDKControlStopTaskRequest + ) + + +class ControlResponse(TypedDict): + subtype: Literal["success"] + request_id: str + response: dict[str, Any] | None + + +class ControlErrorResponse(TypedDict): + subtype: Literal["error"] + request_id: str + error: str + + +class SDKControlResponse(TypedDict): + type: Literal["control_response"] + response: ControlResponse | ControlErrorResponse