clawd-code-sdk 0.3.0__tar.gz → 0.3.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. {clawd_code_sdk-0.3.0 → clawd_code_sdk-0.3.1}/PKG-INFO +1 -1
  2. {clawd_code_sdk-0.3.0 → clawd_code_sdk-0.3.1}/pyproject.toml +1 -1
  3. {clawd_code_sdk-0.3.0 → clawd_code_sdk-0.3.1}/src/clawd_code_sdk/__init__.py +6 -0
  4. clawd_code_sdk-0.3.1/src/clawd_code_sdk/list_sessions.py +245 -0
  5. {clawd_code_sdk-0.3.0 → clawd_code_sdk-0.3.1}/src/clawd_code_sdk/models/__init__.py +4 -0
  6. {clawd_code_sdk-0.3.0 → clawd_code_sdk-0.3.1}/src/clawd_code_sdk/models/base.py +21 -1
  7. {clawd_code_sdk-0.3.0 → clawd_code_sdk-0.3.1}/src/clawd_code_sdk/models/content_blocks.py +2 -2
  8. {clawd_code_sdk-0.3.0 → clawd_code_sdk-0.3.1}/src/clawd_code_sdk/models/messages.py +48 -16
  9. {clawd_code_sdk-0.3.0 → clawd_code_sdk-0.3.1}/src/clawd_code_sdk/models/options.py +20 -1
  10. clawd_code_sdk-0.3.1/tests/e2e/test_mcp_tools.py +193 -0
  11. {clawd_code_sdk-0.3.0 → clawd_code_sdk-0.3.1}/tests/mcp_server.py +14 -1
  12. clawd_code_sdk-0.3.0/tests/e2e/test_mcp_image.py +0 -121
  13. {clawd_code_sdk-0.3.0 → clawd_code_sdk-0.3.1}/.gitignore +0 -0
  14. {clawd_code_sdk-0.3.0 → clawd_code_sdk-0.3.1}/LICENSE +0 -0
  15. {clawd_code_sdk-0.3.0 → clawd_code_sdk-0.3.1}/README.md +0 -0
  16. {clawd_code_sdk-0.3.0 → clawd_code_sdk-0.3.1}/src/clawd_code_sdk/_bundled/.gitignore +0 -0
  17. {clawd_code_sdk-0.3.0 → clawd_code_sdk-0.3.1}/src/clawd_code_sdk/_errors.py +0 -0
  18. {clawd_code_sdk-0.3.0 → clawd_code_sdk-0.3.1}/src/clawd_code_sdk/_internal/__init__.py +0 -0
  19. {clawd_code_sdk-0.3.0 → clawd_code_sdk-0.3.1}/src/clawd_code_sdk/_internal/message_parser.py +0 -0
  20. {clawd_code_sdk-0.3.0 → clawd_code_sdk-0.3.1}/src/clawd_code_sdk/_internal/query.py +0 -0
  21. {clawd_code_sdk-0.3.0 → clawd_code_sdk-0.3.1}/src/clawd_code_sdk/_internal/transport/__init__.py +0 -0
  22. {clawd_code_sdk-0.3.0 → clawd_code_sdk-0.3.1}/src/clawd_code_sdk/_internal/transport/subprocess_cli.py +0 -0
  23. {clawd_code_sdk-0.3.0 → clawd_code_sdk-0.3.1}/src/clawd_code_sdk/_version.py +0 -0
  24. {clawd_code_sdk-0.3.0 → clawd_code_sdk-0.3.1}/src/clawd_code_sdk/anthropic_types.py +0 -0
  25. {clawd_code_sdk-0.3.0 → clawd_code_sdk-0.3.1}/src/clawd_code_sdk/client.py +0 -0
  26. {clawd_code_sdk-0.3.0 → clawd_code_sdk-0.3.1}/src/clawd_code_sdk/mcp_utils.py +0 -0
  27. {clawd_code_sdk-0.3.0 → clawd_code_sdk-0.3.1}/src/clawd_code_sdk/models/agents.py +0 -0
  28. {clawd_code_sdk-0.3.0 → clawd_code_sdk-0.3.1}/src/clawd_code_sdk/models/control.py +0 -0
  29. {clawd_code_sdk-0.3.0 → clawd_code_sdk-0.3.1}/src/clawd_code_sdk/models/hooks.py +0 -0
  30. {clawd_code_sdk-0.3.0 → clawd_code_sdk-0.3.1}/src/clawd_code_sdk/models/input_types.py +0 -0
  31. {clawd_code_sdk-0.3.0 → clawd_code_sdk-0.3.1}/src/clawd_code_sdk/models/mcp.py +0 -0
  32. {clawd_code_sdk-0.3.0 → clawd_code_sdk-0.3.1}/src/clawd_code_sdk/models/output_types.py +0 -0
  33. {clawd_code_sdk-0.3.0 → clawd_code_sdk-0.3.1}/src/clawd_code_sdk/models/permissions.py +0 -0
  34. {clawd_code_sdk-0.3.0 → clawd_code_sdk-0.3.1}/src/clawd_code_sdk/models/sandbox.py +0 -0
  35. {clawd_code_sdk-0.3.0 → clawd_code_sdk-0.3.1}/src/clawd_code_sdk/models/server_info.py +0 -0
  36. {clawd_code_sdk-0.3.0 → clawd_code_sdk-0.3.1}/src/clawd_code_sdk/models/ts_output_types.py +0 -0
  37. {clawd_code_sdk-0.3.0 → clawd_code_sdk-0.3.1}/src/clawd_code_sdk/py.typed +0 -0
  38. {clawd_code_sdk-0.3.0 → clawd_code_sdk-0.3.1}/src/clawd_code_sdk/query.py +0 -0
  39. {clawd_code_sdk-0.3.0 → clawd_code_sdk-0.3.1}/src/clawd_code_sdk/session.py +0 -0
  40. {clawd_code_sdk-0.3.0 → clawd_code_sdk-0.3.1}/src/clawd_code_sdk/storage/ARCHITECTURE.md +0 -0
  41. {clawd_code_sdk-0.3.0 → clawd_code_sdk-0.3.1}/src/clawd_code_sdk/storage/__init__.py +0 -0
  42. {clawd_code_sdk-0.3.0 → clawd_code_sdk-0.3.1}/src/clawd_code_sdk/storage/helpers.py +0 -0
  43. {clawd_code_sdk-0.3.0 → clawd_code_sdk-0.3.1}/src/clawd_code_sdk/storage/models.py +0 -0
  44. {clawd_code_sdk-0.3.0 → clawd_code_sdk-0.3.1}/src/clawd_code_sdk/storage/replay.py +0 -0
  45. {clawd_code_sdk-0.3.0 → clawd_code_sdk-0.3.1}/src/clawd_code_sdk/usage.py +0 -0
  46. {clawd_code_sdk-0.3.0 → clawd_code_sdk-0.3.1}/tests/conftest.py +0 -0
  47. {clawd_code_sdk-0.3.0 → clawd_code_sdk-0.3.1}/tests/e2e/__init__.py +0 -0
  48. {clawd_code_sdk-0.3.0 → clawd_code_sdk-0.3.1}/tests/e2e/test_agents_and_settings.py +0 -0
  49. {clawd_code_sdk-0.3.0 → clawd_code_sdk-0.3.1}/tests/e2e/test_dynamic_control.py +0 -0
  50. {clawd_code_sdk-0.3.0 → clawd_code_sdk-0.3.1}/tests/e2e/test_hook_events.py +0 -0
  51. {clawd_code_sdk-0.3.0 → clawd_code_sdk-0.3.1}/tests/e2e/test_hooks.py +0 -0
  52. {clawd_code_sdk-0.3.0 → clawd_code_sdk-0.3.1}/tests/e2e/test_include_partial_messages.py +0 -0
  53. {clawd_code_sdk-0.3.0 → clawd_code_sdk-0.3.1}/tests/e2e/test_sdk_mcp_tools.py +0 -0
  54. {clawd_code_sdk-0.3.0 → clawd_code_sdk-0.3.1}/tests/e2e/test_slash_commands.py +0 -0
  55. {clawd_code_sdk-0.3.0 → clawd_code_sdk-0.3.1}/tests/e2e/test_stderr_callback.py +0 -0
  56. {clawd_code_sdk-0.3.0 → clawd_code_sdk-0.3.1}/tests/e2e/test_structured_output.py +0 -0
  57. {clawd_code_sdk-0.3.0 → clawd_code_sdk-0.3.1}/tests/e2e/test_subagent_invocation.py +0 -0
  58. {clawd_code_sdk-0.3.0 → clawd_code_sdk-0.3.1}/tests/e2e/test_tool_permissions.py +0 -0
  59. {clawd_code_sdk-0.3.0 → clawd_code_sdk-0.3.1}/tests/mock_claude_server.py +0 -0
  60. {clawd_code_sdk-0.3.0 → clawd_code_sdk-0.3.1}/tests/test_changelog.py +0 -0
  61. {clawd_code_sdk-0.3.0 → clawd_code_sdk-0.3.1}/tests/test_client.py +0 -0
  62. {clawd_code_sdk-0.3.0 → clawd_code_sdk-0.3.1}/tests/test_errors.py +0 -0
  63. {clawd_code_sdk-0.3.0 → clawd_code_sdk-0.3.1}/tests/test_image.png +0 -0
  64. {clawd_code_sdk-0.3.0 → clawd_code_sdk-0.3.1}/tests/test_integration.py +0 -0
  65. {clawd_code_sdk-0.3.0 → clawd_code_sdk-0.3.1}/tests/test_message_parser.py +0 -0
  66. {clawd_code_sdk-0.3.0 → clawd_code_sdk-0.3.1}/tests/test_sdk_mcp_integration.py +0 -0
  67. {clawd_code_sdk-0.3.0 → clawd_code_sdk-0.3.1}/tests/test_session.py +0 -0
  68. {clawd_code_sdk-0.3.0 → clawd_code_sdk-0.3.1}/tests/test_streaming_client.py +0 -0
  69. {clawd_code_sdk-0.3.0 → clawd_code_sdk-0.3.1}/tests/test_subprocess_buffering.py +0 -0
  70. {clawd_code_sdk-0.3.0 → clawd_code_sdk-0.3.1}/tests/test_tool_callbacks.py +0 -0
  71. {clawd_code_sdk-0.3.0 → clawd_code_sdk-0.3.1}/tests/test_transport.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: clawd-code-sdk
3
- Version: 0.3.0
3
+ Version: 0.3.1
4
4
  Summary: Python SDK for Claude Code
5
5
  Project-URL: Documentation, https://github.com/phil65/claude-agent-sdk-python
6
6
  Project-URL: Homepage, https://github.com/phil65/claude-agent-sdk-python
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "clawd-code-sdk"
3
- version = "0.3.0"
3
+ version = "0.3.1"
4
4
  description = "Python SDK for Claude Code"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.13"
@@ -20,6 +20,7 @@ from ._internal.transport import Transport
20
20
  from ._version import __version__
21
21
  from .anthropic_types import ToolResultContentBlock
22
22
  from .client import ClaudeSDKClient
23
+ from .list_sessions import list_sessions
23
24
  from .models import (
24
25
  AgentDefinition,
25
26
  ContinueLatest,
@@ -33,6 +34,7 @@ from .models import (
33
34
  HookInput,
34
35
  HookJSONOutput,
35
36
  HookMatcher,
37
+ ListSessionsOptions,
36
38
  McpSdkServerConfig,
37
39
  McpServerConfig,
38
40
  Message,
@@ -81,6 +83,7 @@ from .models import (
81
83
  UserPromptSubmitHookInput,
82
84
  NewSession,
83
85
  ResumeSession,
86
+ SDKSessionInfo,
84
87
  SessionConfig,
85
88
  )
86
89
  from .query import query
@@ -102,14 +105,17 @@ __cli_version__ = "2.1.11"
102
105
  __all__ = [
103
106
  # Main exports
104
107
  "query",
108
+ "list_sessions",
105
109
  "__version__",
106
110
  # Transport
107
111
  "Transport",
108
112
  "ClaudeSDKClient",
109
113
  # Session config
110
114
  "ContinueLatest",
115
+ "ListSessionsOptions",
111
116
  "NewSession",
112
117
  "ResumeSession",
118
+ "SDKSessionInfo",
113
119
  "SessionConfig",
114
120
  # Types
115
121
  "PermissionMode",
@@ -0,0 +1,245 @@
1
+ """List stored Claude Code sessions with metadata."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ from pathlib import Path # noqa: TC003
7
+ from typing import TYPE_CHECKING
8
+
9
+ import anyenv
10
+
11
+ from clawd_code_sdk.models.messages import SDKSessionInfo
12
+ from clawd_code_sdk.storage.helpers import (
13
+ decode_project_path,
14
+ get_claude_projects_dir,
15
+ path_to_claude_dir_name,
16
+ )
17
+
18
+
19
+ if TYPE_CHECKING:
20
+ from clawd_code_sdk.models.options import ListSessionsOptions
21
+
22
+ logger = logging.getLogger(__name__)
23
+
24
+
25
+ def _read_git_branch_from_tail(path: Path) -> str | None:
26
+ """Read git branch from the last entries of a JSONL file.
27
+
28
+ Reads from the end of the file for efficiency. Scans backward
29
+ through the last chunk of lines to find an entry with a
30
+ ``gitBranch`` field (not all entry types carry it).
31
+
32
+ Args:
33
+ path: Path to the JSONL file.
34
+
35
+ Returns:
36
+ The git branch string, or None if not found.
37
+ """
38
+ try:
39
+ with path.open("rb") as f:
40
+ f.seek(0, 2)
41
+ size = f.tell()
42
+ if size == 0:
43
+ return None
44
+ chunk_size = min(size, 32768)
45
+ f.seek(-chunk_size, 2)
46
+ data = f.read().decode("utf-8", errors="ignore")
47
+ lines = data.strip().split("\n")
48
+ for line in reversed(lines):
49
+ line = line.strip()
50
+ if not line:
51
+ continue
52
+ # Quick string check before parsing
53
+ if "gitBranch" not in line and "git_branch" not in line:
54
+ continue
55
+ try:
56
+ entry: dict[str, object] = anyenv.load_json(line, return_type=dict)
57
+ branch = entry.get("gitBranch") or entry.get("git_branch")
58
+ if isinstance(branch, str) and branch:
59
+ return branch
60
+ except anyenv.JsonLoadError:
61
+ continue
62
+ except OSError:
63
+ pass
64
+ return None
65
+
66
+
67
+ def _extract_session_metadata(
68
+ session_path: Path,
69
+ ) -> tuple[str | None, str | None]:
70
+ """Extract custom_title and first_prompt from a session file.
71
+
72
+ Reads the file line by line, stopping as early as possible.
73
+ Summary entries provide the custom title; the first user message
74
+ provides the first prompt.
75
+
76
+ Args:
77
+ session_path: Path to the JSONL session file.
78
+
79
+ Returns:
80
+ A ``(custom_title, first_prompt)`` tuple.
81
+ """
82
+ custom_title: str | None = None
83
+ first_prompt: str | None = None
84
+
85
+ try:
86
+ with session_path.open(encoding="utf-8", errors="ignore") as fp:
87
+ for line in fp:
88
+ if '"type":"summary"' in line or '"type": "summary"' in line:
89
+ try:
90
+ entry: dict[str, object] = anyenv.load_json(line, return_type=dict)
91
+ if summary := entry.get("summary"):
92
+ custom_title = str(summary)
93
+ except anyenv.JsonLoadError:
94
+ pass
95
+
96
+ elif first_prompt is None and ('"type":"user"' in line or '"type": "user"' in line):
97
+ try:
98
+ entry = anyenv.load_json(line, return_type=dict)
99
+ msg = entry.get("message")
100
+ if isinstance(msg, dict):
101
+ content = msg.get("content", "")
102
+ if isinstance(content, str) and content:
103
+ first_line = content.split("\n")[0].strip()
104
+ if first_line:
105
+ first_prompt = first_line
106
+ except anyenv.JsonLoadError:
107
+ pass
108
+
109
+ # Stop early when we have both
110
+ if custom_title is not None and first_prompt is not None:
111
+ break
112
+ except OSError:
113
+ pass
114
+
115
+ return custom_title, first_prompt
116
+
117
+
118
+ def _build_session_info(session_path: Path, project_cwd: str | None) -> SDKSessionInfo | None:
119
+ """Build an SDKSessionInfo from a session JSONL file.
120
+
121
+ Args:
122
+ session_path: Path to the ``.jsonl`` session file.
123
+ project_cwd: Working directory derived from the project folder name,
124
+ or None if unknown.
125
+
126
+ Returns:
127
+ Populated SDKSessionInfo, or None if the file cannot be read.
128
+ """
129
+ try:
130
+ stat = session_path.stat()
131
+ except OSError:
132
+ return None
133
+
134
+ session_id = session_path.stem
135
+ last_modified = int(stat.st_mtime * 1000) # milliseconds since epoch
136
+ file_size = stat.st_size
137
+
138
+ custom_title, first_prompt = _extract_session_metadata(session_path)
139
+
140
+ # Get git branch from the tail of the file.
141
+ # The raw JSONL uses camelCase ("gitBranch") per ClaudeCodeBaseModel alias config.
142
+ # Not all entry types carry gitBranch, so we scan backward until we find one.
143
+ git_branch = _read_git_branch_from_tail(session_path)
144
+
145
+ # Build display summary: prefer custom title, then first prompt, then session ID
146
+ summary = custom_title or first_prompt or session_id
147
+
148
+ return SDKSessionInfo(
149
+ session_id=session_id,
150
+ summary=summary,
151
+ last_modified=last_modified,
152
+ file_size=file_size,
153
+ custom_title=custom_title,
154
+ first_prompt=first_prompt,
155
+ git_branch=git_branch,
156
+ cwd=project_cwd,
157
+ )
158
+
159
+
160
+ def _list_session_files_for_dir(directory: str) -> list[tuple[Path, str | None]]:
161
+ """List session files for a specific project directory.
162
+
163
+ Args:
164
+ directory: Filesystem path of the project.
165
+
166
+ Returns:
167
+ List of ``(session_path, cwd)`` tuples.
168
+ """
169
+ projects_dir = get_claude_projects_dir()
170
+ dir_name = path_to_claude_dir_name(directory)
171
+ project_dir = projects_dir / dir_name
172
+ if not project_dir.is_dir():
173
+ return []
174
+ return [(p, directory) for p in project_dir.glob("*.jsonl")]
175
+
176
+
177
+ def _list_all_session_files() -> list[tuple[Path, str | None]]:
178
+ """List session files across all projects.
179
+
180
+ Returns:
181
+ List of ``(session_path, cwd)`` tuples where cwd is decoded
182
+ from the project directory name.
183
+ """
184
+ projects_dir = get_claude_projects_dir()
185
+ if not projects_dir.is_dir():
186
+ return []
187
+ results: list[tuple[Path, str | None]] = []
188
+ for project_dir in projects_dir.iterdir():
189
+ if not project_dir.is_dir():
190
+ continue
191
+ cwd = decode_project_path(project_dir.name)
192
+ for session_file in project_dir.glob("*.jsonl"):
193
+ results.append((session_file, cwd))
194
+ return results
195
+
196
+
197
+ def list_sessions(options: ListSessionsOptions | None = None) -> list[SDKSessionInfo]:
198
+ """List sessions with metadata.
199
+
200
+ When ``dir`` is provided in *options*, returns sessions for that project
201
+ directory and its git worktrees. When omitted, returns sessions across
202
+ all projects.
203
+
204
+ Args:
205
+ options: Optional filtering/limiting options.
206
+
207
+ Returns:
208
+ Session metadata sorted by last modified time (newest first).
209
+
210
+ Example::
211
+
212
+ from clawd_code_sdk import list_sessions
213
+
214
+ # List sessions for a specific project
215
+ sessions = list_sessions({"dir": "/path/to/project"})
216
+
217
+ # List all sessions across all projects
218
+ all_sessions = list_sessions()
219
+ """
220
+ opts = options or {}
221
+ directory = opts.get("dir")
222
+ limit = opts.get("limit")
223
+
224
+ # Collect session files
225
+ session_files: list[tuple[Path, str | None]]
226
+ if directory is not None:
227
+ session_files = _list_session_files_for_dir(directory)
228
+ else:
229
+ session_files = _list_all_session_files()
230
+
231
+ # Build session info for each file
232
+ sessions: list[SDKSessionInfo] = []
233
+ for session_path, cwd in session_files:
234
+ info = _build_session_info(session_path, cwd)
235
+ if info is not None:
236
+ sessions.append(info)
237
+
238
+ # Sort by last_modified descending (newest first)
239
+ sessions.sort(key=lambda s: s.last_modified, reverse=True)
240
+
241
+ # Apply limit
242
+ if limit is not None and limit > 0:
243
+ sessions = sessions[:limit]
244
+
245
+ return sessions
@@ -213,6 +213,7 @@ from .messages import (
213
213
  ResultMessage,
214
214
  ResultSuccessMessage,
215
215
  SDKPermissionDenial,
216
+ SDKSessionInfo,
216
217
  StatusSystemMessage,
217
218
  StreamEvent,
218
219
  AuthStatusMessage,
@@ -235,6 +236,7 @@ from .options import ClaudeAgentOptions
235
236
  from .options import (
236
237
  BaseSessionConfig,
237
238
  ContinueLatest,
239
+ ListSessionsOptions,
238
240
  NewSession,
239
241
  ResumeSession,
240
242
  SessionConfig,
@@ -356,6 +358,7 @@ __all__ = [
356
358
  "ResultMessage",
357
359
  "ResultSuccessMessage",
358
360
  "SDKPermissionDenial",
361
+ "SDKSessionInfo",
359
362
  "StatusSystemMessage",
360
363
  "StreamEvent",
361
364
  "AuthStatusMessage",
@@ -382,6 +385,7 @@ __all__ = [
382
385
  "ClaudeAgentOptions",
383
386
  "BaseSessionConfig",
384
387
  "ContinueLatest",
388
+ "ListSessionsOptions",
385
389
  "NewSession",
386
390
  "ResumeSession",
387
391
  "SessionConfig",
@@ -33,7 +33,27 @@ StopReason = Literal[
33
33
  ]
34
34
  ApiKeySource = Literal["none", "env", "config", "ANTHROPIC_API_KEY"]
35
35
  SettingSource = Literal["user", "project", "local"]
36
-
36
+ ToolName = Literal[
37
+ "Task",
38
+ "TaskOutput",
39
+ "Bash",
40
+ "Glob",
41
+ "Grep",
42
+ "ExitPlanMode",
43
+ "Read",
44
+ "Edit",
45
+ "Write",
46
+ "NotebookEdit",
47
+ "WebFetch",
48
+ "TodoWrite",
49
+ "WebSearch",
50
+ "TaskStop",
51
+ "AskUserQuestion",
52
+ "Skill",
53
+ "EnterPlanMode",
54
+ "EnterWorktree",
55
+ "ToolSearch",
56
+ ]
37
57
  IS_DEV = "pytest" in sys.modules
38
58
 
39
59
 
@@ -9,7 +9,7 @@ from typing import TYPE_CHECKING, Annotated, Any, Literal
9
9
  from pydantic import ConfigDict, Discriminator, TypeAdapter
10
10
 
11
11
  from clawd_code_sdk.models import ToolInput # noqa: TC001
12
- from clawd_code_sdk.models.base import ClaudeCodeBaseModel
12
+ from clawd_code_sdk.models.base import ClaudeCodeBaseModel, ToolName # noqa: TC001
13
13
 
14
14
 
15
15
  if TYPE_CHECKING:
@@ -40,7 +40,7 @@ class ToolUseBlock:
40
40
 
41
41
  type: Literal["tool_use"] = field(default="tool_use", repr=False)
42
42
  id: str = ""
43
- name: str = ""
43
+ name: ToolName | str = ""
44
44
  input: ToolInput | dict[str, Any] = field(default_factory=dict)
45
45
  caller: dict[str, str] | None = None
46
46
 
@@ -23,7 +23,7 @@ from clawd_code_sdk.models.content_blocks import ContentBlock, TextBlock # noqa
23
23
  from clawd_code_sdk.models.mcp import McpConnectionStatus # noqa: TC001
24
24
  from clawd_code_sdk.models.output_types import ToolUseResult # noqa: TC001
25
25
 
26
- from .base import ApiKeySource, PermissionMode, StopReason, TaskStatus # noqa: TC001
26
+ from .base import ApiKeySource, PermissionMode, StopReason, TaskStatus, ToolName # noqa: TC001
27
27
 
28
28
 
29
29
  if TYPE_CHECKING:
@@ -48,6 +48,7 @@ ErrorSubType = Literal[
48
48
  "error_max_budget_usd",
49
49
  "error_max_structured_output_retries",
50
50
  ]
51
+ Outcome = Literal["success", "error", "cancelled"]
51
52
 
52
53
 
53
54
  class UserPromptMessageContent(TypedDict):
@@ -115,7 +116,7 @@ class AssistantMessage:
115
116
 
116
117
  type: Literal["assistant"] = "assistant"
117
118
  content: Sequence[ContentBlock]
118
- model: str
119
+ model: Model | str
119
120
  parent_tool_use_id: str | None = None
120
121
  error: AssistantMessageError | None = None
121
122
  session_id: str | None = None # not sure these two are needed.
@@ -281,7 +282,7 @@ class TaskProgressSystemMessage(BaseSystemMessage):
281
282
  tool_use_id: str | None = None
282
283
  description: str = ""
283
284
  usage: TaskProgressUsage | None = None
284
- last_tool_name: str | None = None
285
+ last_tool_name: ToolName | str | None = None
285
286
 
286
287
 
287
288
  class FilePersistedEntry(TypedDict):
@@ -303,11 +304,9 @@ class FilesPersistedSystemMessage(BaseSystemMessage):
303
304
  """System message emitted when files have been persisted."""
304
305
 
305
306
  subtype: Literal["files_persisted"] = "files_persisted"
306
- files: list[FilePersistedEntry] | None = None
307
- failed: list[FilePersistedFailure] | None = None
308
- processed_at: str = ""
309
- uuid: str = ""
310
- session_id: str = ""
307
+ files: list[FilePersistedEntry]
308
+ failed: list[FilePersistedFailure]
309
+ processed_at: str
311
310
 
312
311
 
313
312
  @dataclass(kw_only=True)
@@ -315,12 +314,12 @@ class HookProgressSystemMessage(BaseSystemMessage):
315
314
  """Progress update from a running hook."""
316
315
 
317
316
  subtype: Literal["hook_progress"] = "hook_progress"
318
- hook_id: str = ""
319
- hook_name: str = ""
320
- hook_event: str = ""
321
- stdout: str = ""
322
- stderr: str = ""
323
- output: str = ""
317
+ hook_id: str
318
+ hook_name: str
319
+ hook_event: str
320
+ stdout: str
321
+ stderr: str
322
+ output: str
324
323
 
325
324
 
326
325
  @dataclass(kw_only=True)
@@ -331,7 +330,7 @@ class HookResponseSystemMessage(BaseSystemMessage):
331
330
  hook_id: str
332
331
  hook_name: str
333
332
  hook_event: str
334
- outcome: Literal["success", "error", "cancelled"]
333
+ outcome: Outcome
335
334
  exit_code: int | None = None
336
335
  stderr: str
337
336
  stdout: str
@@ -366,7 +365,7 @@ class ModelUsage(TypedDict):
366
365
 
367
366
 
368
367
  class SDKPermissionDenial(TypedDict):
369
- tool_name: str
368
+ tool_name: ToolName | str
370
369
  tool_use_id: str
371
370
  tool_input: ToolInput
372
371
 
@@ -456,6 +455,39 @@ class AuthStatusMessage(BaseMessage):
456
455
  error: str | None = None
457
456
 
458
457
 
458
+ @dataclass(kw_only=True)
459
+ class SDKSessionInfo:
460
+ """Session metadata returned by list_sessions.
461
+
462
+ Contains summary information about a stored session without
463
+ loading the full conversation history.
464
+ """
465
+
466
+ session_id: str
467
+ """Unique session identifier (UUID)."""
468
+
469
+ summary: str
470
+ """Display title for the session: custom title, auto-generated summary, or first prompt."""
471
+
472
+ last_modified: int
473
+ """Last modified time in milliseconds since epoch."""
474
+
475
+ file_size: int
476
+ """Session file size in bytes."""
477
+
478
+ custom_title: str | None = None
479
+ """User-set session title via /rename."""
480
+
481
+ first_prompt: str | None = None
482
+ """First meaningful user prompt in the session."""
483
+
484
+ git_branch: str | None = None
485
+ """Git branch at the end of the session."""
486
+
487
+ cwd: str | None = None
488
+ """Working directory for the session."""
489
+
490
+
459
491
  SystemMessageUnion = Annotated[
460
492
  InitSystemMessage
461
493
  | HookStartedSystemMessage
@@ -6,7 +6,7 @@ from dataclasses import dataclass, field
6
6
  import json
7
7
  import logging
8
8
  from pathlib import Path
9
- from typing import TYPE_CHECKING, Any, Literal
9
+ from typing import TYPE_CHECKING, Any, Literal, TypedDict
10
10
 
11
11
 
12
12
  if TYPE_CHECKING:
@@ -85,6 +85,25 @@ Can also be specified as a plain ``str``, which is a shortcut for
85
85
  """
86
86
 
87
87
 
88
+ class ListSessionsOptions(TypedDict, total=False):
89
+ """Options for listing sessions.
90
+
91
+ When ``dir`` is provided, returns sessions for that project directory
92
+ and its git worktrees. When omitted, returns sessions across all projects.
93
+ """
94
+
95
+ dir: str
96
+ """Directory to list sessions for.
97
+
98
+ When provided, returns sessions for this project directory
99
+ (and its git worktrees). When omitted, returns sessions
100
+ across all projects.
101
+ """
102
+
103
+ limit: int
104
+ """Maximum number of sessions to return."""
105
+
106
+
88
107
  def resolve_session_config(value: str | SessionConfig | None) -> SessionConfig:
89
108
  """Normalize a session config value.
90
109
 
@@ -0,0 +1,193 @@
1
+ """End-to-end tests for MCP tools over the wire.
2
+
3
+ Tests whether content blocks from an external MCP server
4
+ (FastMCP stdio) are correctly received through the Claude Code CLI.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from pathlib import Path
10
+ import sys
11
+ from typing import TYPE_CHECKING, Any
12
+
13
+ import pytest
14
+
15
+ from clawd_code_sdk import (
16
+ AssistantMessage,
17
+ ClaudeAgentOptions,
18
+ ClaudeSDKClient,
19
+ ResultMessage,
20
+ UserMessage,
21
+ )
22
+ from clawd_code_sdk.models.content_blocks import TextBlock, ToolResultBlock, ToolUseBlock
23
+ from clawd_code_sdk.models.messages import ToolProgressMessage
24
+
25
+
26
+ if TYPE_CHECKING:
27
+ from clawd_code_sdk.models.messages import Message
28
+
29
+
30
+ @pytest.mark.e2e
31
+ @pytest.mark.asyncio
32
+ async def test_mcp_image_tool_wire_format():
33
+ """Test that image content from an MCP tool flows through the wire protocol.
34
+
35
+ Configures Claude Code with a FastMCP stdio server that has a single tool
36
+ returning a PNG image, then asks Claude to call it and inspects what
37
+ content blocks come back.
38
+ """
39
+ mcp_server_path = str(Path(__file__).parent.parent / "mcp_server.py")
40
+
41
+ options = ClaudeAgentOptions(
42
+ mcp_servers={
43
+ "image_test": {"type": "stdio", "command": sys.executable, "args": [mcp_server_path]},
44
+ },
45
+ permission_mode="bypassPermissions",
46
+ allow_dangerously_skip_permissions=True,
47
+ max_turns=3,
48
+ )
49
+
50
+ messages: list[Message] = []
51
+ async with ClaudeSDKClient(options=options) as client:
52
+ await client.query(
53
+ "Call the mcp__image_test__get_test_image tool and describe what you see."
54
+ )
55
+ async for message in client.receive_response():
56
+ messages.append(message)
57
+
58
+ # Verify we got a result
59
+ result_messages = [m for m in messages if isinstance(m, ResultMessage)]
60
+ assert result_messages, f"No ResultMessage. Got: {[type(m).__name__ for m in messages]}"
61
+ assert not result_messages[0].is_error, f"Query error: {result_messages[0].errors}"
62
+
63
+ # Inspect all content blocks from assistant and user messages
64
+ all_content_blocks: list[dict[str, Any]] = []
65
+ for msg in messages:
66
+ if isinstance(msg, AssistantMessage):
67
+ for block in msg.content:
68
+ all_content_blocks.append(
69
+ {"source": "assistant", "type": block.type, "block": block}
70
+ )
71
+ elif isinstance(msg, UserMessage) and isinstance(msg.content, list):
72
+ for block in msg.content:
73
+ all_content_blocks.append({"source": "user", "type": block.type, "block": block})
74
+
75
+ # We expect at least a tool_use block (Claude calling the tool)
76
+ tool_use_blocks = [b for b in all_content_blocks if b["type"] == "tool_use"]
77
+ assert tool_use_blocks, "No tool_use blocks found - Claude didn't call the MCP tool"
78
+
79
+ # Verify the tool_use targeted our MCP tool
80
+ tool_use_block = tool_use_blocks[0]["block"]
81
+ assert isinstance(tool_use_block, ToolUseBlock)
82
+ assert tool_use_block.name == "mcp__image_test__get_test_image"
83
+
84
+ # Find tool_result blocks and verify image content via get_parsed_content()
85
+ tool_result_blocks = [b for b in all_content_blocks if b["type"] == "tool_result"]
86
+ assert tool_result_blocks, "No tool_result blocks found"
87
+
88
+ result_block = tool_result_blocks[0]["block"]
89
+ assert isinstance(result_block, ToolResultBlock)
90
+ assert isinstance(result_block.content, list), "Expected list content in tool result"
91
+
92
+ # Parse into typed Anthropic SDK content blocks
93
+ parsed = result_block.get_parsed_content()
94
+ assert isinstance(parsed, list)
95
+ assert len(parsed) >= 1
96
+
97
+ # Find the image block in parsed content
98
+ image_params = [b for b in parsed if isinstance(b, dict) and b.get("type") == "image"]
99
+ assert image_params, f"No image block in parsed content: {[type(b).__name__ for b in parsed]}"
100
+
101
+ image_param = image_params[0]
102
+ # BetaImageBlockParam is a TypedDict with source.type, source.data, source.media_type
103
+ assert image_param["type"] == "image"
104
+ assert "source" in image_param
105
+ assert image_param["source"]["type"] == "base64"
106
+ assert image_param["source"]["media_type"] == "image/png"
107
+ assert len(image_param["source"]["data"]) > 0, "Image data should not be empty"
108
+
109
+
110
+ @pytest.mark.e2e
111
+ @pytest.mark.asyncio
112
+ async def test_mcp_progress_tool_wire_format():
113
+ """Test that MCP tool progress notifications flow through the wire protocol.
114
+
115
+ Configures Claude Code with a FastMCP stdio server that has a tool
116
+ reporting progress via ctx.report_progress(), then asks Claude to call it
117
+ and verifies that ToolProgressMessage events are received and the tool
118
+ completes successfully.
119
+ """
120
+ mcp_server_path = str(Path(__file__).parent.parent / "mcp_server.py")
121
+
122
+ options = ClaudeAgentOptions(
123
+ mcp_servers={
124
+ "progress_test": {
125
+ "type": "stdio",
126
+ "command": sys.executable,
127
+ "args": [mcp_server_path],
128
+ },
129
+ },
130
+ permission_mode="bypassPermissions",
131
+ allow_dangerously_skip_permissions=True,
132
+ max_turns=3,
133
+ )
134
+
135
+ messages: list[Message] = []
136
+ async with ClaudeSDKClient(options=options) as client:
137
+ await client.query(
138
+ 'Call the mcp__progress_test__test_progress tool with message "hello from test".'
139
+ )
140
+ async for message in client.receive_response():
141
+ messages.append(message)
142
+
143
+ # Verify we got a successful result
144
+ result_messages = [m for m in messages if isinstance(m, ResultMessage)]
145
+ assert result_messages, f"No ResultMessage. Got: {[type(m).__name__ for m in messages]}"
146
+ assert not result_messages[0].is_error, f"Query error: {result_messages[0].errors}"
147
+
148
+ # Collect assistant content blocks to verify tool_use
149
+ all_content_blocks: list[dict[str, Any]] = []
150
+ for msg in messages:
151
+ if isinstance(msg, AssistantMessage):
152
+ for block in msg.content:
153
+ all_content_blocks.append(
154
+ {"source": "assistant", "type": block.type, "block": block}
155
+ )
156
+ elif isinstance(msg, UserMessage) and isinstance(msg.content, list):
157
+ for block in msg.content:
158
+ all_content_blocks.append({"source": "user", "type": block.type, "block": block})
159
+
160
+ # Verify Claude called the progress tool
161
+ tool_use_blocks = [b for b in all_content_blocks if b["type"] == "tool_use"]
162
+ assert tool_use_blocks, "No tool_use blocks found - Claude didn't call the MCP tool"
163
+
164
+ tool_use_block = tool_use_blocks[0]["block"]
165
+ assert isinstance(tool_use_block, ToolUseBlock)
166
+ assert tool_use_block.name == "mcp__progress_test__test_progress"
167
+
168
+ # Verify the tool result contains the expected completion message
169
+ tool_result_blocks = [b for b in all_content_blocks if b["type"] == "tool_result"]
170
+ assert tool_result_blocks, "No tool_result blocks found"
171
+
172
+ result_block = tool_result_blocks[0]["block"]
173
+ assert isinstance(result_block, ToolResultBlock)
174
+ parsed = result_block.get_parsed_content()
175
+ # The result should contain the completion message as text
176
+ if isinstance(parsed, str):
177
+ assert "hello from test" in parsed
178
+ elif isinstance(parsed, list):
179
+ text_parts = [b.text for b in parsed if isinstance(b, TextBlock)] # type: ignore[union-attr]
180
+ combined = " ".join(text_parts)
181
+ assert "hello from test" in combined, f"Expected message in result, got: {combined}"
182
+
183
+ progress_messages = [m for m in messages if isinstance(m, ToolProgressMessage)]
184
+ # Right now no progress messages emitted from the subprocess, seems to be a bug.
185
+ # rever assertion in case it changes
186
+ assert not progress_messages
187
+ for pm in progress_messages:
188
+ assert pm.tool_name, "Progress message should have a tool_name"
189
+ assert pm.elapsed_time_seconds >= 0, "elapsed_time_seconds should be non-negative"
190
+
191
+
192
+ if __name__ == "__main__":
193
+ pytest.main([__file__, "-vv", "-m", "e2e"])
@@ -4,7 +4,8 @@ from __future__ import annotations
4
4
 
5
5
  from pathlib import Path
6
6
 
7
- from fastmcp import FastMCP
7
+ import anyio
8
+ from fastmcp import Context, FastMCP
8
9
  from fastmcp.utilities.types import Image
9
10
 
10
11
 
@@ -18,5 +19,17 @@ async def get_test_image() -> Image:
18
19
  return Image(data=png_path.read_bytes(), format="png")
19
20
 
20
21
 
22
+ @mcp.tool
23
+ async def test_progress(ctx: Context, message: str) -> str:
24
+ """Test progress reporting with the given message."""
25
+ await ctx.report_progress(0, 100, "first step")
26
+ await anyio.sleep(0.5)
27
+ await ctx.report_progress(50, 100, "second step")
28
+ await anyio.sleep(0.5)
29
+ await ctx.report_progress(99, 100, "third step")
30
+ await anyio.sleep(0.5)
31
+ return f"Progress test completed with message: {message}"
32
+
33
+
21
34
  if __name__ == "__main__":
22
35
  mcp.run(show_banner=False, log_level="error")
@@ -1,121 +0,0 @@
1
- """End-to-end test for MCP tool returning image content over the wire.
2
-
3
- Tests whether image content blocks from an external MCP server
4
- (FastMCP stdio) are correctly received through the Claude Code CLI.
5
- """
6
-
7
- from __future__ import annotations
8
-
9
- from pathlib import Path
10
- import sys
11
- from typing import TYPE_CHECKING, Any
12
-
13
- import pytest
14
-
15
- from clawd_code_sdk import (
16
- AssistantMessage,
17
- ClaudeAgentOptions,
18
- ClaudeSDKClient,
19
- ResultMessage,
20
- UserMessage,
21
- )
22
- from clawd_code_sdk.models.content_blocks import ToolResultBlock, ToolUseBlock
23
-
24
-
25
- if TYPE_CHECKING:
26
- from clawd_code_sdk.models.messages import Message
27
-
28
-
29
- @pytest.mark.e2e
30
- @pytest.mark.asyncio
31
- async def test_mcp_image_tool_wire_format():
32
- """Test that image content from an MCP tool flows through the wire protocol.
33
-
34
- Configures Claude Code with a FastMCP stdio server that has a single tool
35
- returning a PNG image, then asks Claude to call it and inspects what
36
- content blocks come back.
37
- """
38
- mcp_server_path = str(Path(__file__).parent.parent / "tests" / "mcp_server.py")
39
-
40
- options = ClaudeAgentOptions(
41
- mcp_servers={
42
- "image_test": {
43
- "type": "stdio",
44
- "command": sys.executable,
45
- "args": [mcp_server_path],
46
- },
47
- },
48
- allowed_tools=["mcp__image_test__get_test_image"],
49
- permission_mode="bypassPermissions",
50
- allow_dangerously_skip_permissions=True,
51
- max_turns=3,
52
- )
53
-
54
- messages: list[Message] = []
55
- async with ClaudeSDKClient(options=options) as client:
56
- await client.query(
57
- "Call the mcp__image_test__get_test_image tool and describe what you see."
58
- )
59
- async for message in client.receive_response():
60
- messages.append(message)
61
-
62
- # Verify we got a result
63
- result_messages = [m for m in messages if isinstance(m, ResultMessage)]
64
- assert result_messages, f"No ResultMessage. Got: {[type(m).__name__ for m in messages]}"
65
- assert not result_messages[0].is_error, f"Query error: {result_messages[0].errors}"
66
-
67
- # Inspect all content blocks from assistant and user messages
68
- all_content_blocks: list[dict[str, Any]] = []
69
- for msg in messages:
70
- if isinstance(msg, AssistantMessage):
71
- for block in msg.content:
72
- all_content_blocks.append(
73
- {
74
- "source": "assistant",
75
- "type": block.type,
76
- "block": block,
77
- }
78
- )
79
- elif isinstance(msg, UserMessage) and isinstance(msg.content, list):
80
- for block in msg.content:
81
- all_content_blocks.append(
82
- {
83
- "source": "user",
84
- "type": block.type,
85
- "block": block,
86
- }
87
- )
88
-
89
- # We expect at least a tool_use block (Claude calling the tool)
90
- tool_use_blocks = [b for b in all_content_blocks if b["type"] == "tool_use"]
91
- assert tool_use_blocks, "No tool_use blocks found - Claude didn't call the MCP tool"
92
-
93
- # Verify the tool_use targeted our MCP tool
94
- tool_use_block = tool_use_blocks[0]["block"]
95
- assert isinstance(tool_use_block, ToolUseBlock)
96
- assert tool_use_block.name == "mcp__image_test__get_test_image"
97
-
98
- # Find tool_result blocks and verify image content via get_parsed_content()
99
- tool_result_blocks = [b for b in all_content_blocks if b["type"] == "tool_result"]
100
- assert tool_result_blocks, "No tool_result blocks found"
101
-
102
- result_block = tool_result_blocks[0]["block"]
103
- assert isinstance(result_block, ToolResultBlock)
104
- assert isinstance(result_block.content, list), "Expected list content in tool result"
105
-
106
- # Parse into typed Anthropic SDK content blocks
107
- parsed = result_block.get_parsed_content()
108
- assert isinstance(parsed, list)
109
- assert len(parsed) >= 1
110
-
111
- # Find the image block in parsed content
112
- image_params = [b for b in parsed if isinstance(b, dict) and b.get("type") == "image"]
113
- assert image_params, f"No image block in parsed content: {[type(b).__name__ for b in parsed]}"
114
-
115
- image_param = image_params[0]
116
- # BetaImageBlockParam is a TypedDict with source.type, source.data, source.media_type
117
- assert image_param["type"] == "image"
118
- assert "source" in image_param
119
- assert image_param["source"]["type"] == "base64"
120
- assert image_param["source"]["media_type"] == "image/png"
121
- assert len(image_param["source"]["data"]) > 0, "Image data should not be empty"
File without changes
File without changes