claude-agent-sdk 0.7.3 → 0.8.0

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d17d03fa867e2779de155d6fba8a86fbc9edbc5662157ca3e21606016c81fb17
4
- data.tar.gz: 949787994cd58eb5be72b8834a519a704697c61ecbfff9ca1d6f4e1fe921b18f
3
+ metadata.gz: 2c723c3e660d135ab87311e27be6a80b8d13127f9e42789a66b1d30ec0395278
4
+ data.tar.gz: 0652f808dd792a6bef8db32afc567567fe9f016abdf095f1d13ae0ac07af0d03
5
5
  SHA512:
6
- metadata.gz: a99fdb7b2dcab7e0286763abfb1f6d0277423a7ef2198a040ef8c76a8959907009ebc21f6523c35ecff1409a66fd8c636648d1f594aaa481119ed3598eb1b06e
7
- data.tar.gz: 8e3e7e26487dcfe993534d3d1b7f695ba5009ad18e9d173c9e1e0f1353ec54565905f3e74e81a39eb5789aa2df008536a2a5a191095e7d8604f36abd27bb6583
6
+ metadata.gz: 399925ff55a9a23ab54cbe97adb18fea804be6c92cf21aa3ca3fdcd4cbe54693dfc8b77b85f15ed2eaf1517acaecd09935701d0248fb99a23be6325d151c99aa
7
+ data.tar.gz: dd14802c444f4d6609cbb8215e79c3f5d8c4d9453e20d0d3af40cd853d653625c93e117082118beee2c5fb0ad29447a170687a44d56f88be6785affb8d118060
data/CHANGELOG.md CHANGED
@@ -5,6 +5,46 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [0.8.0] - 2026-03-05
9
+
10
+ Port of Python SDK v0.1.46 features.
11
+
12
+ ### Added
13
+
14
+ #### Task Message Types
15
+ - `TaskStartedMessage`, `TaskProgressMessage`, `TaskNotificationMessage` — typed `SystemMessage` subclasses for background task lifecycle events
16
+ - `TASK_NOTIFICATION_STATUSES` constant (`completed`, `failed`, `stopped`)
17
+ - `MessageParser` dispatches on `subtype` within `system` messages, falling back to generic `SystemMessage` for unknown subtypes
18
+
19
+ #### MCP Server Control
20
+ - `reconnect_mcp_server(server_name)` on `Query` and `Client` — retry failed MCP server connections
21
+ - `toggle_mcp_server(server_name, enabled)` on `Query` and `Client` — enable/disable MCP servers live
22
+ - `stop_task(task_id)` on `Query` and `Client` — stop a running background task
23
+
24
+ #### Subagent Context on Hook Inputs
25
+ - `agent_id` and `agent_type` attributes on `PreToolUseHookInput`, `PostToolUseHookInput`, `PostToolUseFailureHookInput`, `PermissionRequestHookInput`
26
+ - Populated when hooks fire inside subagents, allowing attribution of tool calls to specific agents
27
+
28
+ #### Result Message
29
+ - `stop_reason` attribute on `ResultMessage` (e.g., `'end_turn'`, `'max_tokens'`, `'stop_sequence'`)
30
+
31
+ #### Typed MCP Status Response
32
+ - `McpServerInfo`, `McpToolAnnotations`, `McpToolInfo`, `McpServerStatus`, `McpStatusResponse` types
33
+ - `.parse` class methods for hydrating from raw CLI response hashes
34
+ - `MCP_SERVER_CONNECTION_STATUSES` constant (`connected`, `failed`, `needs-auth`, `pending`, `disabled`)
35
+
36
+ #### Session Browsing
37
+ - `ClaudeAgentSDK.list_sessions(directory:, limit:, include_worktrees:)` — list sessions from `~/.claude/projects/` JSONL files
38
+ - `ClaudeAgentSDK.get_session_messages(session_id:, directory:, limit:, offset:)` — reconstruct conversation chain from session transcript
39
+ - `SDKSessionInfo` type with `session_id`, `summary`, `last_modified`, `file_size`, `custom_title`, `first_prompt`, `git_branch`, `cwd`
40
+ - `SessionMessage` type with `type`, `uuid`, `session_id`, `message`
41
+ - Pure filesystem operations — no CLI subprocess required
42
+ - Git worktree-aware session scanning
43
+ - `parentUuid` chain walking with cycle detection for robust conversation reconstruction
44
+
45
+ ### Fixed
46
+ - **`McpToolAnnotations.parse` losing `false` values:** `readOnly: false` was evaluated as `false || nil → nil` due to `||` short-circuiting. Now uses `.key?` to check presence before falling back to snake_case keys.
47
+
8
48
  ## [0.7.3] - 2026-02-26
9
49
 
10
50
  ### Fixed
data/README.md CHANGED
@@ -6,6 +6,119 @@
6
6
 
7
7
  [![Gem Version](https://badge.fury.io/rb/claude-agent-sdk.svg?icon=si%3Arubygems)](https://badge.fury.io/rb/claude-agent-sdk)
8
8
 
9
+ ### Feature Parity with Python SDK (v0.1.46)
10
+
11
+ | Feature | Python | Ruby |
12
+ |---------|:------:|:----:|
13
+ | One-shot `query()` | `query()` | `query()` |
14
+ | Bidirectional `Client` | `ClaudeSDKClient` | `Client` |
15
+ | Streaming input | `AsyncIterable` | `Enumerator` |
16
+ | Custom tools (SDK MCP servers) | `@tool` decorator | `create_tool` block |
17
+ | MCP resources & prompts | ✅ | ✅ |
18
+ | Hooks (all 10 events) | ✅ | ✅ |
19
+ | Permission callbacks (`can_use_tool`) | ✅ | ✅ |
20
+ | Structured output | ✅ | ✅ |
21
+ | Thinking config (adaptive/enabled/disabled) | ✅ | ✅ |
22
+ | Effort levels | ✅ | ✅ |
23
+ | Programmatic subagents | ✅ | ✅ |
24
+ | Sandbox settings | ✅ | ✅ |
25
+ | Beta features (1M context) | ✅ | ✅ |
26
+ | File checkpointing & rewind | ✅ | ✅ |
27
+ | Session browsing (`list_sessions`, `get_session_messages`) | ✅ | ✅ |
28
+ | Task message types (started/progress/notification) | ✅ | ✅ |
29
+ | MCP server control (reconnect/toggle/stop) | ✅ | ✅ |
30
+ | Subagent context on hook inputs | ✅ | ✅ |
31
+ | Typed MCP status response | ✅ | ✅ |
32
+ | `stop_reason` on `ResultMessage` | ✅ | ✅ |
33
+ | Fallback model | ✅ | ✅ |
34
+ | Plugin support | ✅ | ✅ |
35
+ | Rails integration (configure block, ActionCable) | — | ✅ |
36
+ | Bundled CLI binary | ✅ | — |
37
+
38
+ <details>
39
+ <summary><strong>Usage & Implementation Differences</strong></summary>
40
+
41
+ #### Async model
42
+
43
+ Python uses `async`/`await` with `anyio` (works with both asyncio and Trio). Ruby uses the [`async`](https://github.com/socketry/async) gem with fibers — no `await` keyword needed, blocking calls yield automatically.
44
+
45
+ ```python
46
+ # Python
47
+ async with ClaudeSDKClient(options) as client:
48
+ await client.query("Hello")
49
+ async for msg in client.receive_messages():
50
+ print(msg)
51
+ ```
52
+
53
+ ```ruby
54
+ # Ruby
55
+ Async do
56
+ client = ClaudeAgentSDK::Client.new(options: options)
57
+ client.connect
58
+ client.query("Hello")
59
+ client.receive_messages { |msg| puts msg }
60
+ client.disconnect
61
+ end.wait
62
+ ```
63
+
64
+ #### Custom tools
65
+
66
+ Python uses a `@tool` decorator. Ruby uses `create_tool` with a block.
67
+
68
+ ```python
69
+ # Python
70
+ @tool(name="add", description="Add numbers", input_schema={...})
71
+ def add(a: int, b: int) -> str:
72
+ return str(a + b)
73
+ ```
74
+
75
+ ```ruby
76
+ # Ruby
77
+ add = ClaudeAgentSDK.create_tool("add", "Add numbers", { a: :number, b: :number }) do |args|
78
+ { content: [{ type: "text", text: (args[:a] + args[:b]).to_s }] }
79
+ end
80
+ ```
81
+
82
+ #### Streaming input
83
+
84
+ Python uses `AsyncIterable`. Ruby uses `Enumerator` or any `#each`-able.
85
+
86
+ ```python
87
+ # Python
88
+ async def messages():
89
+ yield {"type": "user", "message": {"role": "user", "content": "Hello"}}
90
+
91
+ async for msg in query(prompt=messages(), options=options):
92
+ print(msg)
93
+ ```
94
+
95
+ ```ruby
96
+ # Ruby
97
+ messages = ClaudeAgentSDK::Streaming.from_array(["Hello", "Follow up"])
98
+ ClaudeAgentSDK.query(prompt: messages) { |msg| puts msg }
99
+ ```
100
+
101
+ #### Types
102
+
103
+ Python uses `dataclass` with type annotations and `TypedDict`. Ruby uses plain classes with `attr_accessor` and keyword args — no runtime type checking, but the same structure.
104
+
105
+ #### Configuration defaults
106
+
107
+ Python passes options directly. Ruby adds `ClaudeAgentSDK.configure` for global defaults that merge with per-call options — handy for Rails initializers.
108
+
109
+ ```ruby
110
+ # Ruby-only: global defaults
111
+ ClaudeAgentSDK.configure do |config|
112
+ config.default_options = { model: "sonnet", permission_mode: "bypassPermissions" }
113
+ end
114
+ ```
115
+
116
+ #### Subprocess transport
117
+
118
+ Both SDKs spawn `claude` CLI as a subprocess with stream-JSON over stdin/stdout. Python uses `anyio.open_process`; Ruby uses `Open3.popen3` with a background `Thread` for stderr. The wire protocol is identical.
119
+
120
+ </details>
121
+
9
122
  ## Table of Contents
10
123
 
11
124
  - [Installation](#installation)
@@ -23,6 +136,7 @@
23
136
  - [Tools Configuration](#tools-configuration)
24
137
  - [Sandbox Settings](#sandbox-settings)
25
138
  - [File Checkpointing & Rewind](#file-checkpointing--rewind)
139
+ - [Session Browsing](#session-browsing)
26
140
  - [Rails Integration](#rails-integration)
27
141
  - [Types](#types)
28
142
  - [Error Handling](#error-handling)
@@ -39,7 +153,7 @@ Add this line to your application's Gemfile:
39
153
  gem 'claude-agent-sdk', github: 'ya-luotao/claude-agent-sdk-ruby'
40
154
 
41
155
  # Or use a stable version from RubyGems
42
- gem 'claude-agent-sdk', '~> 0.7.3'
156
+ gem 'claude-agent-sdk', '~> 0.8.0'
43
157
  ```
44
158
 
45
159
  And then execute:
@@ -225,13 +339,18 @@ Async do
225
339
  puts "MCP status: #{status}"
226
340
 
227
341
  # Get server initialization info
228
- info = client.server_info
229
- puts "Available commands: #{info}"
230
-
231
- # (Parity alias) Get server initialization info
232
342
  info = client.get_server_info
233
343
  puts "Available commands: #{info}"
234
344
 
345
+ # Reconnect a failed MCP server
346
+ client.reconnect_mcp_server('my-server')
347
+
348
+ # Enable or disable an MCP server
349
+ client.toggle_mcp_server('my-server', false)
350
+
351
+ # Stop a running background task
352
+ client.stop_task('task_abc123')
353
+
235
354
  client.disconnect
236
355
  end.wait
237
356
  ```
@@ -774,6 +893,47 @@ end.wait
774
893
 
775
894
  > **Note:** The `uuid` field on `UserMessage` is populated by the CLI and represents checkpoint identifiers. Rewinding to a UUID restores file state to what it was at that point in the conversation.
776
895
 
896
+ ## Session Browsing
897
+
898
+ Browse and inspect previous Claude Code sessions directly from Ruby — no CLI subprocess required.
899
+
900
+ ### Listing Sessions
901
+
902
+ ```ruby
903
+ # List all sessions (sorted by most recent first)
904
+ sessions = ClaudeAgentSDK.list_sessions
905
+ sessions.each do |session|
906
+ puts "#{session.session_id}: #{session.summary} (#{session.git_branch})"
907
+ end
908
+
909
+ # List sessions for a specific directory
910
+ sessions = ClaudeAgentSDK.list_sessions(directory: '/path/to/project', limit: 10)
911
+
912
+ # Include git worktree sessions
913
+ sessions = ClaudeAgentSDK.list_sessions(directory: '.', include_worktrees: true)
914
+ ```
915
+
916
+ Each `SDKSessionInfo` includes:
917
+ - `session_id`, `summary`, `last_modified`, `file_size`
918
+ - `custom_title`, `first_prompt`, `git_branch`, `cwd`
919
+
920
+ ### Reading Session Messages
921
+
922
+ ```ruby
923
+ # Get the full conversation from a session
924
+ messages = ClaudeAgentSDK.get_session_messages(session_id: 'abc-123-...')
925
+ messages.each do |msg|
926
+ puts "[#{msg.type}] #{msg.message}"
927
+ end
928
+
929
+ # Paginate through messages
930
+ page = ClaudeAgentSDK.get_session_messages(session_id: 'abc-123-...', offset: 10, limit: 20)
931
+ ```
932
+
933
+ Each `SessionMessage` includes `type` (`"user"` or `"assistant"`), `uuid`, `session_id`, and `message` (raw API dict).
934
+
935
+ > **Note:** Session browsing reads `~/.claude/projects/` JSONL files directly. It respects the `CLAUDE_CONFIG_DIR` environment variable and automatically detects git worktrees.
936
+
777
937
  ## Rails Integration
778
938
 
779
939
  The SDK integrates well with Rails applications. Here are common patterns:
@@ -966,13 +1126,26 @@ end
966
1126
 
967
1127
  #### SystemMessage
968
1128
 
969
- System message with metadata.
1129
+ System message with metadata. Task lifecycle events are typed subclasses.
970
1130
 
971
1131
  ```ruby
972
1132
  class SystemMessage
973
- attr_accessor :subtype, # String ('init', etc.)
1133
+ attr_accessor :subtype, # String ('init', 'task_started', 'task_progress', 'task_notification', etc.)
974
1134
  :data # Hash
975
1135
  end
1136
+
1137
+ # Typed subclasses (all inherit from SystemMessage, so is_a?(SystemMessage) still works)
1138
+ class TaskStartedMessage < SystemMessage
1139
+ attr_accessor :task_id, :description, :uuid, :session_id, :tool_use_id, :task_type
1140
+ end
1141
+
1142
+ class TaskProgressMessage < SystemMessage
1143
+ attr_accessor :task_id, :description, :usage, :uuid, :session_id, :tool_use_id, :last_tool_name
1144
+ end
1145
+
1146
+ class TaskNotificationMessage < SystemMessage
1147
+ attr_accessor :task_id, :status, :output_file, :summary, :uuid, :session_id, :tool_use_id, :usage
1148
+ end
976
1149
  ```
977
1150
 
978
1151
  #### ResultMessage
@@ -987,6 +1160,7 @@ class ResultMessage
987
1160
  :is_error, # Boolean
988
1161
  :num_turns, # Integer
989
1162
  :session_id, # String
1163
+ :stop_reason, # String | nil ('end_turn', 'max_tokens', 'stop_sequence')
990
1164
  :total_cost_usd, # Float | nil
991
1165
  :usage, # Hash | nil
992
1166
  :result, # String | nil (final text result)
@@ -1111,6 +1285,13 @@ end
1111
1285
  | `McpSSEServerConfig` | MCP server config for SSE transport |
1112
1286
  | `McpHttpServerConfig` | MCP server config for HTTP transport |
1113
1287
  | `SdkPluginConfig` | SDK plugin configuration |
1288
+ | `McpServerStatus` | Status of a single MCP server connection (with `.parse`) |
1289
+ | `McpStatusResponse` | Response from `get_mcp_status` containing all server statuses (with `.parse`) |
1290
+ | `McpServerInfo` | MCP server name and version |
1291
+ | `McpToolInfo` | MCP tool name, description, and annotations |
1292
+ | `McpToolAnnotations` | MCP tool annotation hints (`read_only`, `destructive`, `open_world`) |
1293
+ | `SDKSessionInfo` | Session metadata from `list_sessions` |
1294
+ | `SessionMessage` | Single message from `get_session_messages` |
1114
1295
  | `SandboxSettings` | Sandbox settings for isolated command execution |
1115
1296
  | `SandboxNetworkConfig` | Network configuration for sandbox |
1116
1297
  | `SandboxIgnoreViolations` | Configure which sandbox violations to ignore |
@@ -1126,6 +1307,8 @@ end
1126
1307
  | `SETTING_SOURCES` | Available setting sources |
1127
1308
  | `HOOK_EVENTS` | Available hook events |
1128
1309
  | `ASSISTANT_MESSAGE_ERRORS` | Possible error types in AssistantMessage |
1310
+ | `TASK_NOTIFICATION_STATUSES` | Task lifecycle notification statuses (`completed`, `failed`, `stopped`) |
1311
+ | `MCP_SERVER_CONNECTION_STATUSES` | MCP server connection states (`connected`, `failed`, `needs-auth`, `pending`, `disabled`) |
1129
1312
 
1130
1313
  ## Error Handling
1131
1314
 
@@ -66,10 +66,32 @@ module ClaudeAgentSDK
66
66
  end
67
67
 
68
68
  def self.parse_system_message(data)
69
- SystemMessage.new(
70
- subtype: data[:subtype],
71
- data: data
72
- )
69
+ case data[:subtype]
70
+ when 'task_started'
71
+ TaskStartedMessage.new(
72
+ subtype: data[:subtype], data: data,
73
+ task_id: data[:task_id], description: data[:description],
74
+ uuid: data[:uuid], session_id: data[:session_id],
75
+ tool_use_id: data[:tool_use_id], task_type: data[:task_type]
76
+ )
77
+ when 'task_progress'
78
+ TaskProgressMessage.new(
79
+ subtype: data[:subtype], data: data,
80
+ task_id: data[:task_id], description: data[:description],
81
+ usage: data[:usage], uuid: data[:uuid], session_id: data[:session_id],
82
+ tool_use_id: data[:tool_use_id], last_tool_name: data[:last_tool_name]
83
+ )
84
+ when 'task_notification'
85
+ TaskNotificationMessage.new(
86
+ subtype: data[:subtype], data: data,
87
+ task_id: data[:task_id], status: data[:status],
88
+ output_file: data[:output_file], summary: data[:summary],
89
+ uuid: data[:uuid], session_id: data[:session_id],
90
+ tool_use_id: data[:tool_use_id], usage: data[:usage]
91
+ )
92
+ else
93
+ SystemMessage.new(subtype: data[:subtype], data: data)
94
+ end
73
95
  end
74
96
 
75
97
  def self.parse_result_message(data)
@@ -80,10 +102,11 @@ module ClaudeAgentSDK
80
102
  is_error: data[:is_error],
81
103
  num_turns: data[:num_turns],
82
104
  session_id: data[:session_id],
105
+ stop_reason: data[:stop_reason],
83
106
  total_cost_usd: data[:total_cost_usd],
84
107
  usage: data[:usage],
85
108
  result: data[:result],
86
- structured_output: data[:structured_output] # Structured output when output_format is specified
109
+ structured_output: data[:structured_output]
87
110
  )
88
111
  end
89
112
 
@@ -316,13 +316,19 @@ module ClaudeAgentSDK
316
316
  permission_mode: fetch.call(:permission_mode)
317
317
  }
318
318
 
319
+ # Subagent context fields shared by tool-lifecycle hooks
320
+ subagent_args = {
321
+ agent_id: fetch.call(:agent_id),
322
+ agent_type: fetch.call(:agent_type)
323
+ }
324
+
319
325
  case event_name
320
326
  when 'PreToolUse'
321
327
  PreToolUseHookInput.new(
322
328
  tool_name: fetch.call(:tool_name),
323
329
  tool_input: fetch.call(:tool_input),
324
330
  tool_use_id: fetch.call(:tool_use_id),
325
- **base_args
331
+ **subagent_args, **base_args
326
332
  )
327
333
  when 'PostToolUse'
328
334
  PostToolUseHookInput.new(
@@ -330,7 +336,7 @@ module ClaudeAgentSDK
330
336
  tool_input: fetch.call(:tool_input),
331
337
  tool_response: fetch.call(:tool_response),
332
338
  tool_use_id: fetch.call(:tool_use_id),
333
- **base_args
339
+ **subagent_args, **base_args
334
340
  )
335
341
  when 'PostToolUseFailure'
336
342
  PostToolUseFailureHookInput.new(
@@ -339,7 +345,7 @@ module ClaudeAgentSDK
339
345
  tool_use_id: fetch.call(:tool_use_id),
340
346
  error: fetch.call(:error),
341
347
  is_interrupt: fetch.call(:is_interrupt),
342
- **base_args
348
+ **subagent_args, **base_args
343
349
  )
344
350
  when 'UserPromptSubmit'
345
351
  UserPromptSubmitHookInput.new(
@@ -377,7 +383,7 @@ module ClaudeAgentSDK
377
383
  tool_name: fetch.call(:tool_name),
378
384
  tool_input: fetch.call(:tool_input),
379
385
  permission_suggestions: fetch.call(:permission_suggestions),
380
- **base_args
386
+ **subagent_args, **base_args
381
387
  )
382
388
  when 'PreCompact'
383
389
  PreCompactHookInput.new(
@@ -662,6 +668,35 @@ module ClaudeAgentSDK
662
668
  })
663
669
  end
664
670
 
671
+ # Reconnect a failed MCP server
672
+ # @param server_name [String] Name of the MCP server to reconnect
673
+ def reconnect_mcp_server(server_name)
674
+ send_control_request({
675
+ subtype: 'mcp_reconnect',
676
+ serverName: server_name
677
+ })
678
+ end
679
+
680
+ # Enable or disable an MCP server
681
+ # @param server_name [String] Name of the MCP server
682
+ # @param enabled [Boolean] Whether to enable or disable
683
+ def toggle_mcp_server(server_name, enabled)
684
+ send_control_request({
685
+ subtype: 'mcp_toggle',
686
+ serverName: server_name,
687
+ enabled: enabled
688
+ })
689
+ end
690
+
691
+ # Stop a running background task
692
+ # @param task_id [String] The ID of the task to stop
693
+ def stop_task(task_id)
694
+ send_control_request({
695
+ subtype: 'stop_task',
696
+ task_id: task_id
697
+ })
698
+ end
699
+
665
700
  # Rewind files to a previous checkpoint (v0.1.15+)
666
701
  # Restores file state to what it was at the given user message
667
702
  # Requires enable_file_checkpointing to be true in options
@@ -0,0 +1,499 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'English'
4
+ require 'json'
5
+ require 'pathname'
6
+ require 'shellwords'
7
+
8
+ module ClaudeAgentSDK
9
+ # Session info returned by list_sessions
10
+ class SDKSessionInfo
11
+ attr_accessor :session_id, :summary, :last_modified, :file_size,
12
+ :custom_title, :first_prompt, :git_branch, :cwd
13
+
14
+ def initialize(session_id:, summary:, last_modified:, file_size:,
15
+ custom_title: nil, first_prompt: nil, git_branch: nil, cwd: nil)
16
+ @session_id = session_id
17
+ @summary = summary
18
+ @last_modified = last_modified
19
+ @file_size = file_size
20
+ @custom_title = custom_title
21
+ @first_prompt = first_prompt
22
+ @git_branch = git_branch
23
+ @cwd = cwd
24
+ end
25
+ end
26
+
27
+ # A single message from a session transcript
28
+ class SessionMessage
29
+ attr_accessor :type, :uuid, :session_id, :message, :parent_tool_use_id
30
+
31
+ def initialize(type:, uuid:, session_id:, message:, parent_tool_use_id: nil)
32
+ @type = type
33
+ @uuid = uuid
34
+ @session_id = session_id
35
+ @message = message
36
+ @parent_tool_use_id = parent_tool_use_id
37
+ end
38
+ end
39
+
40
+ # Session browsing functions
41
+ module Sessions # rubocop:disable Metrics/ModuleLength
42
+ LITE_READ_BUF_SIZE = 65_536
43
+ MAX_SANITIZED_LENGTH = 200
44
+
45
+ UUID_RE = /\A[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\z/i
46
+
47
+ SKIP_FIRST_PROMPT_PATTERN = %r{\A(?:<local-command-stdout>|<session-start-hook>|<tick>|<goal>|
48
+ \[Request\ interrupted\ by\ user[^\]]*\]|
49
+ \s*<ide_opened_file>[\s\S]*</ide_opened_file>\s*\z|
50
+ \s*<ide_selection>[\s\S]*</ide_selection>\s*\z)}x
51
+
52
+ COMMAND_NAME_RE = %r{<command-name>(.*?)</command-name>}
53
+
54
+ SANITIZE_RE = /[^a-zA-Z0-9]/
55
+
56
+ module_function
57
+
58
+ # Match TypeScript's simpleHash: signed 32-bit integer, base-36 output
59
+ def simple_hash(str)
60
+ h = 0
61
+ str.each_char do |ch|
62
+ char_code = ch.ord
63
+ h = ((h << 5) - h + char_code) & 0xFFFFFFFF
64
+ h -= 0x100000000 if h >= 0x80000000
65
+ end
66
+ h = h.abs
67
+
68
+ return '0' if h.zero?
69
+
70
+ digits = '0123456789abcdefghijklmnopqrstuvwxyz'
71
+ out = []
72
+ n = h
73
+ while n.positive?
74
+ out.unshift(digits[n % 36])
75
+ n /= 36
76
+ end
77
+ out.join
78
+ end
79
+
80
+ # Sanitize a filesystem path to a project directory name
81
+ def sanitize_path(name)
82
+ sanitized = name.gsub(SANITIZE_RE, '-')
83
+ return sanitized if sanitized.length <= MAX_SANITIZED_LENGTH
84
+
85
+ "#{sanitized[0, MAX_SANITIZED_LENGTH]}-#{simple_hash(name)}"
86
+ end
87
+
88
+ # Get the Claude config directory
89
+ def config_dir
90
+ ENV.fetch('CLAUDE_CONFIG_DIR', File.expand_path('~/.claude'))
91
+ end
92
+
93
+ # Find the project directory for a given path
94
+ def find_project_dir(path)
95
+ projects_dir = File.join(config_dir, 'projects')
96
+ return nil unless File.directory?(projects_dir)
97
+
98
+ sanitized = sanitize_path(path)
99
+ exact_path = File.join(projects_dir, sanitized)
100
+ return exact_path if File.directory?(exact_path)
101
+
102
+ # For long paths, scan for prefix match
103
+ if sanitized.length > MAX_SANITIZED_LENGTH
104
+ prefix = sanitized[0, MAX_SANITIZED_LENGTH + 1] # includes the trailing '-'
105
+ Dir.children(projects_dir).each do |child|
106
+ candidate = File.join(projects_dir, child)
107
+ return candidate if File.directory?(candidate) && child.start_with?(prefix)
108
+ end
109
+ end
110
+
111
+ nil
112
+ end
113
+
114
+ # Extract a JSON string field value from raw text without full JSON parse
115
+ def extract_json_string_field(text, key, last: false)
116
+ search_patterns = ["\"#{key}\":\"", "\"#{key}\": \""]
117
+ result = nil
118
+
119
+ search_patterns.each do |pattern|
120
+ pos = 0
121
+ loop do
122
+ idx = text.index(pattern, pos)
123
+ break unless idx
124
+
125
+ value_start = idx + pattern.length
126
+ value = extract_json_string_value(text, value_start)
127
+ if value
128
+ result = unescape_json_string(value)
129
+ return result unless last
130
+ end
131
+ pos = value_start
132
+ end
133
+ end
134
+
135
+ result
136
+ end
137
+
138
+ # Extract string value starting at pos (handles escapes)
139
+ def extract_json_string_value(text, start)
140
+ pos = start
141
+ while pos < text.length
142
+ ch = text[pos]
143
+ if ch == '\\'
144
+ pos += 2
145
+ elsif ch == '"'
146
+ return text[start...pos]
147
+ else
148
+ pos += 1
149
+ end
150
+ end
151
+ nil
152
+ end
153
+
154
+ # Unescape a JSON string value
155
+ def unescape_json_string(str)
156
+ JSON.parse("\"#{str}\"")
157
+ rescue JSON::ParserError
158
+ str
159
+ end
160
+
161
+ # Extract the first meaningful user prompt from the head of a JSONL file
162
+ def extract_first_prompt_from_head(head)
163
+ command_fallback = nil
164
+
165
+ head.each_line do |line|
166
+ next unless line.include?('"type":"user"') || line.include?('"type": "user"')
167
+ next if line.include?('"tool_result"')
168
+ next if line.include?('"isMeta":true') || line.include?('"isMeta": true')
169
+ next if line.include?('"isCompactSummary":true') || line.include?('"isCompactSummary": true')
170
+
171
+ entry = JSON.parse(line, symbolize_names: false)
172
+ content = entry.dig('message', 'content')
173
+ next unless content
174
+
175
+ texts = if content.is_a?(String)
176
+ [content]
177
+ elsif content.is_a?(Array)
178
+ content.filter_map { |block| block['text'] if block.is_a?(Hash) && block['type'] == 'text' }
179
+ else
180
+ next
181
+ end
182
+
183
+ texts.each do |text|
184
+ text = text.gsub(/\n+/, ' ').strip
185
+ next if text.empty?
186
+
187
+ if (m = text.match(COMMAND_NAME_RE))
188
+ command_fallback ||= m[1]
189
+ next
190
+ end
191
+
192
+ next if text.match?(SKIP_FIRST_PROMPT_PATTERN)
193
+
194
+ return text.length > 200 ? "#{text[0, 200]}…" : text
195
+ end
196
+ rescue JSON::ParserError
197
+ next
198
+ end
199
+
200
+ command_fallback || ''
201
+ end
202
+
203
+ # Read a single session file with lite (head/tail) strategy
204
+ def read_session_lite(file_path, project_path)
205
+ stat = File.stat(file_path)
206
+ return nil if stat.size.zero? # rubocop:disable Style/ZeroLengthPredicate
207
+
208
+ head, tail = read_head_tail(file_path, stat.size)
209
+
210
+ # Check first line for sidechain
211
+ first_line = head.lines.first || ''
212
+ return nil if first_line.include?('"isSidechain":true') || first_line.include?('"isSidechain": true')
213
+
214
+ build_session_info(file_path, head, tail, stat, project_path)
215
+ rescue StandardError
216
+ nil
217
+ end
218
+
219
+ def read_head_tail(file_path, size)
220
+ head = tail = nil
221
+ File.open(file_path, 'rb') do |f|
222
+ head = (f.read(LITE_READ_BUF_SIZE) || '').force_encoding('UTF-8')
223
+ tail = if size > LITE_READ_BUF_SIZE
224
+ f.seek([0, size - LITE_READ_BUF_SIZE].max)
225
+ (f.read(LITE_READ_BUF_SIZE) || '').force_encoding('UTF-8')
226
+ else
227
+ head
228
+ end
229
+ end
230
+ [head, tail]
231
+ end
232
+
233
+ def build_session_info(file_path, head, tail, stat, project_path)
234
+ custom_title = extract_json_string_field(tail, 'customTitle', last: true)
235
+ first_prompt = extract_first_prompt_from_head(head)
236
+ summary = custom_title || extract_json_string_field(tail, 'summary', last: true) || first_prompt
237
+ return nil if summary.nil? || summary.strip.empty?
238
+
239
+ SDKSessionInfo.new(
240
+ session_id: File.basename(file_path, '.jsonl'),
241
+ summary: summary,
242
+ last_modified: (stat.mtime.to_f * 1000).to_i,
243
+ file_size: stat.size,
244
+ custom_title: custom_title,
245
+ first_prompt: first_prompt,
246
+ git_branch: extract_json_string_field(tail, 'gitBranch', last: true) ||
247
+ extract_json_string_field(head, 'gitBranch', last: false),
248
+ cwd: extract_json_string_field(head, 'cwd', last: false) || project_path
249
+ )
250
+ end
251
+
252
+ # Read all sessions from a project directory
253
+ def read_sessions_from_dir(project_dir, project_path = nil)
254
+ return [] unless File.directory?(project_dir)
255
+
256
+ sessions = []
257
+ Dir.glob(File.join(project_dir, '*.jsonl')).each do |file_path|
258
+ stem = File.basename(file_path, '.jsonl')
259
+ next unless stem.match?(UUID_RE)
260
+
261
+ session = read_session_lite(file_path, project_path)
262
+ sessions << session if session
263
+ end
264
+ sessions
265
+ end
266
+
267
+ # List sessions for a directory (or all sessions)
268
+ # @param directory [String, nil] Working directory to list sessions for
269
+ # @param limit [Integer, nil] Maximum number of sessions to return
270
+ # @param include_worktrees [Boolean] Whether to include git worktree sessions
271
+ # @return [Array<SDKSessionInfo>] Sessions sorted by last_modified descending
272
+ def list_sessions(directory: nil, limit: nil, include_worktrees: true)
273
+ sessions = if directory
274
+ list_sessions_for_directory(directory, include_worktrees)
275
+ else
276
+ list_all_sessions
277
+ end
278
+
279
+ # Sort by last_modified descending
280
+ sessions.sort_by! { |s| -s.last_modified }
281
+ sessions = sessions.first(limit) if limit
282
+ sessions
283
+ end
284
+
285
+ # Get messages from a session transcript
286
+ # @param session_id [String] The session UUID
287
+ # @param directory [String, nil] Working directory to search in
288
+ # @param limit [Integer, nil] Maximum number of messages
289
+ # @param offset [Integer] Number of messages to skip
290
+ # @return [Array<SessionMessage>] Ordered messages from the session
291
+ def get_session_messages(session_id:, directory: nil, limit: nil, offset: 0)
292
+ return [] unless session_id.match?(UUID_RE)
293
+
294
+ file_path = find_session_file(session_id, directory)
295
+ return [] unless file_path && File.exist?(file_path)
296
+
297
+ entries = parse_jsonl_entries(file_path)
298
+ chain = build_conversation_chain(entries)
299
+ messages = filter_visible_messages(chain)
300
+
301
+ # Apply offset and limit
302
+ messages = messages[offset..] || []
303
+ messages = messages.first(limit) if limit
304
+ messages
305
+ end
306
+
307
+ # -- Private helpers --
308
+
309
+ def list_sessions_for_directory(directory, include_worktrees)
310
+ path = File.realpath(directory).unicode_normalize(:nfc)
311
+
312
+ worktree_paths = []
313
+ worktree_paths = detect_worktrees(path) if include_worktrees
314
+
315
+ if worktree_paths.length <= 1
316
+ project_dir = find_project_dir(path)
317
+ return project_dir ? read_sessions_from_dir(project_dir, path) : []
318
+ end
319
+
320
+ # Multiple worktrees: scan all project dirs for matches
321
+ all_sessions = []
322
+ worktree_paths.each do |wt_path|
323
+ project_dir = find_project_dir(wt_path)
324
+ next unless project_dir
325
+
326
+ all_sessions.concat(read_sessions_from_dir(project_dir, wt_path))
327
+ end
328
+
329
+ deduplicate_sessions(all_sessions)
330
+ end
331
+
332
+ def list_all_sessions
333
+ projects_dir = File.join(config_dir, 'projects')
334
+ return [] unless File.directory?(projects_dir)
335
+
336
+ all_sessions = []
337
+ Dir.children(projects_dir).each do |child|
338
+ dir = File.join(projects_dir, child)
339
+ next unless File.directory?(dir)
340
+
341
+ all_sessions.concat(read_sessions_from_dir(dir))
342
+ end
343
+
344
+ deduplicate_sessions(all_sessions)
345
+ end
346
+
347
+ def deduplicate_sessions(sessions)
348
+ by_id = {}
349
+ sessions.each do |s|
350
+ existing = by_id[s.session_id]
351
+ by_id[s.session_id] = s if existing.nil? || s.last_modified > existing.last_modified
352
+ end
353
+ by_id.values
354
+ end
355
+
356
+ def detect_worktrees(path)
357
+ output = `git -C #{Shellwords.escape(path)} worktree list --porcelain 2>/dev/null`
358
+ return [path] unless $CHILD_STATUS.success?
359
+
360
+ paths = output.lines.filter_map do |line|
361
+ line.strip.delete_prefix('worktree ') if line.start_with?('worktree ')
362
+ end
363
+ paths.empty? ? [path] : paths
364
+ rescue StandardError
365
+ [path]
366
+ end
367
+
368
+ def find_session_file(session_id, directory)
369
+ projects_dir = File.join(config_dir, 'projects')
370
+ return nil unless File.directory?(projects_dir)
371
+
372
+ if directory
373
+ path = File.realpath(directory).unicode_normalize(:nfc)
374
+ project_dir = find_project_dir(path)
375
+ if project_dir
376
+ candidate = File.join(project_dir, "#{session_id}.jsonl")
377
+ return candidate if File.exist?(candidate)
378
+ end
379
+
380
+ # Try worktrees
381
+ detect_worktrees(path).each do |wt_path|
382
+ pd = find_project_dir(wt_path)
383
+ next unless pd
384
+
385
+ candidate = File.join(pd, "#{session_id}.jsonl")
386
+ return candidate if File.exist?(candidate)
387
+ end
388
+ end
389
+
390
+ # Scan all project dirs
391
+ Dir.children(projects_dir).each do |child|
392
+ dir = File.join(projects_dir, child)
393
+ next unless File.directory?(dir)
394
+
395
+ candidate = File.join(dir, "#{session_id}.jsonl")
396
+ return candidate if File.exist?(candidate)
397
+ end
398
+
399
+ nil
400
+ end
401
+
402
+ def parse_jsonl_entries(file_path)
403
+ entries = []
404
+ valid_types = %w[user assistant progress system attachment].freeze
405
+
406
+ File.foreach(file_path) do |line|
407
+ entry = JSON.parse(line.strip, symbolize_names: false)
408
+ next unless entry.is_a?(Hash)
409
+ next unless valid_types.include?(entry['type'])
410
+ next unless entry['uuid'].is_a?(String)
411
+
412
+ entries << entry
413
+ rescue JSON::ParserError
414
+ next
415
+ end
416
+ entries
417
+ end
418
+
419
+ def build_conversation_chain(entries)
420
+ return [] if entries.empty?
421
+
422
+ by_uuid = {}
423
+ by_position = {}
424
+ parent_uuids = Set.new
425
+
426
+ entries.each_with_index do |entry, idx|
427
+ by_uuid[entry['uuid']] = entry
428
+ by_position[entry['uuid']] = idx
429
+ parent_uuids << entry['parentUuid'] if entry['parentUuid']
430
+ end
431
+
432
+ # Terminals: entries whose uuid is not any other entry's parentUuid
433
+ terminals = Set.new(by_uuid.keys) - parent_uuids
434
+
435
+ # Walk back from each terminal to find the nearest user/assistant leaf
436
+ leaf_candidates = terminals.filter_map do |uuid|
437
+ walk_to_leaf(by_uuid, uuid)
438
+ end
439
+
440
+ # Keep only main-chain candidates (not sidechain, team, or meta)
441
+ main_leaves = leaf_candidates.reject do |e|
442
+ e['isSidechain'] || e['teamName'] || e['isMeta']
443
+ end
444
+ return [] if main_leaves.empty?
445
+
446
+ # Pick the leaf with highest file position, walk to root
447
+ best_leaf = main_leaves.max_by { |e| by_position[e['uuid']] || 0 }
448
+ walk_to_root(by_uuid, best_leaf)
449
+ end
450
+
451
+ def walk_to_leaf(by_uuid, uuid)
452
+ visited = Set.new
453
+ current = by_uuid[uuid]
454
+ while current
455
+ return current if %w[user assistant].include?(current['type'])
456
+ return nil unless visited.add?(current['uuid'])
457
+
458
+ parent = current['parentUuid']
459
+ current = parent ? by_uuid[parent] : nil
460
+ end
461
+ end
462
+
463
+ def walk_to_root(by_uuid, leaf)
464
+ chain = []
465
+ visited = Set.new
466
+ current = leaf
467
+ while current
468
+ break unless visited.add?(current['uuid'])
469
+
470
+ chain << current
471
+ parent = current['parentUuid']
472
+ current = parent ? by_uuid[parent] : nil
473
+ end
474
+ chain.reverse
475
+ end
476
+
477
+ def filter_visible_messages(chain)
478
+ chain.filter_map do |entry|
479
+ next unless %w[user assistant].include?(entry['type'])
480
+ next if entry['isMeta']
481
+ next if entry['isSidechain']
482
+ next if entry['teamName']
483
+
484
+ SessionMessage.new(
485
+ type: entry['type'],
486
+ uuid: entry['uuid'],
487
+ session_id: entry['sessionId'] || entry['session_id'] || '',
488
+ message: entry['message']
489
+ )
490
+ end
491
+ end
492
+
493
+ private_class_method :list_sessions_for_directory, :list_all_sessions,
494
+ :deduplicate_sessions, :detect_worktrees,
495
+ :find_session_file, :parse_jsonl_entries,
496
+ :build_conversation_chain, :walk_to_leaf, :walk_to_root,
497
+ :filter_visible_messages, :read_head_tail, :build_session_info
498
+ end
499
+ end
@@ -124,23 +124,79 @@ module ClaudeAgentSDK
124
124
  end
125
125
  end
126
126
 
127
+ # Task lifecycle notification statuses
128
+ TASK_NOTIFICATION_STATUSES = %w[completed failed stopped].freeze
129
+
130
+ # Task started system message (subagent/background task started)
131
+ class TaskStartedMessage < SystemMessage
132
+ attr_accessor :task_id, :description, :uuid, :session_id, :tool_use_id, :task_type
133
+
134
+ def initialize(subtype:, data:, task_id:, description:, uuid:, session_id:,
135
+ tool_use_id: nil, task_type: nil)
136
+ super(subtype: subtype, data: data)
137
+ @task_id = task_id
138
+ @description = description
139
+ @uuid = uuid
140
+ @session_id = session_id
141
+ @tool_use_id = tool_use_id
142
+ @task_type = task_type
143
+ end
144
+ end
145
+
146
+ # Task progress system message (periodic update from a running task)
147
+ class TaskProgressMessage < SystemMessage
148
+ attr_accessor :task_id, :description, :usage, :uuid, :session_id, :tool_use_id, :last_tool_name
149
+
150
+ def initialize(subtype:, data:, task_id:, description:, usage:, uuid:, session_id:,
151
+ tool_use_id: nil, last_tool_name: nil)
152
+ super(subtype: subtype, data: data)
153
+ @task_id = task_id
154
+ @description = description
155
+ @usage = usage
156
+ @uuid = uuid
157
+ @session_id = session_id
158
+ @tool_use_id = tool_use_id
159
+ @last_tool_name = last_tool_name
160
+ end
161
+ end
162
+
163
+ # Task notification system message (task completed/failed/stopped)
164
+ class TaskNotificationMessage < SystemMessage
165
+ attr_accessor :task_id, :status, :output_file, :summary, :uuid, :session_id, :tool_use_id, :usage
166
+
167
+ def initialize(subtype:, data:, task_id:, status:, output_file:, summary:, uuid:, session_id:,
168
+ tool_use_id: nil, usage: nil)
169
+ super(subtype: subtype, data: data)
170
+ @task_id = task_id
171
+ @status = status
172
+ @output_file = output_file
173
+ @summary = summary
174
+ @uuid = uuid
175
+ @session_id = session_id
176
+ @tool_use_id = tool_use_id
177
+ @usage = usage
178
+ end
179
+ end
180
+
127
181
  # Result message with cost and usage information
128
182
  class ResultMessage
129
183
  attr_accessor :subtype, :duration_ms, :duration_api_ms, :is_error,
130
- :num_turns, :session_id, :total_cost_usd, :usage, :result, :structured_output
184
+ :num_turns, :session_id, :stop_reason, :total_cost_usd, :usage, :result, :structured_output
131
185
 
132
186
  def initialize(subtype:, duration_ms:, duration_api_ms:, is_error:,
133
- num_turns:, session_id:, total_cost_usd: nil, usage: nil, result: nil, structured_output: nil)
187
+ num_turns:, session_id:, stop_reason: nil, total_cost_usd: nil,
188
+ usage: nil, result: nil, structured_output: nil)
134
189
  @subtype = subtype
135
190
  @duration_ms = duration_ms
136
191
  @duration_api_ms = duration_api_ms
137
192
  @is_error = is_error
138
193
  @num_turns = num_turns
139
194
  @session_id = session_id
195
+ @stop_reason = stop_reason
140
196
  @total_cost_usd = total_cost_usd
141
197
  @usage = usage
142
198
  @result = result
143
- @structured_output = structured_output # Structured output when output_format is specified
199
+ @structured_output = structured_output
144
200
  end
145
201
  end
146
202
 
@@ -320,29 +376,34 @@ module ClaudeAgentSDK
320
376
 
321
377
  # PreToolUse hook input
322
378
  class PreToolUseHookInput < BaseHookInput
323
- attr_accessor :hook_event_name, :tool_name, :tool_input, :tool_use_id
379
+ attr_accessor :hook_event_name, :tool_name, :tool_input, :tool_use_id, :agent_id, :agent_type
324
380
 
325
- def initialize(hook_event_name: 'PreToolUse', tool_name: nil, tool_input: nil, tool_use_id: nil, **base_args)
381
+ def initialize(hook_event_name: 'PreToolUse', tool_name: nil, tool_input: nil, tool_use_id: nil,
382
+ agent_id: nil, agent_type: nil, **base_args)
326
383
  super(**base_args)
327
384
  @hook_event_name = hook_event_name
328
385
  @tool_name = tool_name
329
386
  @tool_input = tool_input
330
387
  @tool_use_id = tool_use_id
388
+ @agent_id = agent_id
389
+ @agent_type = agent_type
331
390
  end
332
391
  end
333
392
 
334
393
  # PostToolUse hook input
335
394
  class PostToolUseHookInput < BaseHookInput
336
- attr_accessor :hook_event_name, :tool_name, :tool_input, :tool_response, :tool_use_id
395
+ attr_accessor :hook_event_name, :tool_name, :tool_input, :tool_response, :tool_use_id, :agent_id, :agent_type
337
396
 
338
397
  def initialize(hook_event_name: 'PostToolUse', tool_name: nil, tool_input: nil, tool_response: nil,
339
- tool_use_id: nil, **base_args)
398
+ tool_use_id: nil, agent_id: nil, agent_type: nil, **base_args)
340
399
  super(**base_args)
341
400
  @hook_event_name = hook_event_name
342
401
  @tool_name = tool_name
343
402
  @tool_input = tool_input
344
403
  @tool_response = tool_response
345
404
  @tool_use_id = tool_use_id
405
+ @agent_id = agent_id
406
+ @agent_type = agent_type
346
407
  end
347
408
  end
348
409
 
@@ -385,10 +446,11 @@ module ClaudeAgentSDK
385
446
 
386
447
  # PostToolUseFailure hook input
387
448
  class PostToolUseFailureHookInput < BaseHookInput
388
- attr_accessor :hook_event_name, :tool_name, :tool_input, :tool_use_id, :error, :is_interrupt
449
+ attr_accessor :hook_event_name, :tool_name, :tool_input, :tool_use_id, :error, :is_interrupt,
450
+ :agent_id, :agent_type
389
451
 
390
452
  def initialize(hook_event_name: 'PostToolUseFailure', tool_name: nil, tool_input: nil, tool_use_id: nil,
391
- error: nil, is_interrupt: nil, **base_args)
453
+ error: nil, is_interrupt: nil, agent_id: nil, agent_type: nil, **base_args)
392
454
  super(**base_args)
393
455
  @hook_event_name = hook_event_name
394
456
  @tool_name = tool_name
@@ -396,6 +458,8 @@ module ClaudeAgentSDK
396
458
  @tool_use_id = tool_use_id
397
459
  @error = error
398
460
  @is_interrupt = is_interrupt
461
+ @agent_id = agent_id
462
+ @agent_type = agent_type
399
463
  end
400
464
  end
401
465
 
@@ -426,15 +490,17 @@ module ClaudeAgentSDK
426
490
 
427
491
  # PermissionRequest hook input
428
492
  class PermissionRequestHookInput < BaseHookInput
429
- attr_accessor :hook_event_name, :tool_name, :tool_input, :permission_suggestions
493
+ attr_accessor :hook_event_name, :tool_name, :tool_input, :permission_suggestions, :agent_id, :agent_type
430
494
 
431
495
  def initialize(hook_event_name: 'PermissionRequest', tool_name: nil, tool_input: nil, permission_suggestions: nil,
432
- **base_args)
496
+ agent_id: nil, agent_type: nil, **base_args)
433
497
  super(**base_args)
434
498
  @hook_event_name = hook_event_name
435
499
  @tool_name = tool_name
436
500
  @tool_input = tool_input
437
501
  @permission_suggestions = permission_suggestions
502
+ @agent_id = agent_id
503
+ @agent_type = agent_type
438
504
  end
439
505
  end
440
506
 
@@ -632,6 +698,105 @@ module ClaudeAgentSDK
632
698
  end
633
699
  end
634
700
 
701
+ # MCP status response types
702
+
703
+ # MCP server connection status values
704
+ MCP_SERVER_CONNECTION_STATUSES = %w[connected failed needs-auth pending disabled].freeze
705
+
706
+ # MCP server info (name and version)
707
+ class McpServerInfo
708
+ attr_accessor :name, :version
709
+
710
+ def initialize(name:, version: nil)
711
+ @name = name
712
+ @version = version
713
+ end
714
+ end
715
+
716
+ # MCP tool annotation hints
717
+ class McpToolAnnotations
718
+ attr_accessor :read_only, :destructive, :open_world
719
+
720
+ def initialize(read_only: nil, destructive: nil, open_world: nil)
721
+ @read_only = read_only
722
+ @destructive = destructive
723
+ @open_world = open_world
724
+ end
725
+
726
+ def self.parse(data)
727
+ return nil unless data
728
+
729
+ new(
730
+ read_only: data.key?(:readOnly) ? data[:readOnly] : data[:read_only],
731
+ destructive: data[:destructive],
732
+ open_world: data.key?(:openWorld) ? data[:openWorld] : data[:open_world]
733
+ )
734
+ end
735
+ end
736
+
737
+ # MCP tool info (name, description, annotations)
738
+ class McpToolInfo
739
+ attr_accessor :name, :description, :annotations
740
+
741
+ def initialize(name:, description: nil, annotations: nil)
742
+ @name = name
743
+ @description = description
744
+ @annotations = annotations
745
+ end
746
+
747
+ def self.parse(data)
748
+ new(
749
+ name: data[:name],
750
+ description: data[:description],
751
+ annotations: McpToolAnnotations.parse(data[:annotations])
752
+ )
753
+ end
754
+ end
755
+
756
+ # Status of a single MCP server connection
757
+ class McpServerStatus
758
+ attr_accessor :name, :status, :server_info, :error, :config, :scope, :tools
759
+
760
+ def initialize(name:, status:, server_info: nil, error: nil, config: nil, scope: nil, tools: nil)
761
+ @name = name
762
+ @status = status
763
+ @server_info = server_info
764
+ @error = error
765
+ @config = config
766
+ @scope = scope
767
+ @tools = tools
768
+ end
769
+
770
+ def self.parse(data)
771
+ server_info = (McpServerInfo.new(name: data[:serverInfo][:name], version: data[:serverInfo][:version]) if data[:serverInfo])
772
+ tools = data[:tools]&.map { |t| McpToolInfo.parse(t) }
773
+
774
+ new(
775
+ name: data[:name],
776
+ status: data[:status],
777
+ server_info: server_info,
778
+ error: data[:error],
779
+ config: data[:config],
780
+ scope: data[:scope],
781
+ tools: tools
782
+ )
783
+ end
784
+ end
785
+
786
+ # Response from get_mcp_status containing all server statuses
787
+ class McpStatusResponse
788
+ attr_accessor :mcp_servers
789
+
790
+ def initialize(mcp_servers:)
791
+ @mcp_servers = mcp_servers
792
+ end
793
+
794
+ def self.parse(data)
795
+ servers = (data[:mcpServers] || []).map { |s| McpServerStatus.parse(s) }
796
+ new(mcp_servers: servers)
797
+ end
798
+ end
799
+
635
800
  # MCP Server configurations
636
801
  class McpStdioServerConfig
637
802
  attr_accessor :type, :command, :args, :env
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ClaudeAgentSDK
4
- VERSION = '0.7.3'
4
+ VERSION = '0.8.0'
5
5
  end
@@ -10,6 +10,7 @@ require_relative 'claude_agent_sdk/message_parser'
10
10
  require_relative 'claude_agent_sdk/query'
11
11
  require_relative 'claude_agent_sdk/sdk_mcp_server'
12
12
  require_relative 'claude_agent_sdk/streaming'
13
+ require_relative 'claude_agent_sdk/sessions'
13
14
  require 'async'
14
15
  require 'securerandom'
15
16
 
@@ -58,6 +59,25 @@ module ClaudeAgentSDK
58
59
  # ClaudeAgentSDK.query(prompt: messages) do |message|
59
60
  # puts message
60
61
  # end
62
+ # List sessions for a directory (or all sessions)
63
+ # @param directory [String, nil] Working directory to list sessions for
64
+ # @param limit [Integer, nil] Maximum number of sessions to return
65
+ # @param include_worktrees [Boolean] Whether to include git worktree sessions
66
+ # @return [Array<SDKSessionInfo>] Sessions sorted by last_modified descending
67
+ def self.list_sessions(directory: nil, limit: nil, include_worktrees: true)
68
+ Sessions.list_sessions(directory: directory, limit: limit, include_worktrees: include_worktrees)
69
+ end
70
+
71
+ # Get messages from a session transcript
72
+ # @param session_id [String] The session UUID
73
+ # @param directory [String, nil] Working directory to search in
74
+ # @param limit [Integer, nil] Maximum number of messages
75
+ # @param offset [Integer] Number of messages to skip
76
+ # @return [Array<SessionMessage>] Ordered messages from the session
77
+ def self.get_session_messages(session_id:, directory: nil, limit: nil, offset: 0)
78
+ Sessions.get_session_messages(session_id: session_id, directory: directory, limit: limit, offset: offset)
79
+ end
80
+
61
81
  def self.query(prompt:, options: nil, &block)
62
82
  return enum_for(:query, prompt: prompt, options: options) unless block
63
83
 
@@ -310,6 +330,28 @@ module ClaudeAgentSDK
310
330
  @query_handler.set_model(model)
311
331
  end
312
332
 
333
+ # Reconnect a failed MCP server
334
+ # @param server_name [String] Name of the MCP server to reconnect
335
+ def reconnect_mcp_server(server_name)
336
+ raise CLIConnectionError, 'Not connected. Call connect() first' unless @connected
337
+ @query_handler.reconnect_mcp_server(server_name)
338
+ end
339
+
340
+ # Enable or disable an MCP server
341
+ # @param server_name [String] Name of the MCP server
342
+ # @param enabled [Boolean] Whether to enable or disable
343
+ def toggle_mcp_server(server_name, enabled)
344
+ raise CLIConnectionError, 'Not connected. Call connect() first' unless @connected
345
+ @query_handler.toggle_mcp_server(server_name, enabled)
346
+ end
347
+
348
+ # Stop a running background task
349
+ # @param task_id [String] The ID of the task to stop
350
+ def stop_task(task_id)
351
+ raise CLIConnectionError, 'Not connected. Call connect() first' unless @connected
352
+ @query_handler.stop_task(task_id)
353
+ end
354
+
313
355
  # Rewind files to a previous checkpoint (v0.1.15+)
314
356
  # Restores file state to what it was at the given user message
315
357
  # Requires enable_file_checkpointing to be true in options
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: claude-agent-sdk
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.7.3
4
+ version: 0.8.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Community Contributors
8
8
  bindir: bin
9
9
  cert_chain: []
10
- date: 2026-02-25 00:00:00.000000000 Z
10
+ date: 2026-03-05 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: async
@@ -109,6 +109,7 @@ files:
109
109
  - lib/claude_agent_sdk/message_parser.rb
110
110
  - lib/claude_agent_sdk/query.rb
111
111
  - lib/claude_agent_sdk/sdk_mcp_server.rb
112
+ - lib/claude_agent_sdk/sessions.rb
112
113
  - lib/claude_agent_sdk/streaming.rb
113
114
  - lib/claude_agent_sdk/subprocess_cli_transport.rb
114
115
  - lib/claude_agent_sdk/transport.rb