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.
@@ -18,6 +18,12 @@ module ClaudeAgentSDK
18
18
  # background flush). This keeps adapter latency off the hot path during
19
19
  # model streaming.
20
20
  #
21
+ # At most ONE background drain task is live at a time. Frames arriving
22
+ # while it is busy stay in the pending buffer, and the drainer picks them up
23
+ # as one coalesced append when its current one completes — so a store slower
24
+ # than the frame rate costs buffered entries, never a task (plus detached
25
+ # batch) per frame, and #enqueue never suspends the read loop.
26
+ #
21
27
  # Adapter failures are retried (MIRROR_APPEND_MAX_ATTEMPTS total) with short
22
28
  # backoff; timeouts are not retried since the in-flight call may still land.
23
29
  # Failures never raise — the local-disk transcript is already durable, so the
@@ -25,17 +31,19 @@ module ClaudeAgentSDK
25
31
  # (which surfaces them as a MirrorErrorMessage). Adapters should dedupe by
26
32
  # entry["uuid"] when present, since a retried batch may overlap a prior write.
27
33
  #
28
- # The semaphore serializes appends, but a #send that exceeds send_timeout is
29
- # abandoned (its worker thread keeps running) and the next drain proceeds, so
30
- # two #append calls for the SAME key can briefly overlap. SessionStore#append
31
- # must be thread-safe per key (see that method's contract). For adapters
32
- # declaring `callback_scheduling -> :inline` the timed-out call is instead
33
- # cancelled cooperatively (interrupted at its next suspension point, ensure
34
- # runs) — but only on the adapter's own fiber; work the adapter offloaded
35
- # may still land, so timeouts are not retried in either mode and the
36
- # cancelled append may remain permanently HALF-applied in the store. The
37
- # drop is surfaced (MirrorErrorMessage, batches_dropped?); the local
38
- # transcript remains the source of truth.
34
+ # The semaphore serializes appends (FIFO, and every drain detaches its batch
35
+ # immediately before queueing on it, so append order matches enqueue order),
36
+ # but a #send that exceeds send_timeout is abandoned (its worker thread keeps
37
+ # running) and the next drain proceeds, so two #append calls for the SAME
38
+ # key can briefly overlap. SessionStore#append must be thread-safe per key
39
+ # (see that method's contract). For adapters declaring
40
+ # `callback_scheduling -> :inline` the timed-out call is instead cancelled
41
+ # cooperatively (interrupted at its next suspension point, ensure runs) —
42
+ # but only on the adapter's own fiber; work the adapter offloaded may still
43
+ # land, so timeouts are not retried in either mode and the cancelled append
44
+ # may remain permanently HALF-applied in the store. The drop is surfaced
45
+ # (MirrorErrorMessage, batches_dropped?); the local transcript remains the
46
+ # source of truth.
39
47
  class TranscriptMirrorBatcher
40
48
  # Eager-flush thresholds (exposed for tests).
41
49
  MAX_PENDING_ENTRIES = 500
@@ -72,6 +80,12 @@ module ClaudeAgentSDK
72
80
  @pending = []
73
81
  @pending_entries = 0
74
82
  @pending_bytes = 0
83
+ # True while the background drain task is live; #enqueue spawns another
84
+ # only once it has exited (see #schedule_background_drain).
85
+ @background_drain_live = false
86
+ # Set when #close starts; from then on anything still in @pending counts
87
+ # as dropped (see #batches_dropped?).
88
+ @closed = false
75
89
  # Batches that exhausted retries (or could not be keyed) and never
76
90
  # reached the store. Written only under @lock; read cross-thread by
77
91
  # #batches_dropped? (a plain Integer read is safe under the GVL).
@@ -86,12 +100,24 @@ module ClaudeAgentSDK
86
100
  # mirror copy is incomplete. Consulted at teardown by the resume-from-store
87
101
  # cleanup so the materialized temp dir (which then holds the only copy of
88
102
  # the dropped turns) is preserved instead of deleted.
103
+ #
104
+ # Frames still buffered once #close has started count too. Query#close
105
+ # stops the read task (the background drainer's parent) right after
106
+ # #close returns, so a frame the read loop enqueued during the close
107
+ # window — or any below-threshold frame after it — is never appended.
108
+ # The old per-frame drain detached it into a parked task whose
109
+ # cancellation counted it; with coalescing it stays in @pending, so it is
110
+ # accounted here. Evaluated lazily: frames the drainer still manages to
111
+ # deliver before the check are not drops, and nothing past #close has to
112
+ # run for a stranded frame to be counted. (A plain ivar/Array#empty? read
113
+ # is safe cross-thread under the GVL, like @dropped_batches.)
89
114
  def batches_dropped?
90
- @dropped_batches.positive?
115
+ @dropped_batches.positive? || (@closed && !@pending.empty?)
91
116
  end
92
117
 
93
118
  # Buffer a frame; schedule an eager background flush if thresholds are
94
- # exceeded. Synchronous and fire-and-forget.
119
+ # exceeded and no background drain is already live (a live one will pick
120
+ # this frame up). Synchronous and fire-and-forget.
95
121
  #
96
122
  # +entries+ are deep-stringified because the transport parses CLI output
97
123
  # with symbolized keys, but SessionStore entries are opaque JSON blobs that
@@ -109,12 +135,10 @@ module ClaudeAgentSDK
109
135
  @pending << { file_path: file_path, entries: entries }
110
136
  @pending_entries += entries.length
111
137
  @pending_bytes += size
112
- return unless @pending_entries > @max_pending_entries || @pending_bytes > @max_pending_bytes
138
+ return unless over_threshold?
113
139
 
114
140
  task = Async::Task.current?
115
- # Fire-and-forget on the reactor; @lock in #drain serializes against any
116
- # in-flight flush so append ordering holds. #drain never raises.
117
- task ? task.async { drain } : drain
141
+ task ? schedule_background_drain(task) : drain
118
142
  end
119
143
 
120
144
  # Flush all pending entries, serialized after any in-flight eager flush.
@@ -122,8 +146,11 @@ module ClaudeAgentSDK
122
146
  drain
123
147
  end
124
148
 
125
- # Final flush before teardown. Never raises.
149
+ # Final flush before teardown. Never raises. Bounded: it waits behind at
150
+ # most the in-flight append, and frames arriving after it detached are
151
+ # not chased (they count as dropped unless delivered; #batches_dropped?).
126
152
  def close
153
+ @closed = true
127
154
  flush
128
155
  rescue StandardError => e
129
156
  warn "Claude SDK: TranscriptMirrorBatcher close flush failed: #{e.message}"
@@ -131,6 +158,38 @@ module ClaudeAgentSDK
131
158
 
132
159
  private
133
160
 
161
+ def over_threshold?
162
+ @pending_entries > @max_pending_entries || @pending_bytes > @max_pending_bytes
163
+ end
164
+
165
+ # Fire-and-forget on the reactor. The drainer loops until the buffer is
166
+ # back under the thresholds, so while it is live every later frame is
167
+ # simply buffered and coalesced into its next #drain — live background
168
+ # tasks <= 1 and detached batches <= 1 + one per #flush / #close caller
169
+ # parked on @lock, independent of frame rate and store latency.
170
+ #
171
+ # No lost wakeup: the loop's final over_threshold? check and the ensure
172
+ # clearing the flag run with no suspension point between them, so an
173
+ # #enqueue that saw the flag set is always observed by that check.
174
+ # Every drainer (this one, #flush, #close) keeps detach-then-acquire, so
175
+ # detach order == @lock FIFO order and append ordering holds. #drain
176
+ # never raises.
177
+ def schedule_background_drain(task)
178
+ return if @background_drain_live
179
+
180
+ @background_drain_live = true
181
+ task.async do
182
+ drain while over_threshold?
183
+ ensure
184
+ @background_drain_live = false
185
+ end
186
+ rescue StandardError
187
+ # Task#async raises synchronously (e.g. FinishedError) before the block
188
+ # runs; don't let a stuck flag disable background drains for good.
189
+ @background_drain_live = false
190
+ raise
191
+ end
192
+
134
193
  # Detach the pending buffer, await any prior flush, then send. Detaching
135
194
  # before acquiring the lock lets #enqueue keep accumulating into a fresh
136
195
  # buffer while a prior flush is in flight. Never raises.
@@ -9,7 +9,7 @@ module ClaudeAgentSDK
9
9
 
10
10
  # Effort levels for `ClaudeAgentOptions#effort`. The CLI (Claude Code 2.1.111+)
11
11
  # accepts these values; the set of *supported* levels is model-dependent
12
- # (e.g. `xhigh` is only supported on Opus 4.7 and falls back to `high` on
12
+ # (e.g. `xhigh` arrived with Opus 4.7 and falls back to `high` on
13
13
  # Opus 4.6 / Sonnet 4.6). An Integer is also accepted and forwarded verbatim.
14
14
  EFFORT_LEVELS = %w[low medium high xhigh max].freeze
15
15
 
@@ -90,6 +90,73 @@ module ClaudeAgentSDK
90
90
  {}
91
91
  end
92
92
 
93
+ # The copy hook used wherever ClaudeAgentOptions are copied (dup_with and
94
+ # the configured-defaults merge). Identity by default: most Type instances
95
+ # are messages or callback payloads that never live inside options, and a
96
+ # user-supplied object that does (an observer, a store adapter) must stay
97
+ # the same object. Option VALUE types include OptionValue to opt in to
98
+ # copying, so a per-session change to e.g. sandbox rules can never reach
99
+ # another session or the configured defaults.
100
+ def dup_for_options
101
+ self
102
+ end
103
+
104
+ # Mixed into the mutable value types that ClaudeAgentOptions holds
105
+ # (SandboxSettings, SystemPromptPreset, AgentDefinition, ...). The copy
106
+ # recurses into the value's own state with Type.deep_dup_for_options, so
107
+ # nested containers and nested value types (SandboxSettings#network) are
108
+ # copied too while identity leaves (McpSdkServerConfig#instance, the
109
+ # callables in HookMatcher#hooks) stay shared. #dup never copies frozen
110
+ # state, so a copy of a frozen value (the configured-defaults snapshot) is
111
+ # mutable.
112
+ module OptionValue
113
+ def dup_for_options
114
+ copy = dup
115
+ copy.instance_variables.each do |ivar|
116
+ copy.instance_variable_set(ivar, Type.deep_dup_for_options(copy.instance_variable_get(ivar)))
117
+ end
118
+ copy
119
+ end
120
+ end
121
+
122
+ # Recurse into Hash/Array containers and option value types, and copy
123
+ # mutable (unfrozen) Strings — a caller-built prompt, model or
124
+ # allowed_tools entry is as much shared state as an Array, and `str <<
125
+ # 'x'` on one copy would otherwise change every other. A frozen String
126
+ # (any literal under frozen_string_literal) is immutable and keeps
127
+ # identity. Every other leaf keeps object identity (observer factories,
128
+ # callbacks, SDK MCP server instances must not be duped). Rebuild
129
+ # containers via dup.clear (never Hash#to_h / Array#map) to preserve
130
+ # container SUBCLASSES: to_h flattens e.g. Rails'
131
+ # HashWithIndifferentAccess into a plain Hash, silently breaking symbol
132
+ # lookups on the copy (config[:type] == 'sdk' → nil). Hash keys: Ruby
133
+ # already stores a dup'd, frozen copy of an unfrozen plain String key, but
134
+ # not of a String SUBCLASS key, so those are copied (and frozen, as keys
135
+ # should be) here. A compare_by_identity Hash is left keyed by the
136
+ # caller's objects: copying a key would break the caller's own lookups.
137
+ def self.deep_dup_for_options(value)
138
+ case value
139
+ when Hash
140
+ copy = value.dup.clear
141
+ value.each { |k, v| copy[option_hash_key(k, value)] = deep_dup_for_options(v) }
142
+ copy
143
+ when Array
144
+ copy = value.dup.clear
145
+ value.each { |v| copy << deep_dup_for_options(v) }
146
+ copy
147
+ when Type then value.dup_for_options
148
+ when String then value.frozen? ? value : value.dup # #dup keeps subclass and encoding
149
+ else value
150
+ end
151
+ end
152
+
153
+ def self.option_hash_key(key, hash)
154
+ return key unless key.is_a?(String) && !key.frozen? && !hash.compare_by_identity?
155
+
156
+ key.dup.freeze
157
+ end
158
+ private_class_method :option_hash_key
159
+
93
160
  private
94
161
 
95
162
  # Allow camelCase attribute access
@@ -785,13 +852,16 @@ module ClaudeAgentSDK
785
852
  # `display` controls how thinking content appears in responses. Valid values
786
853
  # are `"summarized"` (plaintext summary) and `"omitted"` (empty thinking
787
854
  # field, signature only). Defaults are model-dependent: Opus 4.6/Sonnet 4.6
788
- # default to `"summarized"`; Opus 4.7 and Mythos Preview default to
789
- # `"omitted"`. Pass `display: "summarized"` explicitly on Opus 4.7 to get
855
+ # default to `"summarized"`; Opus 4.7 and every later model default to
856
+ # `"omitted"`. Pass `display: "summarized"` explicitly on those to get
790
857
  # visible thinking text. Not supported with `ThinkingConfigDisabled`.
791
858
  THINKING_DISPLAY_VALUES = %w[summarized omitted].freeze
792
859
 
793
- # Adaptive thinking: uses a default budget of 32000 tokens
860
+ # Adaptive thinking: the model decides when and how much to think
861
+ # (sent as `--thinking adaptive`, no budget); control depth with `effort`.
794
862
  class ThinkingConfigAdaptive < Type
863
+ include Type::OptionValue
864
+
795
865
  attr_reader :type, :display
796
866
 
797
867
  def initialize(attributes = {})
@@ -816,6 +886,8 @@ module ClaudeAgentSDK
816
886
 
817
887
  # Enabled thinking: uses a user-specified budget
818
888
  class ThinkingConfigEnabled < Type
889
+ include Type::OptionValue
890
+
819
891
  attr_accessor :budget_tokens
820
892
  attr_reader :type, :display
821
893
 
@@ -841,6 +913,8 @@ module ClaudeAgentSDK
841
913
 
842
914
  # Disabled thinking: sets thinking tokens to 0
843
915
  class ThinkingConfigDisabled < Type
916
+ include Type::OptionValue
917
+
844
918
  attr_reader :type
845
919
 
846
920
  def initialize(attributes = {})
@@ -851,6 +925,8 @@ module ClaudeAgentSDK
851
925
 
852
926
  # Agent definition configuration
853
927
  class AgentDefinition < Type
928
+ include Type::OptionValue
929
+
854
930
  attr_accessor :description, :prompt, :tools, :disallowed_tools, :model, :skills, :memory, :mcp_servers,
855
931
  :initial_prompt, :max_turns, :background, :effort, :permission_mode
856
932
  end
@@ -942,6 +1018,8 @@ module ClaudeAgentSDK
942
1018
 
943
1019
  # Hook matcher configuration
944
1020
  class HookMatcher < Type
1021
+ include Type::OptionValue
1022
+
945
1023
  attr_accessor :matcher, :hooks, :timeout
946
1024
 
947
1025
  def initialize(attributes = {})
@@ -1640,6 +1718,8 @@ module ClaudeAgentSDK
1640
1718
 
1641
1719
  # MCP Server configurations
1642
1720
  class McpStdioServerConfig < Type
1721
+ include Type::OptionValue
1722
+
1643
1723
  attr_accessor :command, :args, :env
1644
1724
  attr_reader :type
1645
1725
 
@@ -1657,6 +1737,8 @@ module ClaudeAgentSDK
1657
1737
  end
1658
1738
 
1659
1739
  class McpSSEServerConfig < Type
1740
+ include Type::OptionValue
1741
+
1660
1742
  attr_accessor :url, :headers
1661
1743
  attr_reader :type
1662
1744
 
@@ -1673,6 +1755,8 @@ module ClaudeAgentSDK
1673
1755
  end
1674
1756
 
1675
1757
  class McpHttpServerConfig < Type
1758
+ include Type::OptionValue
1759
+
1676
1760
  attr_accessor :url, :headers
1677
1761
  attr_reader :type
1678
1762
 
@@ -1689,6 +1773,8 @@ module ClaudeAgentSDK
1689
1773
  end
1690
1774
 
1691
1775
  class McpSdkServerConfig < Type
1776
+ include Type::OptionValue
1777
+
1692
1778
  attr_accessor :name, :instance
1693
1779
  attr_reader :type
1694
1780
 
@@ -1704,6 +1790,8 @@ module ClaudeAgentSDK
1704
1790
 
1705
1791
  # SDK Plugin configuration
1706
1792
  class SdkPluginConfig < Type
1793
+ include Type::OptionValue
1794
+
1707
1795
  attr_accessor :path
1708
1796
  attr_reader :type
1709
1797
 
@@ -1719,6 +1807,8 @@ module ClaudeAgentSDK
1719
1807
 
1720
1808
  # Sandbox network configuration
1721
1809
  class SandboxNetworkConfig < Type
1810
+ include Type::OptionValue
1811
+
1722
1812
  attr_accessor :allowed_domains, :denied_domains, :allow_managed_domains_only,
1723
1813
  :allow_unix_sockets, :allow_all_unix_sockets, :allow_local_binding,
1724
1814
  :allow_mach_lookup, :http_proxy_port, :socks_proxy_port
@@ -1740,6 +1830,8 @@ module ClaudeAgentSDK
1740
1830
 
1741
1831
  # Sandbox filesystem configuration
1742
1832
  class SandboxFilesystemConfig < Type
1833
+ include Type::OptionValue
1834
+
1743
1835
  attr_accessor :allow_write, :deny_write, :deny_read, :allow_read, :allow_managed_read_paths_only
1744
1836
 
1745
1837
  def to_h
@@ -1755,6 +1847,8 @@ module ClaudeAgentSDK
1755
1847
 
1756
1848
  # Sandbox settings for isolated command execution
1757
1849
  class SandboxSettings < Type
1850
+ include Type::OptionValue
1851
+
1758
1852
  attr_accessor :enabled, :fail_if_unavailable, :auto_allow_bash_if_sandboxed,
1759
1853
  :excluded_commands, :allow_unsandboxed_commands, :network, :filesystem,
1760
1854
  :ignore_violations, :enable_weaker_nested_sandbox,
@@ -1786,6 +1880,8 @@ module ClaudeAgentSDK
1786
1880
  # When set, the model is made aware of its remaining token budget so it can
1787
1881
  # pace tool use and wrap up before the limit.
1788
1882
  class TaskBudget < Type
1883
+ include Type::OptionValue
1884
+
1789
1885
  attr_accessor :total
1790
1886
 
1791
1887
  def to_h
@@ -1795,6 +1891,8 @@ module ClaudeAgentSDK
1795
1891
 
1796
1892
  # System prompt file configuration — loads system prompt from a file path
1797
1893
  class SystemPromptFile < Type
1894
+ include Type::OptionValue
1895
+
1798
1896
  attr_accessor :path
1799
1897
  attr_reader :type
1800
1898
 
@@ -1822,6 +1920,8 @@ module ClaudeAgentSDK
1822
1920
  # before 2.1.265 a session with an +append+ prompt recorded it only when
1823
1921
  # +snapshot+ was true. Older CLIs silently ignore it.
1824
1922
  class SystemPromptPreset < Type
1923
+ include Type::OptionValue
1924
+
1825
1925
  attr_reader :type
1826
1926
  attr_accessor :preset, :append, :exclude_dynamic_sections, :snapshot
1827
1927
 
@@ -1844,6 +1944,8 @@ module ClaudeAgentSDK
1844
1944
  # (+--system-prompt <prompt>+); the object form exists so +snapshot+ can be
1845
1945
  # set alongside it (see SystemPromptPreset#snapshot for its semantics).
1846
1946
  class SystemPromptCustom < Type
1947
+ include Type::OptionValue
1948
+
1847
1949
  attr_reader :type
1848
1950
  attr_accessor :prompt, :snapshot
1849
1951
 
@@ -1861,6 +1963,8 @@ module ClaudeAgentSDK
1861
1963
 
1862
1964
  # Tools preset configuration
1863
1965
  class ToolsPreset < Type
1966
+ include Type::OptionValue
1967
+
1864
1968
  attr_reader :type
1865
1969
  attr_accessor :preset
1866
1970
 
@@ -1955,14 +2059,16 @@ module ClaudeAgentSDK
1955
2059
 
1956
2060
  def dup_with(**changes)
1957
2061
  new_options = self.dup
1958
- # A shallow #dup shares nested containers, so mutating a derived copy
1959
- # (e.g. `variant.allowed_tools << 'Bash'`) would bleed into the base and
1960
- # every sibling — including the security-relevant allow/deny lists.
1961
- # Deep-dup Hash/Array containers only; non-container values (procs,
1962
- # SDK MCP server instances, store adapters) must keep their identity.
2062
+ # A shallow #dup shares nested containers and typed option values, so
2063
+ # mutating a derived copy (e.g. `variant.allowed_tools << 'Bash'` or
2064
+ # `variant.sandbox.enabled = false`) would bleed into the base and every
2065
+ # sibling — including the security-relevant allow/deny lists and sandbox
2066
+ # rules. Deep-dup Hash/Array containers and option value types (Type#
2067
+ # dup_for_options, including those nested inside containers such as
2068
+ # agents[:x]); every other leaf (procs, SDK MCP server instances, store
2069
+ # adapters) keeps its identity.
1963
2070
  new_options.instance_variables.each do |ivar|
1964
- value = new_options.instance_variable_get(ivar)
1965
- new_options.instance_variable_set(ivar, deep_dup_containers(value)) if value.is_a?(Hash) || value.is_a?(Array)
2071
+ new_options.instance_variable_set(ivar, Type.deep_dup_for_options(new_options.instance_variable_get(ivar)))
1966
2072
  end
1967
2073
  changes.each { |key, value| new_options[key] = value }
1968
2074
  new_options
@@ -2160,12 +2266,20 @@ module ClaudeAgentSDK
2160
2266
  defaults = ClaudeAgentSDK.default_options
2161
2267
  return attributes unless defaults.any?
2162
2268
 
2163
- # Start from configured defaults. Container values are recursively
2164
- # duped so per-instance mutation (options.allowed_tools << 'Bash')
2165
- # can never corrupt the global defaults; non-container leaves
2166
- # (Strings, Procs, SdkMcpServer instances) intentionally keep identity.
2269
+ # Start from configured defaults. Container values, typed option
2270
+ # values (SandboxSettings, SystemPromptPreset, AgentDefinition, ...)
2271
+ # and mutable Strings are recursively copied (Type.deep_dup_for_options)
2272
+ # so per-instance mutation (options.allowed_tools << 'Bash',
2273
+ # options.sandbox.enabled = false) can never corrupt the global
2274
+ # defaults or reach another session; other leaves (frozen Strings,
2275
+ # Procs, SdkMcpServer instances, store adapters) intentionally keep
2276
+ # identity. The stored defaults are a frozen snapshot
2277
+ # (Configuration#default_options=), and the copy is what makes each
2278
+ # session's containers and values mutable again — its Strings stay the
2279
+ # snapshot's frozen ones, so `options.model << 'x'` fails loudly rather
2280
+ # than reaching other sessions; reassign instead.
2167
2281
  result = {}
2168
- defaults.each { |key, value| result[option_key(key)] = deep_dup_containers(value) }
2282
+ defaults.each { |key, value| result[option_key(key)] = Type.deep_dup_for_options(value) }
2169
2283
  attributes.each do |key, value|
2170
2284
  key = option_key(key)
2171
2285
  default_val = result[key]
@@ -2187,26 +2301,6 @@ module ClaudeAgentSDK
2187
2301
  normalized = normalize_name(name)
2188
2302
  respond_to?(:"#{normalized}=") ? normalized.to_sym : name
2189
2303
  end
2190
-
2191
- # Recurse ONLY into Hash/Array; leaves keep object identity (observer
2192
- # factories, callbacks, SDK MCP server instances must not be duped).
2193
- # Rebuild via dup.clear (never Hash#to_h / Array#map) to preserve
2194
- # container SUBCLASSES: to_h flattens e.g. Rails'
2195
- # HashWithIndifferentAccess into a plain Hash, silently breaking symbol
2196
- # lookups on the copy (config[:type] == 'sdk' → nil).
2197
- def deep_dup_containers(value)
2198
- case value
2199
- when Hash
2200
- copy = value.dup.clear
2201
- value.each { |k, v| copy[k] = deep_dup_containers(v) }
2202
- copy
2203
- when Array
2204
- copy = value.dup.clear
2205
- value.each { |v| copy << deep_dup_containers(v) }
2206
- copy
2207
- else value
2208
- end
2209
- end
2210
2304
  end
2211
2305
 
2212
2306
  # SDK MCP Tool definition
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ClaudeAgentSDK
4
- VERSION = '0.33.0'
4
+ VERSION = '0.34.0'
5
5
  end
@@ -52,18 +52,43 @@ module ClaudeAgentSDK
52
52
  # Internal: pull live SDK MCP server instances out of an mcp_servers Hash.
53
53
  # Accepts both raw Hash configs and typed Mcp*ServerConfig objects — a
54
54
  # McpSdkServerConfig passed without .to_h previously failed the Hash-only
55
- # guard, so its in-process server was silently never registered.
55
+ # guard, so its in-process server was silently never registered. Hash
56
+ # configs may use String or Symbol keys (and a Symbol :sdk type) — the
57
+ # recognition rule must match CommandBuilder#append_mcp_servers, which
58
+ # strips the instance from exactly these entries.
56
59
  def self.extract_sdk_mcp_servers(mcp_servers)
57
60
  return {} unless mcp_servers.is_a?(Hash)
58
61
 
59
62
  servers = {}
60
63
  mcp_servers.each do |name, config|
61
64
  config = config.to_h if config.is_a?(Type)
62
- servers[name] = config[:instance] if config.is_a?(Hash) && config[:type] == 'sdk'
65
+ next unless config.is_a?(Hash) && (config[:type] || config['type']).to_s == 'sdk'
66
+
67
+ servers[name] = config.key?(:instance) ? config[:instance] : config['instance']
63
68
  end
64
69
  servers
65
70
  end
66
71
 
72
+ # Internal: normalize hook lists for the control protocol. An absent or
73
+ # disabled event must not become an empty registration in initialize.
74
+ def self.convert_hooks_to_internal_format(hooks)
75
+ return nil unless hooks
76
+
77
+ internal_hooks = {}
78
+ hooks.each do |event, matchers|
79
+ next if matchers.nil? || matchers.empty?
80
+
81
+ entries = []
82
+ matchers.each do |matcher|
83
+ config = { matcher: matcher.matcher, hooks: matcher.hooks }
84
+ config[:timeout] = matcher.timeout if matcher.timeout
85
+ entries << config
86
+ end
87
+ internal_hooks[event.to_s] = entries unless entries.empty?
88
+ end
89
+ internal_hooks.empty? ? nil : internal_hooks
90
+ end
91
+
67
92
  # Internal: validate can_use_tool and route permission prompts over stdio.
68
93
  #
69
94
  # Shared by query() and Client#connect so both entry points enforce the
@@ -422,6 +447,7 @@ module ClaudeAgentSDK
422
447
  # rename_session). Appends a custom-title entry carrying a fresh uuid +
423
448
  # timestamp via SessionStore#append.
424
449
  # @raise [ArgumentError] if session_id is invalid or title is empty
450
+ # @raise [Errno::ENOENT] if the session is not found in the store (nothing is appended)
425
451
  def self.rename_session_via_store(session_store:, session_id:, title:, directory: nil)
426
452
  SessionMutations.rename_session_via_store(session_store: session_store, session_id: session_id,
427
453
  title: title, directory: directory)
@@ -430,6 +456,7 @@ module ClaudeAgentSDK
430
456
  # Tag a session in a SessionStore (store-backed counterpart to tag_session).
431
457
  # Pass nil to clear the tag.
432
458
  # @raise [ArgumentError] if session_id is invalid or tag is empty after sanitization
459
+ # @raise [Errno::ENOENT] if the session is not found in the store (nothing is appended)
433
460
  def self.tag_session_via_store(session_store:, session_id:, tag:, directory: nil)
434
461
  SessionMutations.tag_session_via_store(session_store: session_store, session_id: session_id,
435
462
  tag: tag, directory: directory)
@@ -528,7 +555,7 @@ module ClaudeAgentSDK
528
555
 
529
556
  raise ArgumentError, 'transport must respond to #connect (see ClaudeAgentSDK::Transport)' if transport && !transport.respond_to?(:connect)
530
557
 
531
- Async do
558
+ Async(&FiberBoundary.capture_otel_context do
532
559
  materialized = nil
533
560
  query_handler = nil
534
561
  begin
@@ -556,25 +583,7 @@ module ClaudeAgentSDK
556
583
  # Extract SDK MCP servers
557
584
  sdk_mcp_servers = extract_sdk_mcp_servers(configured_options.mcp_servers)
558
585
 
559
- hooks = nil
560
- if configured_options.hooks
561
- hooks = {}
562
- configured_options.hooks.each do |event, matchers|
563
- next if matchers.nil? || matchers.empty?
564
-
565
- entries = []
566
- matchers.each do |matcher|
567
- config = {
568
- matcher: matcher.matcher,
569
- hooks: matcher.hooks
570
- }
571
- config[:timeout] = matcher.timeout if matcher.timeout
572
- entries << config
573
- end
574
- hooks[event.to_s] = entries unless entries.empty?
575
- end
576
- hooks = nil if hooks.empty?
577
- end
586
+ hooks = convert_hooks_to_internal_format(configured_options.hooks)
578
587
 
579
588
  # Create Query handler for control protocol
580
589
  query_handler = Query.new(
@@ -686,7 +695,7 @@ module ClaudeAgentSDK
686
695
  end
687
696
  end
688
697
  end
689
- end.wait
698
+ end).wait
690
699
  end
691
700
 
692
701
  # Client for bidirectional, interactive conversations with Claude Code
@@ -762,7 +771,7 @@ module ClaudeAgentSDK
762
771
  def self.open(prompt = nil, options: nil, transport_class: SubprocessCLITransport, transport_args: {})
763
772
  raise ArgumentError, 'Client.open requires a block' unless block_given?
764
773
 
765
- Sync do
774
+ Sync(&FiberBoundary.capture_otel_context do
766
775
  client = new(options: options, transport_class: transport_class, transport_args: transport_args)
767
776
  # connect failures self-clean via connect's rescue -> disconnect ->
768
777
  # raise, and disconnect is idempotent — no double-teardown.
@@ -772,7 +781,7 @@ module ClaudeAgentSDK
772
781
  ensure
773
782
  client.disconnect
774
783
  end
775
- end
784
+ end)
776
785
  end
777
786
 
778
787
  # Connect to Claude with optional initial prompt.
@@ -792,6 +801,7 @@ module ClaudeAgentSDK
792
801
  def connect(prompt = nil)
793
802
  return if @connected
794
803
 
804
+ raise ArgumentError, 'prompt must be a String or an Enumerable of message Hashes/JSONL Strings (got Hash)' if prompt.is_a?(Hash)
795
805
  raise ArgumentError, "prompt must be a String, an Enumerator, or nil (got #{prompt.class})" unless prompt.nil? || prompt.is_a?(String) || prompt.respond_to?(:each)
796
806
 
797
807
  # Validate and configure permission settings
@@ -1050,6 +1060,19 @@ module ClaudeAgentSDK
1050
1060
  end
1051
1061
 
1052
1062
  # Disconnect from Claude
1063
+ #
1064
+ # Callable from inside a user callback (tool handler / hook /
1065
+ # can_use_tool). With the default `callback_scheduling: :thread` the
1066
+ # callback runs on a worker thread: the close is marshalled to the
1067
+ # reactor, returns normally once the teardown completed, and the
1068
+ # callback's return value is dropped (its reactor task was stopped). With
1069
+ # `:inline` the callback's task is a child of the read task being
1070
+ # stopped, so once the teardown has completed the deferred Async::Stop
1071
+ # unwinds the callback — disconnect raises rather than returns there;
1072
+ # ensure blocks run, `rescue StandardError` does not see it. The same
1073
+ # holds, in every scheduling mode, for a streaming-input enumerator that
1074
+ # calls disconnect: it is iterated on the reactor inside a task the close
1075
+ # stops, so it unwinds with Async::Stop once the teardown has completed.
1053
1076
  def disconnect
1054
1077
  if @connected
1055
1078
  ClaudeAgentSDK.notify_observers(@resolved_observers || [], :on_close,
@@ -1125,7 +1148,7 @@ module ClaudeAgentSDK
1125
1148
  sdk_mcp_servers = ClaudeAgentSDK.extract_sdk_mcp_servers(configured_options.mcp_servers)
1126
1149
 
1127
1150
  # Convert hooks to internal format
1128
- hooks = convert_hooks_to_internal_format(configured_options.hooks) if configured_options.hooks
1151
+ hooks = ClaudeAgentSDK.convert_hooks_to_internal_format(configured_options.hooks)
1129
1152
 
1130
1153
  # Extract exclude_dynamic_sections and snapshot from the system prompt
1131
1154
  # for the initialize request (older CLIs ignore unknown initialize fields)
@@ -1236,24 +1259,6 @@ module ClaudeAgentSDK
1236
1259
  @query_handler.set_transcript_mirror_batcher(batcher)
1237
1260
  end
1238
1261
 
1239
- def convert_hooks_to_internal_format(hooks)
1240
- return nil unless hooks
1241
-
1242
- internal_hooks = {}
1243
- hooks.each do |event, matchers|
1244
- internal_hooks[event.to_s] = []
1245
- matchers.each do |matcher|
1246
- config = {
1247
- matcher: matcher.matcher,
1248
- hooks: matcher.hooks
1249
- }
1250
- config[:timeout] = matcher.timeout if matcher.timeout
1251
- internal_hooks[event.to_s] << config
1252
- end
1253
- end
1254
- internal_hooks
1255
- end
1256
-
1257
1262
  def writeln(string)
1258
1263
  write string.end_with?("\n") ? string : "#{string}\n"
1259
1264
  end