claude-agent-sdk 0.16.7 → 0.16.8

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.
@@ -30,6 +30,12 @@ module ClaudeAgentSDK
30
30
  @stderr_task = nil
31
31
  @recent_stderr = []
32
32
  @recent_stderr_mutex = Mutex.new
33
+ # Serializes stdin access across the reactor fiber (transport writes
34
+ # from inside Async) and user-callback threads spawned via FiberBoundary
35
+ # (tool handlers / hooks calling Client#query). Without this lock,
36
+ # close can nil @stdin between write's readiness check and the actual
37
+ # @stdin.write call, producing NoMethodError on nil.
38
+ @stdin_mutex = Mutex.new
33
39
  end
34
40
 
35
41
  def find_cli
@@ -148,20 +154,33 @@ module ClaudeAgentSDK
148
154
 
149
155
  record_bounded_stderr(line_str)
150
156
 
151
- # Call stderr callback if provided
152
- @options.stderr&.call(line_str)
157
+ # Per-line isolation: a callback that raises (e.g. user's logger
158
+ # transiently failing) must not poison the rest of the stderr stream.
159
+ # Without this, the first exception terminates the each_line loop and
160
+ # the SDK silently stops capturing stderr for the lifetime of the
161
+ # process. Matches Python SDK v0.2.82 (PR #932).
162
+ begin
163
+ @options.stderr&.call(line_str)
164
+ rescue StandardError
165
+ # Drop the callback error; the line is already in the recent-stderr
166
+ # ring buffer, which is what ProcessError surfaces on non-zero exit.
167
+ end
153
168
 
154
- # Write to debug_stderr file/IO if provided
155
- if @options.debug_stderr
156
- if @options.debug_stderr.respond_to?(:puts)
157
- @options.debug_stderr.puts(line_str)
158
- elsif @options.debug_stderr.is_a?(String)
159
- File.open(@options.debug_stderr, 'a') { |f| f.puts(line_str) }
169
+ # Write to debug_stderr file/IO if provided, also isolated.
170
+ begin
171
+ if @options.debug_stderr
172
+ if @options.debug_stderr.respond_to?(:puts)
173
+ @options.debug_stderr.puts(line_str)
174
+ elsif @options.debug_stderr.is_a?(String)
175
+ File.open(@options.debug_stderr, 'a') { |f| f.puts(line_str) }
176
+ end
160
177
  end
178
+ rescue StandardError
179
+ # Drop debug_stderr write errors so they never interrupt the loop.
161
180
  end
162
181
  end
163
182
  rescue StandardError
164
- # Ignore errors during stderr reading
183
+ # Stream-level error (pipe closed mid-read); the loop naturally ends here.
165
184
  end
166
185
 
167
186
  def drain_stderr_with_accumulation
@@ -191,13 +210,18 @@ module ClaudeAgentSDK
191
210
  end
192
211
  end
193
212
 
194
- # Close streams
195
- begin
196
- @stdin&.close
197
- rescue IOError
198
- # Already closed, ignore
199
- rescue StandardError => e
200
- cleanup_errors << "stdin: #{e.message}"
213
+ # Close stdin under the same lock that guards write — otherwise a
214
+ # concurrent writer (callbacks running on FiberBoundary threads) can
215
+ # see @stdin nilled mid-write and hit NoMethodError on nil.
216
+ @stdin_mutex.synchronize do
217
+ begin
218
+ @stdin&.close
219
+ rescue IOError
220
+ # Already closed, ignore
221
+ rescue StandardError => e
222
+ cleanup_errors << "stdin: #{e.message}"
223
+ end
224
+ @stdin = nil
201
225
  end
202
226
 
203
227
  begin
@@ -251,7 +275,7 @@ module ClaudeAgentSDK
251
275
 
252
276
  @process = nil
253
277
  @stdout = nil
254
- @stdin = nil
278
+ # @stdin already nilled under the mutex above.
255
279
  @stderr = nil
256
280
  @stderr_task = nil
257
281
  @exit_error = nil
@@ -274,13 +298,28 @@ module ClaudeAgentSDK
274
298
  end
275
299
 
276
300
  def write(data)
277
- raise CLIConnectionError, 'ProcessTransport is not ready for writing' unless @ready && @stdin
278
301
  raise CLIConnectionError, "Cannot write to terminated process" if @process && !@process.alive?
279
302
  raise CLIConnectionError, "Cannot write to process that exited with error: #{@exit_error}" if @exit_error
280
303
 
304
+ # Snapshot @stdin under the lock so close() nilling it concurrently is
305
+ # safe, but do the actual blocking IO *outside* the lock. Holding the
306
+ # mutex across @stdin.write would let a full pipe buffer block the
307
+ # writer indefinitely and block close() (which also needs the lock)
308
+ # from killing the subprocess — a hang on disconnect.
309
+ #
310
+ # If close() runs while we are inside the IO call, it will close the
311
+ # underlying stream and Ruby raises IOError("stream closed in another
312
+ # thread") inside @stdin.write — the rescue below converts that into a
313
+ # standard CLIConnectionError so callers see a clean shutdown error.
314
+ stdin = @stdin_mutex.synchronize do
315
+ raise CLIConnectionError, 'ProcessTransport is not ready for writing' unless @ready && @stdin
316
+
317
+ @stdin
318
+ end
319
+
281
320
  begin
282
- @stdin.write(data)
283
- @stdin.flush
321
+ stdin.write(data)
322
+ stdin.flush
284
323
  rescue StandardError => e
285
324
  @ready = false
286
325
  @exit_error = CLIConnectionError.new("Failed to write to process stdin: #{e}")
@@ -317,6 +356,14 @@ module ClaudeAgentSDK
317
356
  json_line = json_line.strip
318
357
  next if json_line.empty?
319
358
 
359
+ # When no partial JSON is buffered, the next line must start with
360
+ # `{` to be a valid stream-json message. Stray stderr-like text
361
+ # (e.g., debug warnings the CLI occasionally writes to stdout)
362
+ # would otherwise be appended into json_buffer, poisoning every
363
+ # subsequent parse until the buffer overflows. Matches the Python
364
+ # SDK's `if not json_buffer and not json_line.startswith("{")` guard.
365
+ next if json_buffer.empty? && !json_line.start_with?('{')
366
+
320
367
  json_buffer += json_line
321
368
 
322
369
  if json_buffer.bytesize > @max_buffer_size
@@ -344,9 +391,17 @@ module ClaudeAgentSDK
344
391
  # Client disconnected
345
392
  end
346
393
 
347
- # Check process completion
348
- status = @process.value
349
- returncode = status.exitstatus
394
+ # Check process completion. @process may already be nil (close() ran
395
+ # concurrently and reset it) or already waited on (Errno::ECHILD on
396
+ # double-wait). Both are non-fatal — the message loop just exits.
397
+ returncode = nil
398
+ begin
399
+ status = @process&.value
400
+ returncode = status&.exitstatus
401
+ rescue Errno::ECHILD
402
+ # Process was already reaped (e.g., by close()); no exit status to surface.
403
+ returncode = nil
404
+ end
350
405
 
351
406
  if returncode && returncode != 0
352
407
  # Wait briefly for stderr thread to finish draining
@@ -180,12 +180,32 @@ module ClaudeAgentSDK
180
180
  attr_accessor :tool_use_id, :content, :is_error
181
181
  end
182
182
 
183
+ # Server-side tool use (CLI's built-in tools that execute server-side
184
+ # rather than as MCP tools — advisor, web_search, code_execution, etc.).
185
+ # Mirrors Python's `ServerToolUseBlock`.
186
+ class ServerToolUseBlock < Type
187
+ attr_accessor :id, :name, :input
188
+ end
189
+
190
+ # Result of a server-side tool execution. Mirrors Python's
191
+ # `ServerToolResultBlock`.
192
+ class ServerToolResultBlock < Type
193
+ attr_accessor :tool_use_id, :content, :is_error
194
+ end
195
+
183
196
  # Generic content block for types the SDK doesn't explicitly handle (e.g., "document", "image").
184
197
  # Preserves the raw hash data for forward compatibility with newer CLI versions.
185
198
  class UnknownBlock < Type
186
199
  attr_accessor :type, :data
187
200
  end
188
201
 
202
+ # Deferred tool use, emitted on `ResultMessage` when a PreToolUse hook
203
+ # returned `permissionDecision: "defer"`. The session can be resumed later
204
+ # to execute the deferred call. Mirrors Python's `DeferredToolUse`.
205
+ class DeferredToolUse < Type
206
+ attr_accessor :id, :name, :input
207
+ end
208
+
189
209
  # Message Types
190
210
 
191
211
  # User message
@@ -343,7 +363,14 @@ module ClaudeAgentSDK
343
363
  :permission_denials, # Array of { tool_name:, tool_use_id:, tool_input: }
344
364
  :errors, # Array of error strings (present on error subtypes)
345
365
  :uuid,
346
- :fast_mode_state # "off", "cooldown", or "on"
366
+ :fast_mode_state, # "off", "cooldown", or "on"
367
+ :api_error_status # Integer HTTP status (429, 500, 529) on api_error subtype (CLI 2.1.110+)
368
+
369
+ attr_reader :deferred_tool_use # DeferredToolUse, populated when a PreToolUse hook deferred
370
+
371
+ def deferred_tool_use=(value)
372
+ @deferred_tool_use = value.is_a?(Hash) ? DeferredToolUse.from_hash(value) : value
373
+ end
347
374
  end
348
375
 
349
376
  # Stream event for partial message updates
@@ -518,9 +545,16 @@ module ClaudeAgentSDK
518
545
  end
519
546
  end
520
547
 
521
- # Tool permission context
548
+ # Tool permission context delivered to `can_use_tool` callbacks.
549
+ # CLI 2.1.110+ began populating the four pre-formatted display fields
550
+ # (`title`, `display_name`, `description`, `blocked_path`,
551
+ # `decision_reason`) so the SDK consumer can render the same prompt UI
552
+ # the CLI would have shown. Older fields (`signal`, `suggestions`,
553
+ # `tool_use_id`, `agent_id`) remain unchanged.
522
554
  class ToolPermissionContext < Type
523
- attr_accessor :signal, :suggestions, :tool_use_id, :agent_id
555
+ attr_accessor :signal, :suggestions, :tool_use_id, :agent_id,
556
+ :title, :display_name, :description,
557
+ :blocked_path, :decision_reason
524
558
 
525
559
  def initialize(attributes = {})
526
560
  super
@@ -885,9 +919,15 @@ module ClaudeAgentSDK
885
919
  end
886
920
  end
887
921
 
888
- # PostToolUse hook specific output
922
+ # PostToolUse hook specific output.
923
+ #
924
+ # `updated_tool_output` (CLI 2.1.110+) replaces the tool's output entirely
925
+ # — works for any tool, MCP or built-in. `updated_mcp_tool_output` is the
926
+ # legacy MCP-only field that pre-dates the unified one; the CLI still
927
+ # honors it, so both are emitted when set. Mirrors Python's
928
+ # `PostToolUseHookSpecificOutput`.
889
929
  class PostToolUseHookSpecificOutput < Type
890
- attr_accessor :additional_context, :updated_mcp_tool_output
930
+ attr_accessor :additional_context, :updated_mcp_tool_output, :updated_tool_output
891
931
  attr_reader :hook_event_name
892
932
 
893
933
  def initialize(attributes = {})
@@ -898,6 +938,7 @@ module ClaudeAgentSDK
898
938
  def to_h
899
939
  result = { hookEventName: @hook_event_name }
900
940
  result[:additionalContext] = @additional_context if @additional_context
941
+ result[:updatedToolOutput] = @updated_tool_output unless @updated_tool_output.nil?
901
942
  result[:updatedMCPToolOutput] = @updated_mcp_tool_output if @updated_mcp_tool_output
902
943
  result
903
944
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ClaudeAgentSDK
4
- VERSION = '0.16.7'
4
+ VERSION = '0.16.8'
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: claude-agent-sdk
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.16.7
4
+ version: 0.16.8
5
5
  platform: ruby
6
6
  authors:
7
7
  - Community Contributors
@@ -104,6 +104,15 @@ files:
104
104
  - CHANGELOG.md
105
105
  - LICENSE
106
106
  - README.md
107
+ - docs/client.md
108
+ - docs/configuration.md
109
+ - docs/errors.md
110
+ - docs/hooks-and-permissions.md
111
+ - docs/mcp-servers.md
112
+ - docs/observability.md
113
+ - docs/rails.md
114
+ - docs/sessions.md
115
+ - docs/types.md
107
116
  - lib/claude_agent_sdk.rb
108
117
  - lib/claude_agent_sdk/command_builder.rb
109
118
  - lib/claude_agent_sdk/configuration.rb