claude-agent-sdk 0.33.0 → 0.34.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.
@@ -373,7 +373,7 @@ module ClaudeAgentSDK
373
373
  end
374
374
 
375
375
  # `--thinking-display` toggles between `"summarized"` (visible thinking
376
- # text) and `"omitted"` (empty thinking, signature only). Opus 4.7 defaults
376
+ # text) and `"omitted"` (empty thinking, signature only). Current models default
377
377
  # to `"omitted"`, so pass `display: "summarized"` to see reasoning.
378
378
  def append_thinking_display(cmd, display)
379
379
  return if display.nil?
@@ -444,8 +444,12 @@ module ClaudeAgentSDK
444
444
  # Typed Mcp*ServerConfig objects serialize via their wire hash —
445
445
  # without this they'd JSON-stringify as "#<...>" via to_s.
446
446
  config = config.to_h if config.is_a?(Type)
447
- servers_for_cli[name] = if config.is_a?(Hash) && config[:type] == "sdk"
448
- config.except(:instance)
447
+ # Same recognition rule as ClaudeAgentSDK.extract_sdk_mcp_servers:
448
+ # either key style (and a Symbol :sdk type). The live instance is
449
+ # never serialized — JSON.generate would raise on it or leak its
450
+ # #to_s onto the command line.
451
+ servers_for_cli[name] = if config.is_a?(Hash) && (config[:type] || config["type"]).to_s == "sdk"
452
+ config.except(:instance, "instance")
449
453
  else
450
454
  config
451
455
  end
@@ -506,6 +510,10 @@ module ClaudeAgentSDK
506
510
  end
507
511
 
508
512
  def load_settings_file(path)
513
+ # Match the CLI's path resolution when --settings is passed through
514
+ # without a sandbox merge: the subprocess runs in options.cwd.
515
+ # Do not expand lexically: symlink/.. must resolve through the filesystem.
516
+ path = File.join(@options.cwd&.to_s || Dir.pwd, path) unless File.absolute_path?(path)
509
517
  # Missing file: warn and continue with sandbox-only settings (Python
510
518
  # parity: logger.warning("Settings file not found: ...") and an empty
511
519
  # settings object). Raising here turned a misconfiguration the CLI
@@ -1,5 +1,9 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ # default_options= copies through Type.deep_dup_for_options: keep this file
4
+ # loadable on its own (require 'claude_agent_sdk/configuration').
5
+ require_relative 'types'
6
+
3
7
  module ClaudeAgentSDK
4
8
  # Configuration class for setting default options
5
9
  #
@@ -28,11 +32,59 @@ module ClaudeAgentSDK
28
32
  # prompt: "Hello!",
29
33
  # options: ClaudeAgentOptions.new(model: 'opus') # overrides default
30
34
  # )
35
+ #
36
+ # Assignment stores a frozen deep copy of the Hash (containers, typed
37
+ # option values such as SandboxSettings, and mutable Strings are copied;
38
+ # procs, observers, SDK MCP server instances and store adapters keep
39
+ # identity). To change the defaults, assign a new Hash — in-place mutation
40
+ # of the stored one (including `<<` on one of its Strings) raises
41
+ # FrozenError, and later changes to the Hash or Strings you passed in have
42
+ # no effect.
31
43
  class Configuration
32
- attr_accessor :default_options
44
+ # The configured defaults: a frozen snapshot (see class docs).
45
+ #
46
+ # @return [Hash]
47
+ attr_reader :default_options
48
+
49
+ EMPTY_DEFAULTS = {}.freeze
50
+ private_constant :EMPTY_DEFAULTS
33
51
 
34
52
  def initialize
35
- @default_options = {}
53
+ @default_options = EMPTY_DEFAULTS
54
+ end
55
+
56
+ # The defaults are read at request time by every ClaudeAgentOptions.new
57
+ # (merge_with_defaults) with no lock, possibly from many threads at once.
58
+ # A live, caller-owned Hash made that a race: an in-place write from one
59
+ # thread while another iterated the merge raised "can't add a new key
60
+ # into hash during iteration" or tore the read. Storing a private frozen
61
+ # snapshot removes the shared mutable state instead of guarding it — the
62
+ # ivar swap is atomic, a reader only ever sees a complete Hash, and an
63
+ # in-place write fails loudly. Only the copy is frozen: the caller's
64
+ # Hash and objects, and identity leaves, are left untouched.
65
+ #
66
+ # @param value [Hash, nil] nil clears the defaults
67
+ def default_options=(value)
68
+ @default_options = value.nil? ? EMPTY_DEFAULTS : deep_freeze(Type.deep_dup_for_options(value))
69
+ end
70
+
71
+ private
72
+
73
+ # Mirrors Type.deep_dup_for_options' recursion: freeze the containers,
74
+ # option value copies and String copies it produced (and their nested
75
+ # state), never a leaf it returned by identity — freezing an
76
+ # SdkMcpServer or a store adapter would break it. A String reaching here
77
+ # is either the copier's own copy or was already frozen, so freezing it
78
+ # never touches a caller's mutable String.
79
+ def deep_freeze(value)
80
+ case value
81
+ when Hash then value.each_value { |v| deep_freeze(v) }
82
+ when Array then value.each { |v| deep_freeze(v) }
83
+ when Type::OptionValue then value.instance_variables.each { |ivar| deep_freeze(value.instance_variable_get(ivar)) }
84
+ when String then nil # nothing nested; fall through to freeze the copy
85
+ else return value
86
+ end
87
+ value.freeze
36
88
  end
37
89
  end
38
90
 
@@ -129,6 +129,15 @@ module ClaudeAgentSDK
129
129
 
130
130
  data.key?(key) ? data[key] : data[key.to_s]
131
131
  end
132
+
133
+ # The +api_error_status+ field narrowed to Integer-or-nil — the one
134
+ # narrowing both #api_error_status and .error_text read, so a "500"
135
+ # String can neither show up in the message nor go missing from the
136
+ # accessor on its own.
137
+ def api_error_status(data)
138
+ status = field(data, :api_error_status)
139
+ status.is_a?(Integer) ? status : nil
140
+ end
132
141
  end
133
142
  private_constant :Payload
134
143
 
@@ -158,7 +167,7 @@ module ClaudeAgentSDK
158
167
  subtype = Payload.field(data, :subtype)
159
168
  return subtype if subtype.is_a?(String) && !subtype.empty? && subtype != 'success'
160
169
 
161
- status = Payload.field(data, :api_error_status)
170
+ status = Payload.api_error_status(data)
162
171
  return "API error (HTTP #{status})" unless status.nil?
163
172
 
164
173
  'unknown error'
@@ -174,8 +183,7 @@ module ClaudeAgentSDK
174
183
  @errors = Payload.normalize_errors(Payload.field(data, :errors))
175
184
  result = Payload.field(data, :result)
176
185
  @result = result.is_a?(String) ? result : nil
177
- status = Payload.field(data, :api_error_status)
178
- @api_error_status = status.is_a?(Integer) ? status : nil
186
+ @api_error_status = Payload.api_error_status(data)
179
187
  reason = Payload.field(data, :terminal_reason)
180
188
  @terminal_reason = reason.is_a?(String) ? reason : nil
181
189
  session_id = Payload.field(data, :session_id)
@@ -52,7 +52,10 @@ module ClaudeAgentSDK
52
52
  # reactor; a declared-inline adapter accepts that a scheduler-opaque
53
53
  # blocking call would stall it AND escape the cooperative deadline.
54
54
  # Outside a reactor the hard bound applies even to inline-declared
55
- # adapters — the timeout guarantee is never lost.
55
+ # adapters — the timeout guarantee is never lost. Even inside one, the
56
+ # cooperative deadline only bounds the cancellation request: cleanup in
57
+ # the cancelled block's ensure runs unbounded afterwards (see
58
+ # .with_cooperative_timeout).
56
59
  #
57
60
  # The thread hop severs `break`/`return`/`next` from the surrounding method,
58
61
  # so SDK loops yielding user callbacks must keep loop control outside the
@@ -141,6 +144,19 @@ module ClaudeAgentSDK
141
144
 
142
145
  module_function
143
146
 
147
+ # Capture only the optional OTel context before crossing a fiber/thread
148
+ # boundary. OTel keeps its current context fiber-local; copying generic
149
+ # thread locals would also copy unsafe connection/request state. The
150
+ # returned block activates this context on its destination and restores
151
+ # the destination's prior context even when the operation raises.
152
+ # @api private
153
+ def capture_otel_context(&block)
154
+ return block unless defined?(OpenTelemetry::Context) && OpenTelemetry::Context.respond_to?(:current)
155
+
156
+ context = OpenTelemetry::Context.current
157
+ proc { |*args| OpenTelemetry::Context.with_current(context) { block.call(*args) } }
158
+ end
159
+
144
160
  # Run the given block on a plain thread when a Fiber scheduler is active.
145
161
  # Returns the block's value. Exceptions propagate to the caller.
146
162
  #
@@ -199,8 +215,15 @@ module ClaudeAgentSDK
199
215
  return with_cooperative_timeout(task, timeout, on_timeout: expired) { body.call }
200
216
  end
201
217
 
202
- thread = Thread.new(&body)
203
- thread.report_on_exception = false
218
+ work = capture_otel_context(&body)
219
+ thread = Thread.new do
220
+ # The caller re-raises the failure via #value, so the default report
221
+ # would be a duplicate stderr dump. Set as the thread's FIRST
222
+ # statement: assigning it from the caller after Thread.new races a
223
+ # body that raises before the caller gets scheduled again.
224
+ Thread.current.report_on_exception = false
225
+ work.call
226
+ end
204
227
  return thread.value if timeout.nil?
205
228
  raise JoinTimeout, "timed out after #{timeout}s" unless thread.join(timeout)
206
229
 
@@ -227,6 +250,22 @@ module ClaudeAgentSDK
227
250
  # Wrapper composition is the caller's choice — +block+ runs verbatim
228
251
  # inside the timeout scope (.invoke composes the callback wrapper into
229
252
  # its body beforehand; the hook path composes it inside the block).
253
+ #
254
+ # CONTRACT: the deadline bounds the cancellation REQUEST, not the
255
+ # block's completion (issue #71). The cancellation is delivered exactly
256
+ # once, at the block's next suspension point; from there the block's
257
+ # rescue/ensure clauses run to completion on the reactor fiber with no
258
+ # further deadline, and +on_timeout+ is raised only after they return.
259
+ # Fiber-aware cleanup (scheduler-visible IO, sleep, Async primitives)
260
+ # delays just this callback and whoever awaits it — siblings keep
261
+ # running. Scheduler-opaque cleanup — file fsync, non-fiber-aware
262
+ # drivers, a GVL-holding C extension — stalls the whole reactor for its
263
+ # duration, and no deadline can interrupt it. Deliberately not "fixed":
264
+ # a live fiber stack cannot be migrated to a thread, and a second
265
+ # deadline could only interrupt cooperative cleanup (abandoning locks /
266
+ # transactions) while still not touching opaque blocking. Callers that
267
+ # need a bounded wait use :thread scheduling (hard Thread#join bound);
268
+ # inline callbacks keep their cleanup fiber-aware.
230
269
  # @api private
231
270
  def with_cooperative_timeout(task, timeout, on_timeout:, &block)
232
271
  cancellation = Class.new(InlineCancellation)
@@ -53,6 +53,8 @@ module ClaudeAgentSDK
53
53
  @first_user_input = nil # first user prompt of the current trace
54
54
  @pending_prompt = nil # prompt that belongs to the NEXT trace (see on_user_prompt)
55
55
  @last_assistant_text = nil # capture last assistant text for trace output
56
+ @cost_session_id = nil
57
+ @last_total_cost_usd = nil
56
58
  end
57
59
 
58
60
  def on_user_prompt(prompt)
@@ -116,6 +118,8 @@ module ClaudeAgentSDK
116
118
  # end_trace/supersede resets by design — but the session is over now,
117
119
  # and a reused observer must not leak it into the next session.
118
120
  @pending_prompt = nil
121
+ @cost_session_id = nil
122
+ @last_total_cost_usd = nil
119
123
  end
120
124
 
121
125
  private
@@ -237,10 +241,11 @@ module ClaudeAgentSDK
237
241
  # Set trace output (last assistant response — shown in Langfuse UI)
238
242
  # ResultMessage.result has the final text; fall back to last tracked assistant text
239
243
  trace_output = message.result || @last_assistant_text
244
+ cost = cost_since_last_result(message)
240
245
 
241
246
  attrs = {
242
247
  # gen_ai conventions
243
- 'gen_ai.usage.cost' => message.total_cost_usd,
248
+ 'gen_ai.usage.cost' => cost,
244
249
  # OpenInference conventions (Langfuse maps these to usage/cost);
245
250
  # prompt includes cache tokens so prompt_details.* are true subsets
246
251
  'llm.token_count.prompt' => prompt_tokens,
@@ -249,7 +254,7 @@ module ClaudeAgentSDK
249
254
  # OpenInference prompt-cache breakdown (cache_read/cache_write details)
250
255
  'llm.token_count.prompt_details.cache_read' => cache_read_tokens,
251
256
  'llm.token_count.prompt_details.cache_write' => cache_creation_tokens,
252
- 'llm.cost.total' => message.total_cost_usd,
257
+ 'llm.cost.total' => cost,
253
258
  # Trace output (Langfuse shows this in the trace detail view)
254
259
  'output.value' => truncate(trace_output),
255
260
  # Session metadata
@@ -270,6 +275,20 @@ module ClaudeAgentSDK
270
275
  reset_session_buffers
271
276
  end
272
277
 
278
+ # Results carry cumulative cost, but each init/result pair gets its
279
+ # own span. Keep this baseline across trace resets, not across close.
280
+ # /clear changes the session ID; a decreasing counter also starts a
281
+ # new baseline. Missing costs leave the last known total intact.
282
+ def cost_since_last_result(message)
283
+ total = message.total_cost_usd
284
+ return if total.nil?
285
+
286
+ previous = @last_total_cost_usd if @cost_session_id == message.session_id
287
+ @cost_session_id = message.session_id
288
+ @last_total_cost_usd = total
289
+ previous && total >= previous ? total - previous : total
290
+ end
291
+
273
292
  def start_tool_span(block)
274
293
  return unless @root_context
275
294
 
@@ -6,6 +6,7 @@ require 'async'
6
6
  require 'async/queue'
7
7
  require 'async/condition'
8
8
  require 'securerandom'
9
+ require 'timeout'
9
10
  require_relative 'transport'
10
11
  require_relative 'errors'
11
12
  require_relative 'cancellation_signal'
@@ -89,6 +90,7 @@ module ClaudeAgentSDK
89
90
  @next_callback_id = 0
90
91
  @request_counter = 0
91
92
  @request_counter_mutex = Mutex.new
93
+ @control_stream_error = nil
92
94
  @inflight_control_request_tasks = {}
93
95
  @callback_request_signals = {}
94
96
 
@@ -239,7 +241,8 @@ module ClaudeAgentSDK
239
241
  raise CLIConnectionError, 'Query#start must be called inside an Async{} block (e.g. wrap Client#connect in Async{...})' unless parent
240
242
 
241
243
  @owning_scheduler = Fiber.scheduler
242
- @task = parent.async { read_messages }
244
+ # Async child fibers do not inherit OTel's fiber-local current context.
245
+ @task = parent.async(&FiberBoundary.capture_otel_context { read_messages })
243
246
  # Reactor-side agent for #close calls arriving from foreign threads
244
247
  # (FiberBoundary callbacks, plain user threads): Async::Task#stop needs
245
248
  # the owning thread's Fiber.scheduler, so the off-thread caller hands the
@@ -247,7 +250,7 @@ module ClaudeAgentSDK
247
250
  # alive, and is stopped automatically when the parent task finishes.
248
251
  # One-shot: after serving a close it is done; a reactor-side close wakes
249
252
  # it via @close_requests.close (pop -> nil) so it exits without serving.
250
- @close_watcher = parent.async(transient: true) do
253
+ @close_watcher = parent.async(transient: true, &FiberBoundary.capture_otel_context do
251
254
  if (reply = @close_requests.pop)
252
255
  begin
253
256
  close
@@ -255,7 +258,7 @@ module ClaudeAgentSDK
255
258
  reply << true
256
259
  end
257
260
  end
258
- end
261
+ end)
259
262
  end
260
263
 
261
264
  # Spawn a child task that is stopped by #close (mirrors the Python SDK's
@@ -272,7 +275,7 @@ module ClaudeAgentSDK
272
275
  parent = Async::Task.current?
273
276
  raise CLIConnectionError, 'Query#spawn_task must be called inside an Async{} block' unless parent
274
277
 
275
- task = parent.async(&block)
278
+ task = parent.async(&FiberBoundary.capture_otel_context(&block))
276
279
  @child_tasks << task
277
280
  task
278
281
  end
@@ -335,7 +338,7 @@ module ClaudeAgentSDK
335
338
  # Spawn as a child of the current task so @task.stop cascades and
336
339
  # nothing keeps running after close; bare Async do may root at the
337
340
  # reactor and leak past shutdown.
338
- handler_task = Async::Task.current.async do
341
+ handler_task = Async::Task.current.async(&FiberBoundary.capture_otel_context do
339
342
  begin
340
343
  handle_control_request(message)
341
344
  ensure
@@ -345,7 +348,7 @@ module ClaudeAgentSDK
345
348
  @inflight_control_request_tasks.delete(request_id)
346
349
  end
347
350
  end
348
- end
351
+ end)
349
352
  # A handler that never suspends (MCP metadata, unsupported-subtype
350
353
  # error path) already ran to completion inside the async{} above —
351
354
  # its ensure-delete fired before this insert, so registering it here
@@ -413,24 +416,21 @@ module ClaudeAgentSDK
413
416
  e
414
417
  end
415
418
 
416
- # Unblock pending control requests (e.g., initialize) so callers don't
417
- # hang until timeout. Computed AFTER the replacement above so they get
418
- # the same enriched error the message stream does: a refused resume (a
419
- # nonexistent session, a failed --resume-drops-turn guard) is reported
420
- # by the CLI as an error result followed by exit 1 *before* it answers
421
- # the SDK's `initialize`, so signaling the raw `e` here handed that
422
- # in-flight request "Command failed with exit code 1" and discarded the
423
- # real reason (Python #1198).
424
- # INVARIANT: store the result before signaling — senders check the slot
425
- # before waiting (level-trigger).
426
- @pending_control_responses.dup.each do |request_id, condition|
427
- @pending_control_results[request_id] ||= error
428
- condition.signal
429
- end
430
-
431
419
  # Put error in queue so iterators can handle it
432
420
  @message_queue.enqueue({ type: 'error', error: error })
433
421
  ensure
422
+ # EOF is terminal for control requests even when the message stream
423
+ # ends successfully. Serialize terminal publication with registration:
424
+ # every sender is either in this snapshot or rejected before writing.
425
+ # Preserve enriched ResultError from the rescue path (Python #1198).
426
+ waiters = @request_counter_mutex.synchronize do
427
+ @control_stream_error = error || CLIConnectionError.new('Control stream ended')
428
+ @pending_control_responses.dup
429
+ end
430
+ waiters.each do |request_id, condition|
431
+ @pending_control_results[request_id] ||= @control_stream_error
432
+ condition.signal
433
+ end
434
434
  # A callback can no longer be answered after EOF, transport failure,
435
435
  # or reactor cancellation. Wake cooperative worker-thread callbacks too.
436
436
  @callback_request_signals.dup.each_value(&:cancel)
@@ -1022,18 +1022,19 @@ module ClaudeAgentSDK
1022
1022
  # RuntimeError; the eventual response dropped by the key? guard).
1023
1023
  task = Async::Task.current?
1024
1024
 
1025
- # Generate unique request ID (callbacks may issue requests from
1026
- # worker threads concurrently with the reactor)
1025
+ # Reactor callers wait on an Async::Condition; worker-thread callers
1026
+ # on a ThreadWaiter. Register atomically with the terminal-state check
1027
+ # so EOF cannot strand a sender that missed the final broadcast.
1028
+ waiter = task ? Async::Condition.new : ThreadWaiter.new
1027
1029
  request_id = @request_counter_mutex.synchronize do
1030
+ raise @control_stream_error if @control_stream_error
1031
+
1028
1032
  @request_counter += 1
1029
- "req_#{@request_counter}_#{SecureRandom.hex(4)}"
1033
+ id = "req_#{@request_counter}_#{SecureRandom.hex(4)}"
1034
+ @pending_control_responses[id] = waiter
1035
+ id
1030
1036
  end
1031
1037
 
1032
- # Reactor callers wait on an Async::Condition; worker-thread callers
1033
- # on a ThreadWaiter. Registration must precede the write.
1034
- waiter = task ? Async::Condition.new : ThreadWaiter.new
1035
- @pending_control_responses[request_id] = waiter
1036
-
1037
1038
  control_request = {
1038
1039
  type: 'control_request',
1039
1040
  request_id: request_id,
@@ -1041,20 +1042,18 @@ module ClaudeAgentSDK
1041
1042
  request: request
1042
1043
  }
1043
1044
 
1044
- writeln(JSON.generate(control_request))
1045
-
1046
- begin
1047
- await_control_response(request_id, waiter, task, timeout_seconds, request[:subtype])
1048
- result = @pending_control_results[request_id]
1049
- raise result if result.is_a?(Exception)
1050
-
1051
- result&.[](:response) || {}
1052
- ensure
1053
- # Always evict the entries so a late control_response (after timeout)
1054
- # or an Async::Stop propagating through wait does not leak state.
1055
- @pending_control_responses.delete(request_id)
1056
- @pending_control_results.delete(request_id)
1045
+ await_control_response(request_id, waiter, task, timeout_seconds, request[:subtype]) do
1046
+ writeln(JSON.generate(control_request))
1057
1047
  end
1048
+ result = @pending_control_results[request_id]
1049
+ raise result if result.is_a?(Exception)
1050
+
1051
+ result&.[](:response) || {}
1052
+ ensure
1053
+ # Registration, serialization, write and wait share one cleanup scope.
1054
+ # In particular, failed or cancelled writes never retain a waiter.
1055
+ @pending_control_responses.delete(request_id)
1056
+ @pending_control_results.delete(request_id)
1058
1057
  end
1059
1058
 
1060
1059
  # Level-triggered wait: every signal site stores the result BEFORE
@@ -1068,22 +1067,29 @@ module ClaudeAgentSDK
1068
1067
  # Do NOT reimplement the reactor wait as a nested `Async do ... end.wait`
1069
1068
  # — that spawned a separate task and leaked the pending entries when an
1070
1069
  # Async::Stop propagated through `.wait` before cleanup ran.
1070
+ # The yielded send runs inside the same deadline as the response wait.
1071
1071
  def await_control_response(request_id, waiter, task, timeout_seconds, subtype)
1072
+ expired = -> { ControlRequestTimeoutError.new("Control request timeout: #{subtype}") }
1072
1073
  if task
1073
- begin
1074
- task.with_timeout(timeout_seconds) do
1075
- waiter.wait until @pending_control_results.key?(request_id)
1076
- end
1077
- rescue Async::TimeoutError
1078
- raise ControlRequestTimeoutError, "Control request timeout: #{subtype}"
1074
+ # A non-StandardError deadline escapes the transport's write rescue;
1075
+ # only this deadline is translated, not an outer task's cancellation.
1076
+ FiberBoundary.with_cooperative_timeout(task, timeout_seconds, on_timeout: expired) do
1077
+ yield
1078
+ waiter.wait until @pending_control_results.key?(request_id)
1079
1079
  end
1080
1080
  else
1081
- deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout_seconds
1082
- until @pending_control_results.key?(request_id)
1083
- remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
1084
- raise ControlRequestTimeoutError, "Control request timeout: #{subtype}" if remaining <= 0
1085
-
1086
- waiter.wait(remaining)
1081
+ # Only schedulerless callers use stdlib Timeout. A fresh, private
1082
+ # non-StandardError deadline bypasses transport write rescues without
1083
+ # relabeling a transport's own Timeout::Error or an outer deadline.
1084
+ # Interrupt the caller rather than abandoning a still-writing worker.
1085
+ cancellation = Class.new(Exception) # rubocop:disable Lint/InheritException -- cancellation must bypass transport rescues
1086
+ begin
1087
+ Timeout.timeout(timeout_seconds, cancellation) do
1088
+ yield
1089
+ waiter.wait(nil) until @pending_control_results.key?(request_id)
1090
+ end
1091
+ rescue cancellation
1092
+ raise expired.call
1087
1093
  end
1088
1094
  end
1089
1095
  end
@@ -1195,9 +1201,12 @@ module ClaudeAgentSDK
1195
1201
  # server validates arguments against the tool's inputSchema BEFORE the
1196
1202
  # handler runs and reports validation failures, unknown tools, and
1197
1203
  # handler exceptions as in-band isError results). tools/list,
1198
- # initialize, resources/* and prompts/* stay on the SDK paths — the
1199
- # gem drops annotations/_meta from tools/list and negotiates newer
1200
- # protocol versions.
1204
+ # initialize, resources/* and prompts/* stay on the SDK paths: the
1205
+ # gem's tools/list injects "$schema" and drops `required: []` (and
1206
+ # would advertise the empty fallback schema where the SDK advertises
1207
+ # the user's own), and its initialize negotiates newer protocol
1208
+ # versions and advertises prompts/resources/logging even for a
1209
+ # tools-only server. Annotations/_meta do survive the gem path.
1201
1210
  server.handle_message(message)
1202
1211
  end
1203
1212
 
@@ -1509,6 +1518,53 @@ module ClaudeAgentSDK
1509
1518
  # Final mirror flush BEFORE stopping the read task, so the last turn's
1510
1519
  # entries reach the store. #close on the batcher never raises.
1511
1520
  @transcript_mirror_batcher&.close
1521
+ # A close can be called from inside one of the tasks it stops:
1522
+ # - an inline callback (callback_scheduling: :inline) runs on a
1523
+ # control-request handler task that is a CHILD of the read task,
1524
+ # so the read task's unwind (`stopped!` -> `stop_children`)
1525
+ # cascades straight back into this fiber as Async::Stop from
1526
+ # inside `@task.stop` (issue #81);
1527
+ # - a streaming-input enumerator is iterated ON the reactor inside
1528
+ # a spawn_task child tracked in @child_tasks, so
1529
+ # `@child_tasks.each(&:stop)` stops the CURRENT task — a direct
1530
+ # raise (Async::Task#stop on `current?`), no cascade needed.
1531
+ # Either way the Stop used to unwind close_now before the transport
1532
+ # and @close_requests were closed. `defer_stop` makes both the
1533
+ # cascade and the self-stop set a flag instead of raising
1534
+ # (Async::Task#stop checks the deferral before the current?/deliver
1535
+ # branch), so the whole teardown section runs to completion; the
1536
+ # deferred stop is then raised on exit of the block, after the
1537
+ # invariant "close returned/raised => transport closed, waiters
1538
+ # released, close requests closed" already holds. The caller's task
1539
+ # still ends — it belongs to a stopped tree — so its Client#disconnect
1540
+ # surfaces as Async::Stop (Python parity: a hook that awaits
1541
+ # disconnect() gets CancelledError). Applied ONLY inside the trees of
1542
+ # the tasks being stopped: the reactor-side caller (Client#disconnect
1543
+ # from the connect task, the close watcher) and foreign threads keep
1544
+ # the plain path, unchanged.
1545
+ #
1546
+ # The deferred Stop SUPERSEDES anything the teardown raises: async
1547
+ # raises it from defer_stop's ensure with an explicit `cause:`, so a
1548
+ # transport #close error would vanish from the chain entirely. Warn
1549
+ # before it is lost. (An inline hook's cooperative timeout landing
1550
+ # while the teardown is suspended is superseded the same way; harmless,
1551
+ # the handler is ending anyway.)
1552
+ if (caller_task = task_inside_stopped_trees)
1553
+ caller_task.defer_stop do
1554
+ stop_tasks_and_close_transport
1555
+ rescue StandardError => e
1556
+ warn "Claude SDK: close from inside a stopping task failed during teardown: #{e.class}: #{e.message}"
1557
+ raise
1558
+ end
1559
+ else
1560
+ stop_tasks_and_close_transport
1561
+ end
1562
+ end
1563
+
1564
+ # The teardown section that must run to completion once the read and
1565
+ # child tasks are being stopped — see close_now for why a caller inside
1566
+ # one of their trees wraps it in defer_stop.
1567
+ def stop_tasks_and_close_transport
1512
1568
  # Stop tracked child tasks (e.g. stream_input) before the read task and
1513
1569
  # transport so a parked input stream can never keep the reactor alive
1514
1570
  # (mirrors Python close() cancelling _child_tasks).
@@ -1526,11 +1582,39 @@ module ClaudeAgentSDK
1526
1582
 
1527
1583
  warn "Claude SDK: skipped stopping tasks during off-reactor close: #{e.message}"
1528
1584
  end
1529
- @transport.close
1530
- # Release a still-parked close watcher: pop returns nil and it exits
1531
- # without serving. Any foreign-thread close arriving after this point
1532
- # falls back to a direct close (safe — the fibers are now dead).
1533
- @close_requests.close
1585
+ begin
1586
+ @transport.close
1587
+ ensure
1588
+ # Release a still-parked close watcher: pop returns nil and it exits
1589
+ # without serving. Any foreign-thread close arriving after this point
1590
+ # falls back to a direct close (safe — the fibers are now dead).
1591
+ # In the ensure because transport.close can suspend (process reap)
1592
+ # and a deadline delivered there — e.g. an inline hook's cooperative
1593
+ # timeout, which defer_stop does not cover — must not strand the
1594
+ # watcher; this close has no suspension point of its own.
1595
+ @close_requests.close
1596
+ end
1597
+ end
1598
+
1599
+ # The current Async task when it is one of the tasks close_now stops —
1600
+ # the read task or a tracked child task (stream_input) — or a descendant
1601
+ # of one (a control-request handler running an inline callback); nil
1602
+ # otherwise, including on a foreign thread (no task) and for the close
1603
+ # watcher / connect task, which are siblings of those tasks.
1604
+ def task_inside_stopped_trees
1605
+ task = Async::Task.current?
1606
+ return nil unless task
1607
+
1608
+ roots = [@task, *@child_tasks].compact
1609
+ return nil if roots.empty?
1610
+
1611
+ node = task
1612
+ while node
1613
+ return task if roots.any? { |root| root.equal?(node) }
1614
+
1615
+ node = node.parent
1616
+ end
1617
+ nil
1534
1618
  end
1535
1619
 
1536
1620
  # Hand the close to the reactor and wait for completion. Polls watcher