claude-agent-sdk 0.14.2 → 0.15.1
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 +4 -4
- data/CHANGELOG.md +32 -1
- data/README.md +40 -1
- data/lib/claude_agent_sdk/fiber_boundary.rb +39 -0
- data/lib/claude_agent_sdk/query.rb +22 -17
- data/lib/claude_agent_sdk/sdk_mcp_server.rb +6 -4
- data/lib/claude_agent_sdk/session_mutations.rb +39 -22
- data/lib/claude_agent_sdk/sessions.rb +11 -7
- data/lib/claude_agent_sdk/subprocess_cli_transport.rb +29 -9
- data/lib/claude_agent_sdk/version.rb +1 -1
- data/lib/claude_agent_sdk.rb +10 -4
- metadata +3 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 9a037f9cca486fc8d6e94269b9f5f7b686574665ff403457761db764f9f50f07
|
|
4
|
+
data.tar.gz: 01bff518e3306d1fb23b8bc1e9b11b72785a98aecf3636a1096c5522f4720444
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 253df532bcdc4a6e0b13e0d4438944bcf9c6018b5d1062b4831efc6dac7a332c1759f85137e6de4fc44de0e8b1eb7ccfd0033698392c8faf6ca40f7e237b2e51
|
|
7
|
+
data.tar.gz: 143d4ee8780d1a675e1531edc944de1756bff03faa9c42513b9672a3f37ac670dc17929a936cf0f1520d1cfdc45c53042d8f8fa07ad5a559e4b59439e79848f5
|
data/CHANGELOG.md
CHANGED
|
@@ -7,7 +7,38 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
-
## [0.
|
|
10
|
+
## [0.15.1] - 2026-04-22
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
- **Thread-keyed libraries are now safe inside SDK callbacks.** The SDK internally hops to a plain thread at every user-callback boundary — blocks passed to `ClaudeAgentSDK.query` / `Client#receive_messages`, SDK MCP tool handlers, hooks, permission callbacks, and observer methods — so the `async` gem's Fiber scheduler is no longer visible to user code. Previously, any library that keys state on `Thread.current` (ActiveRecord and every DB driver keyed by thread — `pg`, `mysql2`, `sqlite3` — plus per-thread HTTP/cache pools, request stores, etc.) could be corrupted by the scheduler interleaving two fibers onto one checked-out connection. Rails/Sidekiq/Kamal consumers no longer need a caller-side wrapper to avoid this. See the "Thread-keyed libraries are safe inside SDK callbacks" subsection under Rails Integration in the README.
|
|
14
|
+
|
|
15
|
+
### Changed
|
|
16
|
+
- **Callbacks run on a plain thread, not inside `Async::Task`.** Fiber-specific primitives (e.g. `Async::Task.current.sleep`, `Async::Task.current.async { ... }`) are no longer available inside tool handlers, hooks, permission callbacks, message blocks, or observers. Callbacks that want cooperative concurrency can open their own `Async { }` block. In practice callbacks do ordinary Ruby work and return a value, so this rarely affects real code.
|
|
17
|
+
|
|
18
|
+
## [0.15.0] - 2026-04-17
|
|
19
|
+
|
|
20
|
+
### Fixed
|
|
21
|
+
|
|
22
|
+
#### Protocol & CLI
|
|
23
|
+
- `--setting-sources` is now only emitted when the option is explicitly configured. Previously every invocation sent `--setting-sources ""`, which the CLI can interpret as "no setting sources" rather than "use defaults", overriding the CLI's own source resolution.
|
|
24
|
+
- `extra_args` flag names are validated against a lowercase kebab-case pattern and raise `ArgumentError` otherwise. Prevents option injection from multi-tenant configs (e.g. an attacker-controlled hash injecting `--permission-mode bypassPermissions` and relying on CLI last-wins to defeat SDK-chosen safety).
|
|
25
|
+
|
|
26
|
+
#### Concurrency
|
|
27
|
+
- `SubprocessCLITransport#close` replaced `Timeout.timeout` with Async-safe polling on `@process.alive?`. Stdlib `Timeout.timeout` raises via `Thread#raise`, which can corrupt fiber-scheduler state when `close` runs inside the Async reactor. Still raises `Timeout::Error` so existing rescue clauses keep working.
|
|
28
|
+
- Inbound `control_request` handlers are now spawned as children of the current read task via `Async::Task.current.async`. Bare `Async do` had ambiguous parent linkage; `@task.stop` could leave handler tasks writing to a closed transport.
|
|
29
|
+
|
|
30
|
+
#### Sessions
|
|
31
|
+
- `list_sessions` and `get_session_messages` coerce `offset: nil` to 0. Previously callers splatting from an options hash crashed on `nil.positive?` / `messages[nil..]`.
|
|
32
|
+
- `fork_session` streams the source JSONL via `File.foreach` instead of `File.read`, and scrubs non-UTF-8 bytes on each line. Fixes `Encoding::InvalidByteSequenceError` on stray bytes in tool output and avoids slurping sessions that can reach hundreds of MB.
|
|
33
|
+
- `simple_hash` (used for project-dir hashing) now iterates UTF-16 code units to match JavaScript's `charCodeAt`. Previously `each_char` + `ord` diverged from the official tools for supplementary characters (emoji, CJK extensions), so paths containing them hashed to different project directories and silently returned no sessions.
|
|
34
|
+
|
|
35
|
+
#### Security
|
|
36
|
+
- Replaced shell backticks with `Open3.capture3` (array args) in worktree detection. The path argument was already `Shellwords.escape`d, but running via `/bin/sh` leaves a latent shell-injection surface — any future interpolation without escaping would be exploitable.
|
|
37
|
+
|
|
38
|
+
### Changed
|
|
39
|
+
- `derive_fork_title` helper is now `private_class_method`, matching its siblings on `SessionMutations`.
|
|
40
|
+
|
|
41
|
+
|
|
11
42
|
|
|
12
43
|
### Added
|
|
13
44
|
- **`EFFORT_LEVELS` constant** exposing `%w[low medium high xhigh max]`. Consumers can reference `ClaudeAgentSDK::EFFORT_LEVELS` for validation instead of hard-coding the list.
|
data/README.md
CHANGED
|
@@ -106,7 +106,7 @@ Add this line to your application's Gemfile:
|
|
|
106
106
|
gem 'claude-agent-sdk', github: 'ya-luotao/claude-agent-sdk-ruby'
|
|
107
107
|
|
|
108
108
|
# Or use a stable version from RubyGems
|
|
109
|
-
gem 'claude-agent-sdk', '~> 0.
|
|
109
|
+
gem 'claude-agent-sdk', '~> 0.15.1'
|
|
110
110
|
```
|
|
111
111
|
|
|
112
112
|
And then execute:
|
|
@@ -1194,6 +1194,45 @@ For a complete multi-tool example, see [examples/otel_langfuse_example.rb](examp
|
|
|
1194
1194
|
|
|
1195
1195
|
The SDK integrates well with Rails applications. Here are common patterns:
|
|
1196
1196
|
|
|
1197
|
+
### Thread-keyed libraries are safe inside SDK callbacks
|
|
1198
|
+
|
|
1199
|
+
The SDK depends on [`async`](https://github.com/socketry/async), which installs
|
|
1200
|
+
a Fiber scheduler that multiplexes fibers onto a single OS thread and
|
|
1201
|
+
intercepts IO so blocking calls yield to siblings. Most mature Ruby libraries
|
|
1202
|
+
are thread-safe but not fiber-safe — they key state (checked-out DB
|
|
1203
|
+
connections, per-thread caches, request stores) on `Thread.current`. When the
|
|
1204
|
+
scheduler interleaves two fibers on one thread, those fibers share the same
|
|
1205
|
+
state slot, and interleaved IO on a shared connection silently corrupts wire
|
|
1206
|
+
protocols. This affects every DB driver keyed by thread (`pg`, `mysql2`,
|
|
1207
|
+
`sqlite3`), ActiveRecord's connection pool, and HTTP/cache clients pooled per
|
|
1208
|
+
thread.
|
|
1209
|
+
|
|
1210
|
+
You do **not** need to think about this. The SDK hops to a plain thread at
|
|
1211
|
+
every user-callback boundary — message blocks given to `query` / `Client`, SDK
|
|
1212
|
+
MCP tool handlers, hooks, permission callbacks, and observer methods — so
|
|
1213
|
+
your code runs with no Fiber scheduler active and inherits the ordinary
|
|
1214
|
+
thread-keyed assumptions every Rails / Sidekiq / Kamal app already makes:
|
|
1215
|
+
|
|
1216
|
+
```ruby
|
|
1217
|
+
tool = ClaudeAgentSDK.create_tool('lookup_user', 'Look up a user', { id: Integer }) do |args|
|
|
1218
|
+
user = User.find(args[:id]) # just works
|
|
1219
|
+
{ content: [{ type: 'text', text: user.name }] }
|
|
1220
|
+
end
|
|
1221
|
+
|
|
1222
|
+
ClaudeAgentSDK.query(prompt: '...') do |message|
|
|
1223
|
+
Message.create!(role: 'assistant', body: message.to_s) # just works
|
|
1224
|
+
end
|
|
1225
|
+
```
|
|
1226
|
+
|
|
1227
|
+
The trade-off: because callbacks run on a plain thread rather than inside
|
|
1228
|
+
an `Async::Task`, fiber-specific primitives aren't available to them —
|
|
1229
|
+
`Async::Task.current` will raise "No async task available". If a callback
|
|
1230
|
+
wants cooperative concurrency it should open its own `Async { }` block. In
|
|
1231
|
+
practice, callbacks typically do some Ruby work, call external services, and
|
|
1232
|
+
return — so this rarely matters. If you wrap your own call site in an outer
|
|
1233
|
+
`Async { }` block, the scheduler is visible to your code again; you've opted
|
|
1234
|
+
in, and whatever fiber-safety rules your app uses apply there.
|
|
1235
|
+
|
|
1197
1236
|
### ActionCable Streaming
|
|
1198
1237
|
|
|
1199
1238
|
Stream Claude responses to the frontend in real-time:
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ClaudeAgentSDK
|
|
4
|
+
# Internal. Consumers of the SDK should never need this directly.
|
|
5
|
+
#
|
|
6
|
+
# The SDK depends on `async`, which installs a Fiber scheduler whenever an
|
|
7
|
+
# `Async { }` block is active. That scheduler multiplexes fibers onto a
|
|
8
|
+
# single OS thread and intercepts IO so blocking calls yield to siblings.
|
|
9
|
+
#
|
|
10
|
+
# Most mature Ruby libraries are thread-safe but not fiber-safe: they key
|
|
11
|
+
# state (checked-out DB connections, per-thread caches, request stores)
|
|
12
|
+
# on `Thread.current`. When the scheduler interleaves two fibers on one
|
|
13
|
+
# thread, those fibers share one state slot — and interleaved IO on a
|
|
14
|
+
# shared connection silently corrupts wire protocols. This bites every
|
|
15
|
+
# DB driver keyed by thread (pg, mysql2, sqlite3), ActiveRecord's
|
|
16
|
+
# connection pool, and any HTTP/cache client pooled per-thread.
|
|
17
|
+
#
|
|
18
|
+
# The SDK invokes user-supplied callbacks (tool handlers, hooks,
|
|
19
|
+
# permission callbacks, message blocks, observer methods) from inside
|
|
20
|
+
# its reactor. `FiberBoundary.invoke` hops those calls to a plain
|
|
21
|
+
# Ruby thread so user code runs on a fiber-scheduler-free thread and
|
|
22
|
+
# inherits the same thread-keyed state assumptions the rest of the
|
|
23
|
+
# user's app makes.
|
|
24
|
+
#
|
|
25
|
+
# No-op when no scheduler is active, so it's cheap to use unconditionally.
|
|
26
|
+
module FiberBoundary
|
|
27
|
+
module_function
|
|
28
|
+
|
|
29
|
+
# Run the given block on a plain thread when a Fiber scheduler is active.
|
|
30
|
+
# Returns the block's value. Exceptions propagate to the caller.
|
|
31
|
+
def invoke(&block)
|
|
32
|
+
return block.call unless Fiber.scheduler
|
|
33
|
+
|
|
34
|
+
thread = Thread.new(&block)
|
|
35
|
+
thread.report_on_exception = false
|
|
36
|
+
thread.value
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -162,14 +162,17 @@ module ClaudeAgentSDK
|
|
|
162
162
|
handle_control_response(message)
|
|
163
163
|
when 'control_request'
|
|
164
164
|
request_id = message[:request_id] || message[:requestId]
|
|
165
|
-
task
|
|
165
|
+
# Spawn as a child of the current task so @task.stop cascades and
|
|
166
|
+
# nothing keeps running after close; bare Async do may root at the
|
|
167
|
+
# reactor and leak past shutdown.
|
|
168
|
+
handler_task = Async::Task.current.async do
|
|
166
169
|
begin
|
|
167
170
|
handle_control_request(message)
|
|
168
171
|
ensure
|
|
169
172
|
@inflight_control_request_tasks.delete(request_id) if request_id
|
|
170
173
|
end
|
|
171
174
|
end
|
|
172
|
-
@inflight_control_request_tasks[request_id] =
|
|
175
|
+
@inflight_control_request_tasks[request_id] = handler_task if request_id
|
|
173
176
|
when 'control_cancel_request'
|
|
174
177
|
request_id = message[:request_id] || message[:requestId]
|
|
175
178
|
task = request_id ? @inflight_control_request_tasks[request_id] : nil
|
|
@@ -297,11 +300,11 @@ module ClaudeAgentSDK
|
|
|
297
300
|
agent_id: request_data[:agent_id]
|
|
298
301
|
)
|
|
299
302
|
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
context
|
|
304
|
-
|
|
303
|
+
# User-supplied permission callback runs on a plain thread, not the
|
|
304
|
+
# Async reactor, so AR/PG calls inside it aren't intercepted.
|
|
305
|
+
response = FiberBoundary.invoke do
|
|
306
|
+
@can_use_tool.call(request_data[:tool_name], request_data[:input], context)
|
|
307
|
+
end
|
|
305
308
|
|
|
306
309
|
# Convert PermissionResult to expected format
|
|
307
310
|
case response
|
|
@@ -335,19 +338,21 @@ module ClaudeAgentSDK
|
|
|
335
338
|
# Create typed HookContext
|
|
336
339
|
context = HookContext.new(signal: nil)
|
|
337
340
|
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
341
|
+
# Hop off the Fiber scheduler before invoking user hook code. The
|
|
342
|
+
# Async-side timeout still wraps the hop; if it fires, .value returns
|
|
343
|
+
# early with an exception and the worker thread is left to finish on
|
|
344
|
+
# its own (matches prior best-effort cancellation semantics).
|
|
345
|
+
unless @hook_callback_timeouts[callback_id]
|
|
346
|
+
hook_output = FiberBoundary.invoke do
|
|
347
|
+
callback.call(hook_input, request_data[:tool_use_id], context)
|
|
348
|
+
end
|
|
349
|
+
end
|
|
343
350
|
|
|
344
351
|
if (timeout = @hook_callback_timeouts[callback_id])
|
|
345
352
|
hook_output = Async::Task.current.with_timeout(timeout) do
|
|
346
|
-
|
|
347
|
-
hook_input,
|
|
348
|
-
|
|
349
|
-
context
|
|
350
|
-
)
|
|
353
|
+
FiberBoundary.invoke do
|
|
354
|
+
callback.call(hook_input, request_data[:tool_use_id], context)
|
|
355
|
+
end
|
|
351
356
|
end
|
|
352
357
|
end
|
|
353
358
|
|
|
@@ -79,8 +79,9 @@ module ClaudeAgentSDK
|
|
|
79
79
|
tool = @tools.find { |t| t.name == name }
|
|
80
80
|
raise "Tool '#{name}' not found" unless tool
|
|
81
81
|
|
|
82
|
-
# Call the tool's handler
|
|
83
|
-
|
|
82
|
+
# Call the tool's handler on a plain thread so the async gem's
|
|
83
|
+
# Fiber scheduler is not visible to user code (which may hit AR/PG).
|
|
84
|
+
result = FiberBoundary.invoke { tool.handler.call(arguments) }
|
|
84
85
|
|
|
85
86
|
# Ensure result has the expected format
|
|
86
87
|
unless result.is_a?(Hash) && result[:content]
|
|
@@ -180,8 +181,9 @@ module ClaudeAgentSDK
|
|
|
180
181
|
end
|
|
181
182
|
|
|
182
183
|
def call(server_context: nil, **args)
|
|
183
|
-
# Filter out server_context and pass remaining args to handler
|
|
184
|
-
|
|
184
|
+
# Filter out server_context and pass remaining args to handler.
|
|
185
|
+
# Hop to a plain thread so user handlers don't see the Fiber scheduler.
|
|
186
|
+
result = FiberBoundary.invoke { @tool_def.handler.call(args) }
|
|
185
187
|
|
|
186
188
|
content = ClaudeAgentSDK.flexible_fetch(result, 'content', 'content')
|
|
187
189
|
unless result.is_a?(Hash) && content
|
|
@@ -108,10 +108,10 @@ module ClaudeAgentSDK
|
|
|
108
108
|
raise Errno::ENOENT, "Session #{session_id} not found#{" in project directory for #{directory}" if directory}" unless result
|
|
109
109
|
|
|
110
110
|
file_path, project_dir = result
|
|
111
|
-
|
|
112
|
-
raise ArgumentError, "Session #{session_id} has no messages to fork" if
|
|
111
|
+
file_size = File.size(file_path)
|
|
112
|
+
raise ArgumentError, "Session #{session_id} has no messages to fork" if file_size.zero?
|
|
113
113
|
|
|
114
|
-
transcript, content_replacements = parse_fork_transcript(
|
|
114
|
+
transcript, content_replacements = parse_fork_transcript(file_path)
|
|
115
115
|
transcript.reject! { |e| e['isSidechain'] }
|
|
116
116
|
raise ArgumentError, "Session #{session_id} has no messages to fork" if transcript.empty?
|
|
117
117
|
|
|
@@ -149,19 +149,9 @@ module ClaudeAgentSDK
|
|
|
149
149
|
})
|
|
150
150
|
end
|
|
151
151
|
|
|
152
|
-
# Derive title
|
|
152
|
+
# Derive title — only read head/tail chunks when we need to generate one
|
|
153
153
|
fork_title = title&.strip
|
|
154
|
-
if fork_title.nil? || fork_title.empty?
|
|
155
|
-
head = content[0, Sessions::LITE_READ_BUF_SIZE] || ''
|
|
156
|
-
tail = content.length > Sessions::LITE_READ_BUF_SIZE ? content[-Sessions::LITE_READ_BUF_SIZE..] : head
|
|
157
|
-
base = Sessions.extract_json_string_field(tail, 'customTitle', last: true) ||
|
|
158
|
-
Sessions.extract_json_string_field(head, 'customTitle', last: true) ||
|
|
159
|
-
Sessions.extract_json_string_field(tail, 'aiTitle', last: true) ||
|
|
160
|
-
Sessions.extract_json_string_field(head, 'aiTitle', last: true) ||
|
|
161
|
-
Sessions.extract_first_prompt_from_head(head) ||
|
|
162
|
-
'Forked session'
|
|
163
|
-
fork_title = "#{base} (fork)"
|
|
164
|
-
end
|
|
154
|
+
fork_title = "#{derive_fork_title(file_path, file_size)} (fork)" if fork_title.nil? || fork_title.empty?
|
|
165
155
|
|
|
166
156
|
lines << JSON.generate({
|
|
167
157
|
'type' => 'custom-title',
|
|
@@ -236,13 +226,20 @@ module ClaudeAgentSDK
|
|
|
236
226
|
nil
|
|
237
227
|
end
|
|
238
228
|
|
|
239
|
-
# Parse a fork transcript
|
|
240
|
-
|
|
229
|
+
# Parse a fork transcript by streaming the JSONL file line-by-line.
|
|
230
|
+
# Opens in binary mode and scrubs invalid UTF-8 so stray non-UTF-8
|
|
231
|
+
# bytes in tool results do not raise Encoding::InvalidByteSequenceError.
|
|
232
|
+
def parse_fork_transcript(file_path)
|
|
241
233
|
transcript = []
|
|
242
234
|
content_replacements = nil
|
|
243
235
|
|
|
244
|
-
|
|
245
|
-
|
|
236
|
+
File.foreach(file_path, mode: 'rb') do |line|
|
|
237
|
+
line = line.force_encoding('UTF-8').scrub
|
|
238
|
+
begin
|
|
239
|
+
entry = JSON.parse(line.strip)
|
|
240
|
+
rescue JSON::ParserError
|
|
241
|
+
next
|
|
242
|
+
end
|
|
246
243
|
next unless entry.is_a?(Hash) && entry['uuid']
|
|
247
244
|
|
|
248
245
|
if entry['type'] == 'content-replacement'
|
|
@@ -250,13 +247,33 @@ module ClaudeAgentSDK
|
|
|
250
247
|
next
|
|
251
248
|
end
|
|
252
249
|
transcript << entry
|
|
253
|
-
rescue JSON::ParserError
|
|
254
|
-
next
|
|
255
250
|
end
|
|
256
251
|
|
|
257
252
|
[transcript, content_replacements]
|
|
258
253
|
end
|
|
259
254
|
|
|
255
|
+
# Derive a fork title from the source file's head/tail chunks without
|
|
256
|
+
# slurping the entire file. Matches the lookup order used for
|
|
257
|
+
# SDKSessionInfo.custom_title / ai_title / first_prompt.
|
|
258
|
+
def derive_fork_title(file_path, file_size)
|
|
259
|
+
buf_size = [Sessions::LITE_READ_BUF_SIZE, file_size].min
|
|
260
|
+
File.open(file_path, 'rb') do |f|
|
|
261
|
+
head = (f.read(buf_size) || '').force_encoding('UTF-8').scrub
|
|
262
|
+
tail = if file_size > Sessions::LITE_READ_BUF_SIZE
|
|
263
|
+
f.seek(-buf_size, IO::SEEK_END)
|
|
264
|
+
(f.read(buf_size) || '').force_encoding('UTF-8').scrub
|
|
265
|
+
else
|
|
266
|
+
head
|
|
267
|
+
end
|
|
268
|
+
Sessions.extract_json_string_field(tail, 'customTitle', last: true) ||
|
|
269
|
+
Sessions.extract_json_string_field(head, 'customTitle', last: true) ||
|
|
270
|
+
Sessions.extract_json_string_field(tail, 'aiTitle', last: true) ||
|
|
271
|
+
Sessions.extract_json_string_field(head, 'aiTitle', last: true) ||
|
|
272
|
+
Sessions.extract_first_prompt_from_head(head) ||
|
|
273
|
+
'Forked session'
|
|
274
|
+
end
|
|
275
|
+
end
|
|
276
|
+
|
|
260
277
|
# Build a single forked entry with remapped UUIDs.
|
|
261
278
|
def build_forked_entry(original, index, total, uuid_mapping, by_uuid,
|
|
262
279
|
forked_session_id, source_session_id, now)
|
|
@@ -396,7 +413,7 @@ module ClaudeAgentSDK
|
|
|
396
413
|
|
|
397
414
|
private_class_method :find_session_file_with_dir,
|
|
398
415
|
:find_in_directory, :try_project_dir, :find_in_all_projects,
|
|
399
|
-
:parse_fork_transcript, :build_forked_entry, :resolve_parent_uuid,
|
|
416
|
+
:parse_fork_transcript, :derive_fork_title, :build_forked_entry, :resolve_parent_uuid,
|
|
400
417
|
:append_to_session, :append_to_session_in_directory,
|
|
401
418
|
:append_to_session_global, :try_append, :sanitize_unicode, :unicode_category
|
|
402
419
|
end
|
|
@@ -1,9 +1,8 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require 'English'
|
|
4
3
|
require 'json'
|
|
4
|
+
require 'open3'
|
|
5
5
|
require 'pathname'
|
|
6
|
-
require 'shellwords'
|
|
7
6
|
|
|
8
7
|
module ClaudeAgentSDK
|
|
9
8
|
# Session info returned by list_sessions
|
|
@@ -59,11 +58,13 @@ module ClaudeAgentSDK
|
|
|
59
58
|
|
|
60
59
|
module_function
|
|
61
60
|
|
|
62
|
-
# Match TypeScript's simpleHash: signed 32-bit integer, base-36 output
|
|
61
|
+
# Match TypeScript's simpleHash: signed 32-bit integer, base-36 output.
|
|
62
|
+
# JS's charCodeAt returns UTF-16 code units, so supplementary characters
|
|
63
|
+
# (emoji, CJK extensions) emit two surrogate code units — iterate over
|
|
64
|
+
# UTF-16LE shorts instead of Unicode codepoints to preserve parity.
|
|
63
65
|
def simple_hash(str)
|
|
64
66
|
h = 0
|
|
65
|
-
str.
|
|
66
|
-
char_code = ch.ord
|
|
67
|
+
str.encode('UTF-16LE').unpack('v*').each do |char_code|
|
|
67
68
|
h = ((h << 5) - h + char_code) & 0xFFFFFFFF
|
|
68
69
|
h -= 0x100000000 if h >= 0x80000000
|
|
69
70
|
end
|
|
@@ -306,6 +307,7 @@ module ClaudeAgentSDK
|
|
|
306
307
|
# @param include_worktrees [Boolean] Whether to include git worktree sessions
|
|
307
308
|
# @return [Array<SDKSessionInfo>] Sessions sorted by last_modified descending
|
|
308
309
|
def list_sessions(directory: nil, limit: nil, offset: 0, include_worktrees: true)
|
|
310
|
+
offset ||= 0
|
|
309
311
|
sessions = if directory
|
|
310
312
|
list_sessions_for_directory(directory, include_worktrees)
|
|
311
313
|
else
|
|
@@ -354,6 +356,8 @@ module ClaudeAgentSDK
|
|
|
354
356
|
def get_session_messages(session_id:, directory: nil, limit: nil, offset: 0)
|
|
355
357
|
return [] unless session_id.match?(UUID_RE)
|
|
356
358
|
|
|
359
|
+
offset ||= 0
|
|
360
|
+
|
|
357
361
|
file_path = find_session_file(session_id, directory)
|
|
358
362
|
return [] unless file_path && File.exist?(file_path)
|
|
359
363
|
|
|
@@ -439,8 +443,8 @@ module ClaudeAgentSDK
|
|
|
439
443
|
end
|
|
440
444
|
|
|
441
445
|
def detect_worktrees(path)
|
|
442
|
-
output =
|
|
443
|
-
return [path] unless
|
|
446
|
+
output, _err, status = Open3.capture3('git', '-C', path, 'worktree', 'list', '--porcelain')
|
|
447
|
+
return [path] unless status.success?
|
|
444
448
|
|
|
445
449
|
paths = output.lines.filter_map do |line|
|
|
446
450
|
line.strip.delete_prefix('worktree ') if line.start_with?('worktree ')
|
|
@@ -12,6 +12,7 @@ module ClaudeAgentSDK
|
|
|
12
12
|
class SubprocessCLITransport < Transport
|
|
13
13
|
DEFAULT_MAX_BUFFER_SIZE = 1024 * 1024 # 1MB buffer limit
|
|
14
14
|
MINIMUM_CLAUDE_CODE_VERSION = '2.0.0'
|
|
15
|
+
EXTRA_ARG_FLAG_RE = /\A[a-z0-9][a-z0-9-]*\z/
|
|
15
16
|
|
|
16
17
|
def initialize(options_or_prompt = nil, options = nil)
|
|
17
18
|
# Support both new single-arg form and legacy two-arg form
|
|
@@ -162,15 +163,21 @@ module ClaudeAgentSDK
|
|
|
162
163
|
build_plugins_args(cmd)
|
|
163
164
|
|
|
164
165
|
# Setting sources
|
|
165
|
-
|
|
166
|
-
|
|
166
|
+
if @options.setting_sources
|
|
167
|
+
cmd.concat(['--setting-sources', @options.setting_sources.join(',')])
|
|
168
|
+
end
|
|
167
169
|
|
|
168
170
|
# Extra args
|
|
169
171
|
@options.extra_args.each do |flag, value|
|
|
172
|
+
flag_str = flag.to_s
|
|
173
|
+
unless EXTRA_ARG_FLAG_RE.match?(flag_str)
|
|
174
|
+
raise ArgumentError, "Invalid extra_args flag name: #{flag.inspect} (expected lowercase kebab-case)"
|
|
175
|
+
end
|
|
176
|
+
|
|
170
177
|
if value.nil?
|
|
171
|
-
cmd << "--#{
|
|
178
|
+
cmd << "--#{flag_str}"
|
|
172
179
|
else
|
|
173
|
-
cmd.concat(["--#{
|
|
180
|
+
cmd.concat(["--#{flag_str}", value.to_s])
|
|
174
181
|
end
|
|
175
182
|
end
|
|
176
183
|
|
|
@@ -339,15 +346,12 @@ module ClaudeAgentSDK
|
|
|
339
346
|
# EOF on stdin. Without this grace period, SIGTERM can interrupt the
|
|
340
347
|
# write and cause the last assistant message to be lost.
|
|
341
348
|
begin
|
|
342
|
-
if @process.alive?
|
|
343
|
-
# Give the process up to 5 seconds to exit on its own
|
|
344
|
-
Timeout.timeout(5) { @process.value }
|
|
345
|
-
end
|
|
349
|
+
wait_process_with_timeout(5) if @process.alive?
|
|
346
350
|
rescue Timeout::Error
|
|
347
351
|
# Graceful shutdown timed out — send SIGTERM
|
|
348
352
|
begin
|
|
349
353
|
Process.kill('TERM', @process.pid)
|
|
350
|
-
|
|
354
|
+
wait_process_with_timeout(2)
|
|
351
355
|
rescue Timeout::Error
|
|
352
356
|
# SIGTERM didn't work — force kill
|
|
353
357
|
begin
|
|
@@ -378,6 +382,22 @@ module ClaudeAgentSDK
|
|
|
378
382
|
@exit_error = nil
|
|
379
383
|
end
|
|
380
384
|
|
|
385
|
+
# Wait for the spawned process to exit, up to +timeout_seconds+. Polls
|
|
386
|
+
# @process.alive? rather than using stdlib Timeout.timeout, which raises
|
|
387
|
+
# across threads via Thread#raise and corrupts Async fiber-scheduler state
|
|
388
|
+
# (close is always called inside an Async task). Yields to the current
|
|
389
|
+
# Async task when one is active so the reactor keeps running.
|
|
390
|
+
def wait_process_with_timeout(timeout_seconds)
|
|
391
|
+
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout_seconds
|
|
392
|
+
task = defined?(Async::Task) ? Async::Task.current? : nil
|
|
393
|
+
while @process.alive?
|
|
394
|
+
raise Timeout::Error if Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
|
|
395
|
+
|
|
396
|
+
task ? task.sleep(0.05) : sleep(0.05)
|
|
397
|
+
end
|
|
398
|
+
@process.value
|
|
399
|
+
end
|
|
400
|
+
|
|
381
401
|
def write(data)
|
|
382
402
|
raise CLIConnectionError, 'ProcessTransport is not ready for writing' unless @ready && @stdin
|
|
383
403
|
raise CLIConnectionError, "Cannot write to terminated process" if @process && !@process.alive?
|
data/lib/claude_agent_sdk.rb
CHANGED
|
@@ -13,6 +13,7 @@ require_relative 'claude_agent_sdk/sdk_mcp_server'
|
|
|
13
13
|
require_relative 'claude_agent_sdk/streaming'
|
|
14
14
|
require_relative 'claude_agent_sdk/sessions'
|
|
15
15
|
require_relative 'claude_agent_sdk/session_mutations'
|
|
16
|
+
require_relative 'claude_agent_sdk/fiber_boundary'
|
|
16
17
|
require 'async'
|
|
17
18
|
require 'securerandom'
|
|
18
19
|
|
|
@@ -28,9 +29,12 @@ module ClaudeAgentSDK
|
|
|
28
29
|
end
|
|
29
30
|
|
|
30
31
|
# Safely call a method on each observer, suppressing any errors.
|
|
32
|
+
# Each observer is invoked through FiberBoundary so that user code runs
|
|
33
|
+
# on a plain thread (no Fiber scheduler) even when called from inside
|
|
34
|
+
# the SDK's Async reactor.
|
|
31
35
|
def self.notify_observers(observers, method, *args)
|
|
32
36
|
observers.each do |obs|
|
|
33
|
-
obs.send(method, *args)
|
|
37
|
+
FiberBoundary.invoke { obs.send(method, *args) }
|
|
34
38
|
rescue StandardError
|
|
35
39
|
nil
|
|
36
40
|
end
|
|
@@ -230,12 +234,14 @@ module ClaudeAgentSDK
|
|
|
230
234
|
end
|
|
231
235
|
end
|
|
232
236
|
|
|
233
|
-
# Read and yield messages from the query handler (filters out control messages)
|
|
237
|
+
# Read and yield messages from the query handler (filters out control messages).
|
|
238
|
+
# User block is invoked through FiberBoundary so ActiveRecord / PG calls
|
|
239
|
+
# inside it don't see the async gem's Fiber scheduler.
|
|
234
240
|
query_handler.receive_messages do |data|
|
|
235
241
|
message = MessageParser.parse(data)
|
|
236
242
|
if message
|
|
237
243
|
ClaudeAgentSDK.notify_observers(resolved_observers, :on_message, message)
|
|
238
|
-
block.call(message)
|
|
244
|
+
FiberBoundary.invoke { block.call(message) }
|
|
239
245
|
end
|
|
240
246
|
end
|
|
241
247
|
ensure
|
|
@@ -406,7 +412,7 @@ module ClaudeAgentSDK
|
|
|
406
412
|
message = MessageParser.parse(data)
|
|
407
413
|
if message
|
|
408
414
|
ClaudeAgentSDK.notify_observers(@resolved_observers, :on_message, message)
|
|
409
|
-
block.call(message)
|
|
415
|
+
FiberBoundary.invoke { block.call(message) }
|
|
410
416
|
end
|
|
411
417
|
end
|
|
412
418
|
end
|
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.
|
|
4
|
+
version: 0.15.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Community Contributors
|
|
8
8
|
bindir: bin
|
|
9
9
|
cert_chain: []
|
|
10
|
-
date: 2026-04-
|
|
10
|
+
date: 2026-04-21 00:00:00.000000000 Z
|
|
11
11
|
dependencies:
|
|
12
12
|
- !ruby/object:Gem::Dependency
|
|
13
13
|
name: async
|
|
@@ -106,6 +106,7 @@ files:
|
|
|
106
106
|
- lib/claude_agent_sdk.rb
|
|
107
107
|
- lib/claude_agent_sdk/configuration.rb
|
|
108
108
|
- lib/claude_agent_sdk/errors.rb
|
|
109
|
+
- lib/claude_agent_sdk/fiber_boundary.rb
|
|
109
110
|
- lib/claude_agent_sdk/instrumentation.rb
|
|
110
111
|
- lib/claude_agent_sdk/instrumentation/otel.rb
|
|
111
112
|
- lib/claude_agent_sdk/message_parser.rb
|