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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ff012ff95472382ecb3dcd851f47157c74a91342800a07c49f0a1be77e7df98b
4
- data.tar.gz: 71ee7e4a407d412b949aaa91214167780d7f12297d3037c90cb15b9dd8e4f00e
3
+ metadata.gz: 9a037f9cca486fc8d6e94269b9f5f7b686574665ff403457761db764f9f50f07
4
+ data.tar.gz: 01bff518e3306d1fb23b8bc1e9b11b72785a98aecf3636a1096c5522f4720444
5
5
  SHA512:
6
- metadata.gz: 2b63a4e8aea6c5b5c7a536bef496ba62e86414fc347453f7b72f731684b850650a5bce83f94282d0af7c39ad7557e325b7b9b3c25fddfaf2b6b2ba9d9ccc4852
7
- data.tar.gz: a5cb854023470f8a43e791747d084e0b2b8107046ff1992c3198dc75b89e693d7fbe7ec1e6eda2ee2be41d3ab6086695f413ebd7b46aaa7ae46acff4cfd73042
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.14.2] - 2026-04-17
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.14.2'
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 = Async do
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] = task if 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
- response = @can_use_tool.call(
301
- request_data[:tool_name],
302
- request_data[:input],
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
- hook_output = callback.call(
339
- hook_input,
340
- request_data[:tool_use_id],
341
- context
342
- ) unless @hook_callback_timeouts[callback_id]
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
- callback.call(
347
- hook_input,
348
- request_data[:tool_use_id],
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
- result = tool.handler.call(arguments)
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
- result = @tool_def.handler.call(args)
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
- content = File.read(file_path)
112
- raise ArgumentError, "Session #{session_id} has no messages to fork" if content.empty?
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(content, session_id)
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, extracting entries and content-replacement data.
240
- def parse_fork_transcript(content, _session_id)
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
- content.each_line do |line|
245
- entry = JSON.parse(line.strip)
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.each_char do |ch|
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 = `git -C #{Shellwords.escape(path)} worktree list --porcelain 2>/dev/null`
443
- return [path] unless $CHILD_STATUS.success?
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
- sources_value = @options.setting_sources ? @options.setting_sources.join(',') : ''
166
- cmd.concat(['--setting-sources', sources_value])
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 << "--#{flag}"
178
+ cmd << "--#{flag_str}"
172
179
  else
173
- cmd.concat(["--#{flag}", value.to_s])
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
- Timeout.timeout(2) { @process.value }
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?
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ClaudeAgentSDK
4
- VERSION = '0.14.2'
4
+ VERSION = '0.15.1'
5
5
  end
@@ -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.14.2
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-17 00:00:00.000000000 Z
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