claude-agent-sdk 1.0.0 → 1.2.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.
Files changed (47) hide show
  1. checksums.yaml +4 -4
  2. data/.yardopts +10 -0
  3. data/CHANGELOG.md +110 -0
  4. data/README.md +43 -31
  5. data/docs/cli-installer.md +26 -4
  6. data/docs/client.md +40 -11
  7. data/docs/configuration.md +206 -1
  8. data/docs/errors.md +32 -2
  9. data/docs/hooks-and-permissions.md +30 -10
  10. data/docs/mcp-servers.md +30 -9
  11. data/docs/observability.md +61 -10
  12. data/docs/options.md +232 -0
  13. data/docs/rails.md +263 -18
  14. data/docs/sessions.md +40 -12
  15. data/docs/subagents.md +1 -1
  16. data/docs/types.md +100 -11
  17. data/lib/claude_agent_sdk/cli_installer.rb +140 -19
  18. data/lib/claude_agent_sdk/command_builder.rb +84 -27
  19. data/lib/claude_agent_sdk/fiber_boundary.rb +45 -2
  20. data/lib/claude_agent_sdk/instrumentation/otel.rb +90 -28
  21. data/lib/claude_agent_sdk/query.rb +547 -132
  22. data/lib/claude_agent_sdk/railtie.rb +27 -2
  23. data/lib/claude_agent_sdk/sdk_mcp_server.rb +78 -26
  24. data/lib/claude_agent_sdk/session_mutations.rb +112 -92
  25. data/lib/claude_agent_sdk/session_resume.rb +356 -39
  26. data/lib/claude_agent_sdk/session_store.rb +31 -2
  27. data/lib/claude_agent_sdk/sessions.rb +720 -138
  28. data/lib/claude_agent_sdk/subprocess_cli_transport.rb +252 -29
  29. data/lib/claude_agent_sdk/testing/session_store_conformance.rb +18 -7
  30. data/lib/claude_agent_sdk/transcript_mirror_batcher.rb +45 -37
  31. data/lib/claude_agent_sdk/transport.rb +28 -12
  32. data/lib/claude_agent_sdk/types/attributes.rb +9 -0
  33. data/lib/claude_agent_sdk/types/base.rb +85 -15
  34. data/lib/claude_agent_sdk/types/hooks.rb +73 -0
  35. data/lib/claude_agent_sdk/types/mcp.rb +37 -1
  36. data/lib/claude_agent_sdk/types/messages.rb +7 -1
  37. data/lib/claude_agent_sdk/types/option_values.rb +186 -4
  38. data/lib/claude_agent_sdk/types/options.rb +104 -17
  39. data/lib/claude_agent_sdk/types/permissions.rb +18 -9
  40. data/lib/claude_agent_sdk/version.rb +1 -1
  41. data/lib/claude_agent_sdk.rb +111 -53
  42. data/lib/generators/claude_agent_sdk/install/templates/claude_agent_sdk.rb.tt +6 -0
  43. data/sig/claude_agent_sdk/types/hooks.rbs +6 -3
  44. data/sig/claude_agent_sdk/types/option_values.rbs +23 -6
  45. data/sig/claude_agent_sdk/types/options.rbs +20 -7
  46. data/sig/claude_agent_sdk/types/permissions.rbs +4 -2
  47. metadata +6 -4
@@ -18,8 +18,10 @@ module ClaudeAgentSDK
18
18
 
19
19
  # Claude Agent Options for configuring queries
20
20
  class ClaudeAgentOptions < Type
21
- # `env` routinely carries credentials (ANTHROPIC_API_KEY, ...).
22
- inspect_filtered :env
21
+ # `env` routinely carries credentials (ANTHROPIC_API_KEY, ...). So does
22
+ # `settings` (its own `env`, `apiKeyHelper`), as a Hash or a JSON String,
23
+ # and an `extra_args` value can be one.
24
+ inspect_filtered :env, :settings, :extra_args
23
25
 
24
26
  attr_accessor :allowed_tools, :system_prompt, :mcp_servers, :permission_mode,
25
27
  :resume, :resume_session_at, :session_id, :max_turns, :disallowed_tools,
@@ -81,21 +83,11 @@ module ClaudeAgentSDK
81
83
  self.include_hook_events = false
82
84
  self.strict_mcp_config = false
83
85
  self.forward_subagent_text = false
86
+ self.verbatim_prompts = false
84
87
 
85
88
  super(merge_with_defaults(attributes || {}))
86
89
 
87
- # Non-nil defaults for options that need them.
88
- self.env ||= {}
89
- self.extra_args ||= {}
90
- self.mcp_servers ||= {}
91
- self.add_dirs ||= []
92
- self.observers ||= []
93
- self.allowed_tools ||= []
94
- self.disallowed_tools ||= []
95
- self.session_store_flush ||= 'batched'
96
- # 0 is a valid (immediate) timeout, so only fill in the default for nil.
97
- self.load_timeout_ms = 60_000 if load_timeout_ms.nil?
98
- self.callback_scheduling = :thread if callback_scheduling.nil?
90
+ fill_nil_defaults
99
91
  end
100
92
 
101
93
  def dup_with(**changes)
@@ -203,6 +195,58 @@ module ClaudeAgentSDK
203
195
  @forward_subagent_text = coerce_boolean(value)
204
196
  end
205
197
 
198
+ # Deliver every prompt to Claude as written.
199
+ #
200
+ # When true, every user message the SDK sends is marked `client_composed`:
201
+ # a String prompt to {ClaudeAgentSDK.query}, {Client#connect} or
202
+ # {Client#query}, and every message of a streamed (Enumerable) prompt to
203
+ # any of them. Claude Code then delivers the text exactly as given: no
204
+ # `@path` file-mention expansion and no slash-command dispatch. Use it when
205
+ # the prompt is assembled from content your end user did not type (earlier
206
+ # turns, tool output, third-party text), so an `@/absolute/path` inside it
207
+ # cannot make Claude Code read a local file. `tools: []`, `allowed_tools`
208
+ # and `disallowed_tools` do not stop that expansion: it happens before the
209
+ # model runs, without a tool call.
210
+ #
211
+ # While the option is on there is no per-message opt-out: a
212
+ # `client_composed` key on a streamed message Hash (either spelling) is
213
+ # overwritten. For per-turn control, leave the option off and set
214
+ # `client_composed: true` on individual streamed messages. The caller's
215
+ # Hashes are never mutated. A streamed JSONL String is parsed, marked and
216
+ # re-serialized; one that is not a single JSON object raises
217
+ # `ArgumentError` rather than being sent unmarked (on the background
218
+ # streaming paths that ends the stream with a warning, like any other
219
+ # stream error).
220
+ #
221
+ # On current Claude Code versions a turn delivered this way also skips the
222
+ # turn-start attachment pass as a whole: `@server:resource` MCP mentions
223
+ # are not expanded either, and the prompt goes without the context Claude
224
+ # Code normally attaches (nested `CLAUDE.md` and rules files, skill and
225
+ # tool listings, other per-turn reminders). The pass between tool calls is
226
+ # unaffected, so most of that context arrives after the turn's first tool
227
+ # call instead.
228
+ #
229
+ # Requires Claude Code 2.1.248 or later; older versions ignore the field,
230
+ # so prompts are still expanded there, and the SDK warns when it connects
231
+ # to one with this option on. Read once when the session starts. Not a
232
+ # CLI flag. Matches the Python SDK's `verbatim_prompts`.
233
+ #
234
+ # Assigning coerces to a Boolean; {#verbatim_prompts?} is the predicate
235
+ # form.
236
+ #
237
+ # @return [Boolean]
238
+ attr_reader :verbatim_prompts
239
+
240
+ # @return [Boolean] {#verbatim_prompts}, as a strict Boolean.
241
+ def verbatim_prompts?
242
+ !!verbatim_prompts
243
+ end
244
+
245
+ # @see #verbatim_prompts
246
+ def verbatim_prompts=(value)
247
+ @verbatim_prompts = coerce_boolean(value)
248
+ end
249
+
206
250
  # Request model-generated progress summaries for subagent (`local_agent`)
207
251
  # tasks. `true` *requests* generation: while the CLI has it enabled, a
208
252
  # subagent's {TaskProgressMessage#summary} **may** carry a one-line status.
@@ -288,16 +332,59 @@ module ClaudeAgentSDK
288
332
 
289
333
  private
290
334
 
335
+ # Non-nil defaults for options that need them.
336
+ def fill_nil_defaults
337
+ self.env ||= {}
338
+ self.extra_args ||= {}
339
+ self.mcp_servers ||= {}
340
+ self.add_dirs ||= []
341
+ self.observers ||= []
342
+ self.allowed_tools ||= []
343
+ self.disallowed_tools ||= []
344
+ self.session_store_flush ||= 'batched'
345
+ # 0 is a valid (immediate) timeout, so only fill in the default for nil.
346
+ self.load_timeout_ms = 60_000 if load_timeout_ms.nil?
347
+ self.callback_scheduling = :thread if callback_scheduling.nil?
348
+ end
349
+
350
+ # `mcp_servers` holds typed configs, which filter themselves, next to raw
351
+ # Hash configs carrying the same credentials: those are rendered by
352
+ # Type#inspect_mcp_server_config. Anything but a Hash (the JSON of a
353
+ # config as a String, or the path to one) is replaced outright, like a
354
+ # String `settings`.
355
+ def inspect_attributes
356
+ super.map { |name, value| [name, name == 'mcp_servers' ? inspect_mcp_servers(value) : value] }
357
+ end
358
+
359
+ def inspect_mcp_servers(servers)
360
+ return '[FILTERED]' unless servers.is_a?(Hash)
361
+
362
+ servers.to_h { |name, config| [name, config.is_a?(Hash) ? inspect_mcp_server_config(config) : config] }
363
+ rescue StandardError
364
+ '[FILTERED]'
365
+ end
366
+
291
367
  # Strict key validation: unlike other Type subclasses (which silently drop
292
368
  # unknown keys for forward-compat with newer CLI output), ClaudeAgentOptions
293
369
  # is a developer-facing config object — typos should fail loudly.
294
370
  def assign_attribute(name, value)
295
- setter = :"#{normalize_name(name)}="
296
- raise ArgumentError, "unknown ClaudeAgentOptions option: #{name.inspect}" unless respond_to?(setter)
371
+ setter = option_setter(normalize_name(name))
372
+ raise ArgumentError, "unknown ClaudeAgentOptions option: #{name.inspect}" unless setter
297
373
 
298
374
  public_send(setter, value)
299
375
  end
300
376
 
377
+ # The writer of the option with this normalized name, nil when the name
378
+ # is not an option. An option is a declared attribute, or a setter user
379
+ # code defined (on a subclass, a module it includes, or the object);
380
+ # respond_to? alone would also answer for '[]' (#[]=) and '=' (#==).
381
+ def option_setter(normalized)
382
+ setter = :"#{normalized}="
383
+ return unless respond_to?(setter)
384
+
385
+ setter if self.class.attribute?(normalized) || user_defined_method?(setter)
386
+ end
387
+
301
388
  # Merge caller-provided attributes with configured defaults.
302
389
  # Only keys the caller explicitly passed are treated as overrides;
303
390
  # method-signature defaults ([], {}, false) are NOT present unless the caller wrote them.
@@ -346,7 +433,7 @@ module ClaudeAgentSDK
346
433
  # reports the typo exactly as the developer wrote it.
347
434
  def option_key(name)
348
435
  normalized = normalize_name(name)
349
- respond_to?(:"#{normalized}=") ? normalized.to_sym : name
436
+ option_setter(normalized) ? normalized.to_sym : name
350
437
  end
351
438
  end
352
439
  end
@@ -34,20 +34,21 @@ module ClaudeAgentSDK
34
34
  @rules = value&.map { |rule| rule.is_a?(Hash) ? PermissionRuleValue.new(rule) : rule }
35
35
  end
36
36
 
37
+ # The wire form. The CLI validates the updatedPermissions of a
38
+ # can_use_tool reply as one unit and drops the whole array when a single
39
+ # entry does not fit its schema, so two things it rejects are never
40
+ # written: a rule without content has no ruleContent key (the schema
41
+ # wants a String or no key, not null), and an update that has a type but
42
+ # no destination goes to 'session' — the narrowest one: it lasts for this
43
+ # run and writes no settings file (the default Python PR #1330 proposes).
37
44
  def to_h
38
45
  result = { type: @type }
39
- result[:destination] = @destination if @destination
46
+ destination = @destination || ('session' unless @type.nil?)
47
+ result[:destination] = destination if destination
40
48
 
41
49
  case @type
42
50
  when 'addRules', 'replaceRules', 'removeRules'
43
- if @rules
44
- result[:rules] = @rules.map do |rule|
45
- {
46
- toolName: rule.tool_name,
47
- ruleContent: rule.rule_content
48
- }
49
- end
50
- end
51
+ result[:rules] = @rules.map { |rule| rule_to_h(rule) } if @rules
51
52
  result[:behavior] = @behavior if @behavior
52
53
  when 'setMode'
53
54
  result[:mode] = @mode if @mode
@@ -57,6 +58,14 @@ module ClaudeAgentSDK
57
58
 
58
59
  result
59
60
  end
61
+
62
+ private
63
+
64
+ def rule_to_h(rule)
65
+ wire_rule = { toolName: rule.tool_name }
66
+ wire_rule[:ruleContent] = rule.rule_content unless rule.rule_content.nil?
67
+ wire_rule
68
+ end
60
69
  end
61
70
 
62
71
  # Tool permission context delivered to `can_use_tool` callbacks.
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ClaudeAgentSDK
4
- VERSION = '1.0.0'
4
+ VERSION = '1.2.0'
5
5
  end
@@ -79,7 +79,10 @@ module ClaudeAgentSDK # rubocop:disable Metrics/ModuleLength -- the public entry
79
79
  end
80
80
 
81
81
  # Internal: normalize hook lists for the control protocol. An absent or
82
- # disabled event must not become an empty registration in initialize.
82
+ # disabled event must not become an empty registration in initialize. An
83
+ # event written as a String under one key and as a Symbol under another
84
+ # ('PreToolUse' and :PreToolUse) is one event: its matcher lists are
85
+ # joined in the order they were written, never replaced.
83
86
  # @api private
84
87
  def self.convert_hooks_to_internal_format(hooks)
85
88
  return nil unless hooks
@@ -94,7 +97,7 @@ module ClaudeAgentSDK # rubocop:disable Metrics/ModuleLength -- the public entry
94
97
  config[:timeout] = matcher.timeout if matcher.timeout
95
98
  entries << config
96
99
  end
97
- internal_hooks[event.to_s] = entries unless entries.empty?
100
+ (internal_hooks[event.to_s] ||= []).concat(entries) unless entries.empty?
98
101
  end
99
102
  internal_hooks.empty? ? nil : internal_hooks
100
103
  end
@@ -139,7 +142,8 @@ module ClaudeAgentSDK # rubocop:disable Metrics/ModuleLength -- the public entry
139
142
  eds = system_prompt.exclude_dynamic_sections
140
143
  return eds if [true, false].include?(eds)
141
144
  elsif system_prompt.is_a?(Hash)
142
- type = system_prompt[:type] || system_prompt['type']
145
+ # The tag may be a Symbol (type: :preset), as CommandBuilder reads it.
146
+ type = (system_prompt[:type] || system_prompt['type']).to_s
143
147
  if type == 'preset'
144
148
  eds = system_prompt.fetch(:exclude_dynamic_sections) { system_prompt['exclude_dynamic_sections'] }
145
149
  return eds if [true, false].include?(eds)
@@ -160,7 +164,7 @@ module ClaudeAgentSDK # rubocop:disable Metrics/ModuleLength -- the public entry
160
164
  snapshot = system_prompt.snapshot
161
165
  return snapshot if [true, false].include?(snapshot)
162
166
  when Hash
163
- type = system_prompt[:type] || system_prompt['type']
167
+ type = (system_prompt[:type] || system_prompt['type']).to_s
164
168
  if %w[preset custom].include?(type)
165
169
  snapshot = system_prompt.fetch(:snapshot) { system_prompt['snapshot'] }
166
170
  return snapshot if [true, false].include?(snapshot)
@@ -173,14 +177,39 @@ module ClaudeAgentSDK # rubocop:disable Metrics/ModuleLength -- the public entry
173
177
  # Each observer is invoked through FiberBoundary so that user code runs
174
178
  # on a plain thread (no Fiber scheduler) even when called from inside
175
179
  # the SDK's Async reactor — or in place when scheduling is :inline.
180
+ #
181
+ # What the observer raises, and what its callback_wrapper raises, is
182
+ # contained where it happens: inside the hop, on the execution context
183
+ # the observer runs on. The rescue must not sit around the hop, because
184
+ # the calling fiber WAITS there, and an exception raised into a waiting
185
+ # fiber is not the observer's — a caller's `task.with_timeout` /
186
+ # `Timeout.timeout` deadline is delivered exactly that way. Rescued out
187
+ # here, an expired deadline was swallowed as if the observer had failed,
188
+ # and the turn carried on. So the wrapper is composed inside the rescued
189
+ # body rather than handed to FiberBoundary.invoke, which would run it
190
+ # outside that rescue.
191
+ #
192
+ # Known residue with `scheduling: :inline` (and with no scheduler at all):
193
+ # the observer runs on the calling fiber itself, so a deadline that lands
194
+ # while the observer is suspended is raised inside the observer's own
195
+ # frames, cannot be told from the observer's own timeout, and is still
196
+ # swallowed here.
176
197
  # @api private
177
198
  def self.notify_observers(observers, method, *args, scheduling: :thread, wrapper: nil)
178
199
  observers.each do |obs|
179
- FiberBoundary.invoke(scheduling: scheduling, wrapper: wrapper) { obs.send(method, *args) }
180
- rescue StandardError, ScriptError
181
- # ScriptError too: NotImplementedError < ScriptError (not
182
- # StandardError), and a stubbed observer must never mask the original
183
- # error being notified or abort connect/teardown cleanup.
200
+ FiberBoundary.invoke(scheduling: scheduling) do
201
+ invocation = proc { obs.send(method, *args) }
202
+ wrapper ? wrapper.call(invocation) : invocation.call
203
+ rescue StandardError, ScriptError
204
+ # ScriptError too: NotImplementedError < ScriptError (not
205
+ # StandardError), and a stubbed observer must never mask the original
206
+ # error being notified or abort connect/teardown cleanup.
207
+ nil
208
+ end
209
+ rescue ScriptError
210
+ # A ScriptError raised around the body rather than in it (the hop's own
211
+ # plumbing). No deadline is a ScriptError, so nothing a caller injects
212
+ # into the waiting fiber is caught here.
184
213
  nil
185
214
  end
186
215
  end
@@ -703,7 +732,14 @@ module ClaudeAgentSDK # rubocop:disable Metrics/ModuleLength -- the public entry
703
732
  raise ArgumentError, 'transport must respond to #connect (see ClaudeAgentSDK::Transport)'
704
733
  end
705
734
 
706
- Async(&FiberBoundary.capture_otel_context do # rubocop:disable Metrics/BlockLength -- the reactor task body of query()
735
+ # finished: false tells Async that a waiter handles this task's failure
736
+ # (the .wait at the end re-raises it), as Kernel#Sync does for its own
737
+ # task. Without it a task that fails before anyone waits for it — always
738
+ # the case outside a reactor, where Async runs the whole task before it
739
+ # returns — is also logged as "Task may have ended with unhandled
740
+ # exception", message and backtrace included, although the caller gets
741
+ # the same error raised and may well rescue it.
742
+ Async(finished: false, &FiberBoundary.capture_otel_context do # rubocop:disable Metrics/BlockLength -- the reactor task body of query()
707
743
  materialized = nil
708
744
  query_handler = nil
709
745
  begin
@@ -749,7 +785,9 @@ module ClaudeAgentSDK # rubocop:disable Metrics/ModuleLength -- the public entry
749
785
  forward_subagent_text: configured_options.forward_subagent_text?,
750
786
  agent_progress_summaries: configured_options.agent_progress_summaries,
751
787
  callback_scheduling: callback_scheduling,
752
- callback_wrapper: callback_wrapper
788
+ callback_wrapper: callback_wrapper,
789
+ verbatim_prompts: configured_options.verbatim_prompts?,
790
+ run_end_ceiling_ms: Query.run_end_ceiling_ms(configured_options.env)
753
791
  )
754
792
 
755
793
  # Mirror transcripts to the session_store, if configured. Installed
@@ -782,7 +820,7 @@ module ClaudeAgentSDK # rubocop:disable Metrics/ModuleLength -- the public entry
782
820
  parent_tool_use_id: nil,
783
821
  session_id: ''
784
822
  }
785
- transport.write("#{JSON.generate(message)}\n")
823
+ transport.write("#{Query.serialize_user_message(message, configured_options.verbatim_prompts?)}\n")
786
824
  # Background-spawn so messages stream to the user block while stdin
787
825
  # close waits (without timeout) for the first result; a synchronous
788
826
  # call would defer all delivery until the turn completes (mirrors
@@ -901,16 +939,13 @@ module ClaudeAgentSDK # rubocop:disable Metrics/ModuleLength -- the public entry
901
939
  # The Client class always uses streaming mode for bidirectional communication.
902
940
  #
903
941
  # @example Basic usage
904
- # Async do
905
- # client = ClaudeAgentSDK::Client.new
906
- # client.connect # No arguments needed - automatically uses streaming mode
907
- #
942
+ # # Client.open connects, yields the client and always disconnects,
943
+ # # also when the block raises.
944
+ # ClaudeAgentSDK::Client.open do |client|
908
945
  # client.query("What is the capital of France?")
909
946
  # client.receive_response do |msg|
910
947
  # puts msg if msg.is_a?(ClaudeAgentSDK::AssistantMessage)
911
948
  # end
912
- #
913
- # client.disconnect
914
949
  # end
915
950
  #
916
951
  # @example With hooks
@@ -1041,17 +1076,22 @@ module ClaudeAgentSDK # rubocop:disable Metrics/ModuleLength -- the public entry
1041
1076
  # (The enumerator branch streams in the background and cannot raise
1042
1077
  # out of connect.) No on_close follows for pre-handshake failures
1043
1078
  # (disconnect gates it on @connected): the session never opened.
1044
- notify_error(e) if e.is_a?(StandardError) && !@connected
1045
- # Tear down the partial connect, but never let a cleanup failure (e.g. a
1046
- # custom transport whose #close raises) mask the original connect error.
1047
- # Rescue Exception (not StandardError) so reactor cancellation
1048
- # (Async::Stop < Exception) after materialize_resume set @materialized
1049
- # still runs disconnect -> @materialized.cleanup, never leaking the temp
1050
- # CLAUDE_CONFIG_DIR that holds the redacted .credentials.json copy.
1051
1079
  begin
1052
- disconnect
1053
- rescue StandardError => cleanup_error
1054
- warn "Claude SDK: cleanup after failed connect raised: #{cleanup_error.message}"
1080
+ notify_error(e) if e.is_a?(StandardError) && !@connected
1081
+ ensure
1082
+ # Tear down the partial connect, but never let a cleanup failure (e.g. a
1083
+ # custom transport whose #close raises) mask the original connect error.
1084
+ # Rescue Exception (not StandardError) so reactor cancellation
1085
+ # (Async::Stop < Exception) after materialize_resume set @materialized
1086
+ # still runs disconnect -> @materialized.cleanup, never leaking the temp
1087
+ # CLAUDE_CONFIG_DIR that holds the redacted .credentials.json copy.
1088
+ # In an ensure: an exception raised into this fiber while it waits
1089
+ # for the on_error observer (a caller's deadline) must not skip it.
1090
+ begin
1091
+ disconnect
1092
+ rescue StandardError => cleanup_error
1093
+ warn "Claude SDK: cleanup after failed connect raised: #{cleanup_error.message}"
1094
+ end
1055
1095
  end
1056
1096
  raise
1057
1097
  end
@@ -1084,7 +1124,7 @@ module ClaudeAgentSDK # rubocop:disable Metrics/ModuleLength -- the public entry
1084
1124
  parent_tool_use_id: nil,
1085
1125
  session_id: session_id
1086
1126
  }
1087
- writeln(JSON.generate(message))
1127
+ writeln(Query.serialize_user_message(message, @verbatim_prompts))
1088
1128
  elsif prompt.respond_to?(:each)
1089
1129
  # Inline iteration on the caller, Python client.py parity — NOT
1090
1130
  # Query#stream_input, whose ensure always ends input after
@@ -1312,11 +1352,11 @@ module ClaudeAgentSDK # rubocop:disable Metrics/ModuleLength -- the public entry
1312
1352
  # holds, in every scheduling mode, for a streaming-input enumerator that
1313
1353
  # calls disconnect: it is iterated on the reactor inside a task the close
1314
1354
  # stops, so it unwinds with Async::Stop once the teardown has completed.
1355
+ #
1356
+ # Interrupted while an observer's on_close runs — the caller's deadline
1357
+ # expires, or its task is stopped — disconnect still completes the
1358
+ # teardown, then lets the interruption propagate.
1315
1359
  def disconnect
1316
- if @connected
1317
- ClaudeAgentSDK.notify_observers(@resolved_observers || [], :on_close,
1318
- scheduling: @callback_scheduling, wrapper: @callback_wrapper)
1319
- end
1320
1360
  # Tear down whatever exists — robust to a partial/failed connect, where
1321
1361
  # @connected is still false but a transport and/or materialized temp dir
1322
1362
  # were already created. #close on the query handler also closes the
@@ -1333,26 +1373,36 @@ module ClaudeAgentSDK # rubocop:disable Metrics/ModuleLength -- the public entry
1333
1373
  # and the materialized-dir decision at the bottom needs to ask it.
1334
1374
  query_handler = @query_handler
1335
1375
  begin
1336
- @query_handler&.close
1376
+ # Notified inside the begin: an exception raised into this fiber while
1377
+ # it waits for an observer (a caller's deadline) propagates, and must
1378
+ # not skip the teardown below.
1379
+ if @connected
1380
+ ClaudeAgentSDK.notify_observers(@resolved_observers || [], :on_close,
1381
+ scheduling: @callback_scheduling, wrapper: @callback_wrapper)
1382
+ end
1337
1383
  ensure
1338
- @query_handler = nil
1339
1384
  begin
1340
- @transport&.close
1385
+ @query_handler&.close
1341
1386
  ensure
1342
- @transport = nil
1343
- @connected = false
1344
- # Remove the materialized resume temp dir AFTER the subprocess
1345
- # exited — unless the mirror dropped batches: the store copy is then
1346
- # incomplete and the temp dir holds the only copy of the dropped
1347
- # turns, so it is preserved (scrubbed of credentials) with a warning
1348
- # instead of deleted.
1349
- if @materialized
1350
- if query_handler&.mirror_batches_dropped?
1351
- @materialized.preserve_transcripts
1352
- else
1353
- @materialized.cleanup
1387
+ @query_handler = nil
1388
+ begin
1389
+ @transport&.close
1390
+ ensure
1391
+ @transport = nil
1392
+ @connected = false
1393
+ # Remove the materialized resume temp dir AFTER the subprocess
1394
+ # exited — unless the mirror dropped batches: the store copy is then
1395
+ # incomplete and the temp dir holds the only copy of the dropped
1396
+ # turns, so it is preserved (scrubbed of credentials) with a warning
1397
+ # instead of deleted.
1398
+ if @materialized
1399
+ if query_handler&.mirror_batches_dropped?
1400
+ @materialized.preserve_transcripts
1401
+ else
1402
+ @materialized.cleanup
1403
+ end
1404
+ @materialized = nil
1354
1405
  end
1355
- @materialized = nil
1356
1406
  end
1357
1407
  end
1358
1408
  end
@@ -1394,6 +1444,10 @@ module ClaudeAgentSDK # rubocop:disable Metrics/ModuleLength -- the public entry
1394
1444
  exclude_dynamic_sections = ClaudeAgentSDK.extract_exclude_dynamic_sections(configured_options.system_prompt)
1395
1445
  system_prompt_snapshot = ClaudeAgentSDK.extract_system_prompt_snapshot(configured_options.system_prompt)
1396
1446
 
1447
+ # Captured once, so String and streamed prompts in one session are
1448
+ # stamped alike (the Query stamps the streamed ones with this value).
1449
+ @verbatim_prompts = configured_options.verbatim_prompts?
1450
+
1397
1451
  # Create Query handler
1398
1452
  @query_handler = Query.new(
1399
1453
  transport: @transport,
@@ -1408,7 +1462,9 @@ module ClaudeAgentSDK # rubocop:disable Metrics/ModuleLength -- the public entry
1408
1462
  forward_subagent_text: configured_options.forward_subagent_text?,
1409
1463
  agent_progress_summaries: configured_options.agent_progress_summaries,
1410
1464
  callback_scheduling: @callback_scheduling,
1411
- callback_wrapper: @callback_wrapper
1465
+ callback_wrapper: @callback_wrapper,
1466
+ verbatim_prompts: @verbatim_prompts,
1467
+ run_end_ceiling_ms: Query.run_end_ceiling_ms(configured_options.env)
1412
1468
  )
1413
1469
 
1414
1470
  # Mirror transcripts to the session_store, if configured.
@@ -1450,7 +1506,9 @@ module ClaudeAgentSDK # rubocop:disable Metrics/ModuleLength -- the public entry
1450
1506
  # key styles — an explicit nil is preserved, mirroring Python's
1451
1507
  # `"session_id" not in msg`). Strings pass through verbatim (Ruby
1452
1508
  # superset: Streaming.user_message emits pre-serialized JSONL; no
1453
- # parse-stamp-regenerate, which would block the reactor on huge frames).
1509
+ # parse-stamp-regenerate, which would block the reactor on huge frames),
1510
+ # except that verbatim_prompts must mark them `client_composed`, so with
1511
+ # that option on they are parsed and re-serialized (Query.stamp_user_message).
1454
1512
  def stream_query_messages(prompt, session_id)
1455
1513
  prompt.each do |msg|
1456
1514
  case msg
@@ -1460,13 +1518,13 @@ module ClaudeAgentSDK # rubocop:disable Metrics/ModuleLength -- the public entry
1460
1518
  ClaudeAgentSDK.notify_observers(@resolved_observers, :on_user_prompt, text,
1461
1519
  scheduling: @callback_scheduling, wrapper: @callback_wrapper)
1462
1520
  end
1463
- writeln(JSON.generate(msg))
1521
+ writeln(Query.serialize_user_message(msg, @verbatim_prompts))
1464
1522
  when String
1465
1523
  if (text = ClaudeAgentSDK.extract_user_prompt_text(msg))
1466
1524
  ClaudeAgentSDK.notify_observers(@resolved_observers, :on_user_prompt, text,
1467
1525
  scheduling: @callback_scheduling, wrapper: @callback_wrapper)
1468
1526
  end
1469
- writeln(msg)
1527
+ writeln(Query.serialize_user_message(msg, @verbatim_prompts))
1470
1528
  else
1471
1529
  # No to_s fallback — silently serializing arbitrary objects is the
1472
1530
  # exact inspect-garbage bug class this method exists to prevent.
@@ -22,6 +22,12 @@ ClaudeAgentSDK.configure do |config|
22
22
  # whatever the process cwd. To run a binary from somewhere else instead:
23
23
  # cli_path: '/usr/local/bin/claude',
24
24
 
25
+ # Multi-user apps: turn off the CLI's auto-memory — kept per project
26
+ # directory, read by every session and written by claude_code-preset
27
+ # sessions, so one user's "remember ..." reaches all others (the value
28
+ # must stay '1'; '0' forces it on). See docs/rails.md ("Per-user isolation").
29
+ # env: { 'CLAUDE_CODE_DISABLE_AUTO_MEMORY' => '1' },
30
+
25
31
  # OpenTelemetry tracing (Langfuse, Honeycomb, ...). A factory lambda gives
26
32
  # every query/session its own observer — safe under Puma and job threads.
27
33
  # observers: [-> { ClaudeAgentSDK::Instrumentation::OTelObserver.new }],
@@ -47,8 +47,9 @@ module ClaudeAgentSDK
47
47
  | CwdChangedHookSpecificOutput
48
48
  | FileChangedHookSpecificOutput
49
49
 
50
- # What a hook callback returns: a Hash (snake_case or camelCase keys), a
51
- # typed output, or nil (treated as {}).
50
+ # What a hook callback returns: a Hash (Symbol or String keys, snake_case
51
+ # or camelCase, also inside hook_specific_output), a typed output, or nil
52
+ # (treated as {}).
52
53
  type hook_output = Hash[Symbol | String, untyped] | SyncHookJSONOutput | AsyncHookJSONOutput | nil
53
54
 
54
55
  # A hook callback (HookMatcher#hooks entries). tool_use_id is nil for
@@ -520,7 +521,9 @@ module ClaudeAgentSDK
520
521
 
521
522
  attr_accessor reason: String?
522
523
 
523
- # A typed output, or the equivalent Hash (camelCase wire keys, sent as-is).
524
+ # A typed output, or the equivalent Hash (snake_case or camelCase keys).
525
+ # #to_h returns a Hash as written; the SDK puts its keys in wire spelling
526
+ # when it sends the output.
524
527
  attr_accessor hook_specific_output: (hook_specific_output | Hash[Symbol | String, untyped])?
525
528
 
526
529
  def to_h: () -> Hash[Symbol, untyped]
@@ -7,6 +7,16 @@ module ClaudeAgentSDK
7
7
  # the Hash form (String or camelCase keys, e.g. `klass.new(value.to_h)`)
8
8
  # is accepted too. An unknown key raises ArgumentError. Discriminators
9
9
  # (`type`) are fixed by the class and read-only.
10
+ #
11
+ # The keyword overload documents the accepted keys and their types; no
12
+ # type checker enforces it. `klass.new(typo: 1)` is also a Symbol-keyed
13
+ # Hash, which the second overload accepts whatever its keys and values, so
14
+ # Steep reports neither a misspelled keyword nor a wrong value type, and
15
+ # the runtime checker behind `rake rbs:test` never reports a constructor
16
+ # mismatch (it only checks the attribute setters the constructor calls).
17
+ # The enforcement is the ArgumentError the constructor raises at runtime
18
+ # for an unknown key. This holds for every constructor in sig/ that has
19
+ # both overloads.
10
20
 
11
21
  # Adaptive thinking: the model decides when and how much to think.
12
22
  class ThinkingConfigAdaptive < Type
@@ -81,15 +91,15 @@ module ClaudeAgentSDK
81
91
 
82
92
  # A local plugin directory (ClaudeAgentOptions#plugins entries).
83
93
  class SdkPluginConfig < Type
84
- def initialize: (?path: String?) -> void
94
+ def initialize: (?path: (String | Pathname)?) -> void
85
95
  | (Hash[Symbol | String, untyped]? attributes) -> void
86
96
 
87
- attr_accessor path: String?
97
+ attr_accessor path: (String | Pathname)?
88
98
 
89
99
  # "local".
90
100
  attr_reader type: String
91
101
 
92
- def to_h: () -> { type: String, path: String? }
102
+ def to_h: () -> { type: String, path: (String | Pathname)? }
93
103
  end
94
104
 
95
105
  class SandboxNetworkConfig < Type
@@ -138,6 +148,13 @@ module ClaudeAgentSDK
138
148
 
139
149
  # Sandbox settings for isolated command execution
140
150
  # (ClaudeAgentOptions#sandbox).
151
+ #
152
+ # network / filesystem take the typed config or a Hash. A Hash may spell
153
+ # the fields of the typed class in snake_case or in the CLI's camelCase,
154
+ # with Symbol or String keys; other keys are sent as written. A snake_case
155
+ # key is sent under the CLI's name only when its value has the shape the
156
+ # CLI accepts for it; otherwise it is sent as written, which the CLI
157
+ # ignores.
141
158
  class SandboxSettings < Type
142
159
  def initialize: (?enabled: bool?, ?fail_if_unavailable: bool?, ?auto_allow_bash_if_sandboxed: bool?, ?excluded_commands: Array[String]?, ?allow_unsandboxed_commands: bool?, ?network: (SandboxNetworkConfig | Hash[Symbol | String, untyped])?, ?filesystem: (SandboxFilesystemConfig | Hash[Symbol | String, untyped])?, ?ignore_violations: Hash[Symbol | String, Array[String]]?, ?enable_weaker_nested_sandbox: bool?, ?enable_weaker_network_isolation: bool?, ?ripgrep: Hash[Symbol | String, untyped]?) -> void
143
160
  | (Hash[Symbol | String, untyped]? attributes) -> void
@@ -181,15 +198,15 @@ module ClaudeAgentSDK
181
198
 
182
199
  # A system prompt loaded from a file.
183
200
  class SystemPromptFile < Type
184
- def initialize: (?path: String?) -> void
201
+ def initialize: (?path: (String | Pathname)?) -> void
185
202
  | (Hash[Symbol | String, untyped]? attributes) -> void
186
203
 
187
- attr_accessor path: String?
204
+ attr_accessor path: (String | Pathname)?
188
205
 
189
206
  # "file".
190
207
  attr_reader type: String
191
208
 
192
- def to_h: () -> { type: String, path: String? }
209
+ def to_h: () -> { type: String, path: (String | Pathname)? }
193
210
  end
194
211
 
195
212
  # The Claude Code preset system prompt, optionally extended.