claude-agent-sdk 0.35.0 → 0.37.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 (49) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +74 -0
  3. data/README.md +17 -8
  4. data/docs/cli-installer.md +16 -2
  5. data/docs/client.md +44 -4
  6. data/docs/errors.md +15 -1
  7. data/docs/hooks-and-permissions.md +27 -3
  8. data/docs/mcp-servers.md +36 -7
  9. data/docs/rails.md +3 -4
  10. data/docs/sessions.md +149 -34
  11. data/docs/types.md +106 -4
  12. data/lib/claude_agent_sdk/cancellation_signal.rb +2 -1
  13. data/lib/claude_agent_sdk/cli_installer.rb +68 -11
  14. data/lib/claude_agent_sdk/command_builder.rb +109 -98
  15. data/lib/claude_agent_sdk/deprecation.rb +90 -0
  16. data/lib/claude_agent_sdk/errors.rb +8 -0
  17. data/lib/claude_agent_sdk/fiber_boundary.rb +113 -2
  18. data/lib/claude_agent_sdk/instrumentation/otel.rb +15 -7
  19. data/lib/claude_agent_sdk/message_parser.rb +23 -9
  20. data/lib/claude_agent_sdk/observer.rb +2 -1
  21. data/lib/claude_agent_sdk/option_warnings.rb +2 -2
  22. data/lib/claude_agent_sdk/query.rb +99 -51
  23. data/lib/claude_agent_sdk/railtie.rb +14 -3
  24. data/lib/claude_agent_sdk/sdk_mcp_server.rb +58 -38
  25. data/lib/claude_agent_sdk/session_mutations.rb +28 -16
  26. data/lib/claude_agent_sdk/session_resume.rb +39 -35
  27. data/lib/claude_agent_sdk/session_store.rb +35 -21
  28. data/lib/claude_agent_sdk/session_summary.rb +12 -5
  29. data/lib/claude_agent_sdk/sessions.rb +112 -24
  30. data/lib/claude_agent_sdk/streaming.rb +1 -1
  31. data/lib/claude_agent_sdk/subprocess_cli_transport.rb +75 -52
  32. data/lib/claude_agent_sdk/tasks/claude_agent_sdk.rake +12 -5
  33. data/lib/claude_agent_sdk/testing/session_store_conformance.rb +15 -11
  34. data/lib/claude_agent_sdk/transcript_mirror_batcher.rb +19 -1
  35. data/lib/claude_agent_sdk/types/attributes.rb +271 -0
  36. data/lib/claude_agent_sdk/types/base.rb +320 -0
  37. data/lib/claude_agent_sdk/types/content_blocks.rb +57 -0
  38. data/lib/claude_agent_sdk/types/hooks.rb +640 -0
  39. data/lib/claude_agent_sdk/types/mcp.rb +232 -0
  40. data/lib/claude_agent_sdk/types/messages.rb +614 -0
  41. data/lib/claude_agent_sdk/types/option_values.rb +302 -0
  42. data/lib/claude_agent_sdk/types/options.rb +352 -0
  43. data/lib/claude_agent_sdk/types/permissions.rb +107 -0
  44. data/lib/claude_agent_sdk/types/sessions.rb +10 -0
  45. data/lib/claude_agent_sdk/types.rb +13 -2534
  46. data/lib/claude_agent_sdk/version.rb +1 -1
  47. data/lib/claude_agent_sdk.rb +308 -73
  48. data/lib/generators/claude_agent_sdk/install/templates/claude_agent_sdk.rb.tt +3 -3
  49. metadata +12 -1
@@ -71,6 +71,8 @@ module ClaudeAgentSDK
71
71
  # scheduler-aware and park only the stream task; CPU-bound or
72
72
  # scheduler-opaque work must be moved by the user (a producer Thread
73
73
  # feeding a Thread::Queue, or FiberBoundary.invoke inside the enumerator).
74
+ #
75
+ # @api private
74
76
  module FiberBoundary
75
77
  # Raised by .invoke when a timeout-bounded call exceeds its allotted time.
76
78
  # The worker thread is abandoned (cancellation is best-effort; the
@@ -133,6 +135,58 @@ module ClaudeAgentSDK
133
135
  end
134
136
  end
135
137
 
138
+ # Carries a SystemExit / SignalException (Interrupt included) raised by
139
+ # a user callback out of the FiberBoundary hop — see .invoke_callback.
140
+ # A StandardError so the hop ends normally: a :thread worker that died
141
+ # with SystemExit would have it re-raised by Ruby on the MAIN thread,
142
+ # asynchronously, before the SDK could answer the pending request.
143
+ # #cause (and #original) is the exception it carries. A callback_wrapper
144
+ # sees this carrier, never the original.
145
+ # @api private
146
+ class ProcessExitCarrier < StandardError
147
+ attr_reader :original
148
+
149
+ def initialize(original)
150
+ @original = original
151
+ super(FiberBoundary.process_exit_message(original))
152
+ end
153
+ end
154
+
155
+ # Hands a callback's process-exit exception from the code running the
156
+ # callback to the SDK code waiting for it — or, once that waiter has
157
+ # stopped waiting (hook timeout, cancelled request), lets the callback
158
+ # side re-raise it where it is, as plain Ruby would. Either way it is
159
+ # never dropped.
160
+ # @api private
161
+ class ProcessExitHandoff
162
+ def initialize
163
+ @mutex = Mutex.new
164
+ @state = :waiting
165
+ @original = nil
166
+ end
167
+
168
+ # Callback side: true when the waiter will receive +original+.
169
+ def hand_off(original)
170
+ @mutex.synchronize do
171
+ next false unless @state == :waiting
172
+
173
+ @state = :handed_off
174
+ @original = original
175
+ true
176
+ end
177
+ end
178
+
179
+ # Waiter side, when it stops waiting: the exception handed off but not
180
+ # yet received, if any.
181
+ def abandon
182
+ @mutex.synchronize do
183
+ handed_off = @state == :handed_off
184
+ @state = :abandoned
185
+ handed_off ? @original : nil
186
+ end
187
+ end
188
+ end
189
+
136
190
  # Sentinel returned by .invoke_iteration when the user block attempted `break`.
137
191
  class Break
138
192
  attr_reader :value
@@ -144,6 +198,63 @@ module ClaudeAgentSDK
144
198
 
145
199
  module_function
146
200
 
201
+ # Invoke a user callback that answers a CLI control request (hook,
202
+ # can_use_tool, SDK MCP tool / resource / prompt handler) across the
203
+ # boundary, like .invoke. A SystemExit or SignalException (Interrupt
204
+ # included) raised while the callback runs is never swallowed: it is
205
+ # re-raised here, on the calling fiber, as the ORIGINAL exception, so
206
+ # Query#handle_control_request can answer the request first and then let
207
+ # it terminate the process as Ruby normally would.
208
+ #
209
+ # The conversion must sit INSIDE the hop, innermost around the user
210
+ # call: in :thread mode a worker dying with SystemExit has it re-raised
211
+ # by Ruby on the main thread at an arbitrary point, before any response
212
+ # is written. So the worker ends with a ProcessExitCarrier instead, and
213
+ # the carrier is unwrapped once control is back on the calling fiber. In
214
+ # :inline mode the callback runs on the reactor fiber — usually the main
215
+ # thread — so this also covers a real Ctrl-C / SIGTERM delivered while
216
+ # the callback runs. A callback_wrapper sees the carrier (a
217
+ # StandardError, #cause = the original); ensure-based wrappers still
218
+ # run their cleanup, and a wrapper that swallows the carrier cannot
219
+ # swallow the exit (the handoff re-raises it).
220
+ #
221
+ # If the caller stops waiting first (hook timeout, cancelled request),
222
+ # the exception is re-raised in the abandoned worker thread instead — a
223
+ # SystemExit from a non-main thread then ends the process, as in plain
224
+ # Ruby. Cancellation (Async::Stop, InlineCancellation) is not a
225
+ # SignalException and passes through untouched.
226
+ # @api private
227
+ def invoke_callback(scheduling: :thread, wrapper: nil, &callback)
228
+ handoff = ProcessExitHandoff.new
229
+ received = false
230
+ begin
231
+ invoke(scheduling: scheduling, wrapper: wrapper) do
232
+ callback.call
233
+ rescue SystemExit, SignalException => e
234
+ raise unless handoff.hand_off(e)
235
+
236
+ raise ProcessExitCarrier, e
237
+ end
238
+ rescue ProcessExitCarrier => e
239
+ received = true
240
+ raise e.original
241
+ ensure
242
+ unless received
243
+ pending = handoff.abandon
244
+ raise pending if pending
245
+ end
246
+ end
247
+ end
248
+
249
+ # Text reporting a process-exit exception to the CLI: its class, plus
250
+ # its message when that adds anything ("SystemExit: exit",
251
+ # "SignalException: SIGTERM", "Interrupt").
252
+ # @api private
253
+ def process_exit_message(error)
254
+ detail = error.message
255
+ detail.empty? || detail == error.class.name ? error.class.name : "#{error.class}: #{detail}"
256
+ end
257
+
147
258
  # Capture only the optional OTel context before crossing a fiber/thread
148
259
  # boundary. OTel keeps its current context fiber-local; copying generic
149
260
  # thread locals would also copy unsafe connection/request state. The
@@ -267,10 +378,10 @@ module ClaudeAgentSDK
267
378
  # need a bounded wait use :thread scheduling (hard Thread#join bound);
268
379
  # inline callbacks keep their cleanup fiber-aware.
269
380
  # @api private
270
- def with_cooperative_timeout(task, timeout, on_timeout:, &block)
381
+ def with_cooperative_timeout(task, timeout, on_timeout:, &)
271
382
  cancellation = Class.new(InlineCancellation)
272
383
  begin
273
- task.with_timeout(timeout, cancellation, &block)
384
+ task.with_timeout(timeout, cancellation, &)
274
385
  rescue cancellation
275
386
  raise on_timeout.call
276
387
  end
@@ -34,7 +34,7 @@ module ClaudeAgentSDK
34
34
  # observer = ClaudeAgentSDK::Instrumentation::OTelObserver.new
35
35
  # options = ClaudeAgentSDK::ClaudeAgentOptions.new(observers: [observer])
36
36
  # ClaudeAgentSDK.query(prompt: "Hello", options: options) { |msg| ... }
37
- class OTelObserver
37
+ class OTelObserver # rubocop:disable Metrics/ClassLength -- one observer mapping every message type to spans
38
38
  include ClaudeAgentSDK::Observer
39
39
 
40
40
  TRACER_NAME = 'claude_agent_sdk'
@@ -124,7 +124,7 @@ module ClaudeAgentSDK
124
124
 
125
125
  private
126
126
 
127
- def start_trace(message)
127
+ def start_trace(message) # rubocop:disable Metrics/AbcSize -- flat mapping of init-message fields to root span attributes
128
128
  # A new init without an intervening ResultMessage (e.g. /clear or an
129
129
  # interrupted turn) supersedes the current trace; finish it so it is
130
130
  # exported instead of leaking as a never-ended span, and reset the
@@ -157,15 +157,21 @@ module ClaudeAgentSDK
157
157
  'session.id' => message.session_id
158
158
  }.merge(@default_attributes)
159
159
 
160
- attrs['claude_code.version'] = message.claude_code_version if message.respond_to?(:claude_code_version) && message.claude_code_version
160
+ if message.respond_to?(:claude_code_version) && message.claude_code_version
161
+ attrs['claude_code.version'] = message.claude_code_version
162
+ end
161
163
  attrs['claude_code.cwd'] = message.cwd if message.respond_to?(:cwd) && message.cwd
162
- attrs['claude_code.permission_mode'] = message.permission_mode if message.respond_to?(:permission_mode) && message.permission_mode
164
+ if message.respond_to?(:permission_mode) && message.permission_mode
165
+ attrs['claude_code.permission_mode'] = message.permission_mode
166
+ end
163
167
 
164
168
  @root_span = @tracer.start_span('claude_agent.session', attributes: compact_attrs(attrs))
165
169
  @root_context = OpenTelemetry::Trace.context_with_span(@root_span)
166
170
 
167
171
  # Apply buffered prompt if on_user_prompt was called before InitMessage arrived
168
- @root_span.set_attribute('input.value', truncate(@first_user_input)) if @first_user_input && !@first_user_input.empty?
172
+ return unless @first_user_input && !@first_user_input.empty?
173
+
174
+ @root_span.set_attribute('input.value', truncate(@first_user_input))
169
175
  end
170
176
 
171
177
  def handle_assistant(message)
@@ -222,7 +228,7 @@ module ClaudeAgentSDK
222
228
  end
223
229
  end
224
230
 
225
- def end_trace(message)
231
+ def end_trace(message) # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity -- flat mapping of optional usage/cost fields to span attributes
226
232
  return unless @root_span
227
233
 
228
234
  usage = message.usage || {}
@@ -235,7 +241,9 @@ module ClaudeAgentSDK
235
241
  # tokens (Anthropic's input_tokens excludes them; OpenInference's own
236
242
  # Anthropic instrumentation sums them in). gen_ai.usage.* keys keep
237
243
  # the raw exclusive values — Langfuse prices those additively.
238
- prompt_tokens = (input_tokens || 0) + (cache_creation_tokens || 0) + (cache_read_tokens || 0) if input_tokens || cache_creation_tokens || cache_read_tokens
244
+ if input_tokens || cache_creation_tokens || cache_read_tokens
245
+ prompt_tokens = (input_tokens || 0) + (cache_creation_tokens || 0) + (cache_read_tokens || 0)
246
+ end
239
247
  total_tokens = (prompt_tokens || 0) + (output_tokens || 0) if prompt_tokens || output_tokens
240
248
 
241
249
  # Set trace output (last assistant response — shown in Langfuse UI)
@@ -5,9 +5,11 @@ require_relative 'errors'
5
5
 
6
6
  module ClaudeAgentSDK
7
7
  # Parse message from CLI output into typed Message objects
8
+ #
9
+ # @api private
8
10
  class MessageParser
9
- def self.parse(data)
10
- raise MessageParseError.new("Invalid message data type", data: data) unless data.is_a?(Hash)
11
+ def self.parse(data) # rubocop:disable Metrics/CyclomaticComplexity -- flat dispatch over CLI message types
12
+ raise MessageParseError.new('Invalid message data type', data: data) unless data.is_a?(Hash)
11
13
 
12
14
  message_type = data[:type]
13
15
  raise MessageParseError.new("Message missing 'type' field", data: data) unless message_type
@@ -47,13 +49,17 @@ module ClaudeAgentSDK
47
49
  uuid = data[:uuid] # UUID for rewind support
48
50
  tool_use_result = data[:tool_use_result]
49
51
  message_data = data[:message]
50
- raise MessageParseError.new("Missing message field in user message", data: data) unless message_data
52
+ raise MessageParseError.new('Missing message field in user message', data: data) unless message_data
51
53
  # A non-Hash message (malformed CLI output) raised a raw TypeError from
52
54
  # message_data[:content] instead of the documented MessageParseError.
53
- raise MessageParseError.new("Invalid message field in user message (expected Hash, got #{message_data.class})", data: data) unless message_data.is_a?(Hash)
55
+ unless message_data.is_a?(Hash)
56
+ raise MessageParseError.new(
57
+ "Invalid message field in user message (expected Hash, got #{message_data.class})", data: data
58
+ )
59
+ end
54
60
 
55
61
  content = message_data[:content]
56
- raise MessageParseError.new("Missing content in user message", data: data) unless content
62
+ raise MessageParseError.new('Missing content in user message', data: data) unless content
57
63
 
58
64
  origin = parse_origin(data)
59
65
 
@@ -85,11 +91,17 @@ module ClaudeAgentSDK
85
91
  message_data = data[:message]
86
92
  # A non-Hash message (malformed CLI output) raised a raw TypeError from
87
93
  # dig instead of the documented MessageParseError.
88
- raise MessageParseError.new("Invalid message field in assistant message (expected Hash, got #{message_data.class})", data: data) unless message_data.is_a?(Hash)
94
+ unless message_data.is_a?(Hash)
95
+ raise MessageParseError.new(
96
+ "Invalid message field in assistant message (expected Hash, got #{message_data.class})", data: data
97
+ )
98
+ end
89
99
 
90
100
  content = message_data[:content]
91
- raise MessageParseError.new("Missing content in assistant message", data: data) unless content
92
- raise MessageParseError.new("Invalid assistant content (expected Array, got #{content.class})", data: data) unless content.is_a?(Array)
101
+ raise MessageParseError.new('Missing content in assistant message', data: data) unless content
102
+ unless content.is_a?(Array)
103
+ raise MessageParseError.new("Invalid assistant content (expected Array, got #{content.class})", data: data)
104
+ end
93
105
 
94
106
  content_blocks = parse_content_blocks(content, data)
95
107
  AssistantMessage.new(
@@ -194,7 +206,9 @@ module ClaudeAgentSDK
194
206
  # opaque TypeError/NoMethodError from `block[:type]` deep in parsing.
195
207
  def self.parse_content_blocks(content, data)
196
208
  content.map do |block|
197
- raise MessageParseError.new("Invalid content block (expected Hash, got #{block.class})", data: data) unless block.is_a?(Hash)
209
+ unless block.is_a?(Hash)
210
+ raise MessageParseError.new("Invalid content block (expected Hash, got #{block.class})", data: data)
211
+ end
198
212
 
199
213
  parse_content_block(block)
200
214
  end
@@ -39,7 +39,8 @@ module ClaudeAgentSDK
39
39
  # Client#query/#receive_messages/#receive_response/#connect (after
40
40
  # argument/configuration validation — usage errors such as 'Not
41
41
  # connected' or invalid options do not notify) — including errors raised
42
- # by the user's own message block — before on_close where both fire. query() fires on_close even for connect-phase failures (its
42
+ # by the user's own message block — before on_close where both fire.
43
+ # query() fires on_close even for connect-phase failures (its
43
44
  # ensure always runs); a Client#connect failure before the handshake
44
45
  # completes fires on_error WITHOUT on_close (the session never opened).
45
46
  # Not notified (by design): errors raised by control-request methods
@@ -1,9 +1,9 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'set'
4
-
5
3
  module ClaudeAgentSDK
6
4
  # Advisory warnings for option combinations that silently change behavior.
5
+ #
6
+ # @api private
7
7
  module OptionWarnings
8
8
  @emitted = Set.new
9
9
  @mutex = Mutex.new
@@ -1,7 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'json'
4
- require 'set'
5
4
  require 'async'
6
5
  require 'async/queue'
7
6
  require 'async/condition'
@@ -20,9 +19,17 @@ module ClaudeAgentSDK
20
19
  # - Tool permission callbacks
21
20
  # - Message streaming
22
21
  # - Initialization handshake
23
- class Query
22
+ #
23
+ # @api private
24
+ class Query # rubocop:disable Metrics/ClassLength -- control-protocol hub: routing, hooks, permissions, MCP bridge
24
25
  attr_reader :transport, :is_streaming_mode, :sdk_mcp_servers
25
26
 
27
+ # The CLI's response to the initialize control request (nil before
28
+ # #initialize_protocol completes). Read by Client#server_info.
29
+ #
30
+ # @api private
31
+ attr_reader :initialization_result
32
+
26
33
  CONTROL_REQUEST_TIMEOUT_ENV_VAR = 'CLAUDE_AGENT_SDK_CONTROL_REQUEST_TIMEOUT_SECONDS'
27
34
  DEFAULT_CONTROL_REQUEST_TIMEOUT_SECONDS = 1200.0
28
35
 
@@ -64,7 +71,7 @@ module ClaudeAgentSDK
64
71
  end
65
72
  end
66
73
 
67
- def initialize(transport:, is_streaming_mode:, can_use_tool: nil, hooks: nil, sdk_mcp_servers: nil, agents: nil,
74
+ def initialize(transport:, is_streaming_mode:, can_use_tool: nil, hooks: nil, sdk_mcp_servers: nil, agents: nil, # rubocop:disable Metrics/AbcSize, Metrics/MethodLength -- initializes every control-protocol concern in one place
68
75
  exclude_dynamic_sections: nil, system_prompt_snapshot: nil, skills: nil,
69
76
  forward_subagent_text: false, agent_progress_summaries: nil,
70
77
  callback_scheduling: :thread, callback_wrapper: nil)
@@ -133,7 +140,7 @@ module ClaudeAgentSDK
133
140
 
134
141
  # Initialize control protocol if in streaming mode
135
142
  # @return [Hash, nil] Initialize response with supported commands, or nil if not streaming
136
- def initialize_protocol
143
+ def initialize_protocol # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity -- builds the initialize request from every optional option
137
144
  return nil unless @is_streaming_mode
138
145
 
139
146
  # Build hooks configuration for initialization
@@ -238,7 +245,10 @@ module ClaudeAgentSDK
238
245
  return if @task
239
246
 
240
247
  parent = Async::Task.current?
241
- raise CLIConnectionError, 'Query#start must be called inside an Async{} block (e.g. wrap Client#connect in Async{...})' unless parent
248
+ unless parent
249
+ raise CLIConnectionError,
250
+ 'Query#start must be called inside an Async{} block (e.g. wrap Client#connect in Async{...})'
251
+ end
242
252
 
243
253
  @owning_scheduler = Fiber.scheduler
244
254
  # Async child fibers do not inherit OTel's fiber-local current context.
@@ -271,11 +281,11 @@ module ClaudeAgentSDK
271
281
  # Fine for the current one-shot call sites (max two tasks per Query); do
272
282
  # not route per-request work (control handlers, per-turn streams) through
273
283
  # this without adding completion-based removal.
274
- def spawn_task(&block)
284
+ def spawn_task(&)
275
285
  parent = Async::Task.current?
276
286
  raise CLIConnectionError, 'Query#spawn_task must be called inside an Async{} block' unless parent
277
287
 
278
- task = parent.async(&FiberBoundary.capture_otel_context(&block))
288
+ task = parent.async(&FiberBoundary.capture_otel_context(&))
279
289
  @child_tasks << task
280
290
  task
281
291
  end
@@ -323,8 +333,8 @@ module ClaudeAgentSDK
323
333
  DEFAULT_CONTROL_REQUEST_TIMEOUT_SECONDS
324
334
  end
325
335
 
326
- def read_messages
327
- @transport.read_messages do |message|
336
+ def read_messages # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity -- concurrency-sensitive read loop; kept whole on purpose
337
+ @transport.read_messages do |message| # rubocop:disable Metrics/BlockLength -- see read_messages
328
338
  break if @closed
329
339
 
330
340
  msg_type = message[:type]
@@ -339,14 +349,12 @@ module ClaudeAgentSDK
339
349
  # nothing keeps running after close; bare Async do may root at the
340
350
  # reactor and leak past shutdown.
341
351
  handler_task = Async::Task.current.async(&FiberBoundary.capture_otel_context do
342
- begin
343
- handle_control_request(message)
344
- ensure
345
- # Identity-guarded: if the CLI ever reused an in-flight request
346
- # id, the later handler owns the slot and must stay cancellable.
347
- if request_id && @inflight_control_request_tasks[request_id].equal?(Async::Task.current)
348
- @inflight_control_request_tasks.delete(request_id)
349
- end
352
+ handle_control_request(message)
353
+ ensure
354
+ # Identity-guarded: if the CLI ever reused an in-flight request
355
+ # id, the later handler owns the slot and must stay cancellable.
356
+ if request_id && @inflight_control_request_tasks[request_id].equal?(Async::Task.current)
357
+ @inflight_control_request_tasks.delete(request_id)
350
358
  end
351
359
  end)
352
360
  # A handler that never suspends (MCP metadata, unsupported-subtype
@@ -384,11 +392,7 @@ module ClaudeAgentSDK
384
392
  @first_result_received = true
385
393
  @first_result_condition.signal
386
394
  end
387
- if message[:is_error]
388
- @last_error_result = message
389
- else
390
- @last_error_result = nil
391
- end
395
+ @last_error_result = message[:is_error] ? message : nil
392
396
  elsif !(msg_type == 'system' && message[:subtype] == 'session_state_changed')
393
397
  # Anything other than the post-turn session_state_changed marker
394
398
  # means the conversation moved on; a ProcessError now is a fresh
@@ -534,11 +538,12 @@ module ClaudeAgentSDK
534
538
  waiter = @pending_control_responses[request_id]
535
539
  return unless waiter
536
540
 
537
- if response[:subtype] == 'error'
538
- @pending_control_results[request_id] = StandardError.new(response[:error] || 'Unknown error')
539
- else
540
- @pending_control_results[request_id] = response
541
- end
541
+ @pending_control_results[request_id] =
542
+ if response[:subtype] == 'error'
543
+ StandardError.new(response[:error] || 'Unknown error')
544
+ else
545
+ response
546
+ end
542
547
 
543
548
  # Signal that response is ready. INVARIANT: the result slot above
544
549
  # MUST be written before this signal — senders check the slot before
@@ -546,7 +551,7 @@ module ClaudeAgentSDK
546
551
  waiter.signal
547
552
  end
548
553
 
549
- def handle_control_request(request)
554
+ def handle_control_request(request) # rubocop:disable Metrics/MethodLength -- subtype dispatch plus the shared error response
550
555
  request_id = request[:request_id] || request[:requestId]
551
556
  request_data = request[:request]
552
557
  subtype = request_data[:subtype]
@@ -575,13 +580,50 @@ module ClaudeAgentSDK
575
580
  }
576
581
  }
577
582
  writeln(JSON.generate(success_response))
583
+ responded = true
578
584
  rescue Async::Stop
579
585
  # Cancellation requested; respond with an error so the CLI can unblock.
580
586
  send_control_error(request_id, 'Cancelled')
587
+ rescue SystemExit, SignalException => e
588
+ # exit / Interrupt / a signal raised while a user callback ran
589
+ # (FiberBoundary.invoke_callback re-raises it here, on the reactor) —
590
+ # or a real signal landing on this fiber. Never swallowed: answer the
591
+ # request the way an ordinary callback failure is answered, so the CLI
592
+ # is not left waiting, then let it terminate the process as Ruby
593
+ # normally would. The transport flushes every write.
594
+ respond_to_process_exit(request_id, request_data, e) unless responded
595
+ raise
581
596
  rescue StandardError => e
582
597
  send_control_error(request_id, e.message)
583
598
  end
584
599
 
600
+ # The response an ordinary exception from the callback would have
601
+ # produced, with the process-exit exception named by class: an error
602
+ # control response for hooks / can_use_tool; for SDK MCP requests an
603
+ # in-band isError result (tools/call) or a JSON-RPC internal error
604
+ # (resources/read, prompts/get), inside a successful control response.
605
+ def respond_to_process_exit(request_id, request_data, error)
606
+ message = FiberBoundary.process_exit_message(error)
607
+ mcp_message = request_data[:message] if request_data.is_a?(Hash) && request_data[:subtype] == 'mcp_message'
608
+ return send_control_error(request_id, message) unless mcp_message.is_a?(Hash)
609
+
610
+ mcp_response = { jsonrpc: '2.0', id: mcp_message[:id] }
611
+ if mcp_message[:method] == 'tools/call'
612
+ mcp_response[:result] = { content: [{ type: 'text', text: message }], isError: true }
613
+ else
614
+ mcp_response[:error] = { code: -32_603, message: message }
615
+ end
616
+ writeln(JSON.generate({
617
+ type: 'control_response',
618
+ response: {
619
+ subtype: 'success', request_id: request_id, requestId: request_id,
620
+ response: { mcp_response: mcp_response }
621
+ }
622
+ }))
623
+ rescue CLIConnectionError
624
+ nil # the CLI is already gone; nothing is waiting for the answer
625
+ end
626
+
585
627
  def send_control_error(request_id, message)
586
628
  error_response = {
587
629
  type: 'control_response',
@@ -600,7 +642,7 @@ module ClaudeAgentSDK
600
642
  nil
601
643
  end
602
644
 
603
- def handle_permission_request(request_data, request_id: nil)
645
+ def handle_permission_request(request_data, request_id: nil) # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity -- permission round-trip: input, callback, result conversion
604
646
  raise 'canUseTool callback is not provided' unless @can_use_tool
605
647
 
606
648
  signal = CancellationSignal.new
@@ -608,13 +650,17 @@ module ClaudeAgentSDK
608
650
  original_input = request_data[:input]
609
651
 
610
652
  # Field order mirrors Python _internal/query.py's can_use_tool branch.
611
- # Suggestions are hydrated into PermissionUpdate (Python #920); a
612
- # malformed entry raises here, on the reactor, and becomes an error
613
- # control_response — same observable behavior as Python.
653
+ # Suggestions are hydrated into PermissionUpdate (Python #920) through
654
+ # the lenient .wrap, so fields a newer CLI adds never trip the
655
+ # strict-attribute warning meant for user-built updates. A nil (or
656
+ # false) entry becomes an empty PermissionUpdate, as PermissionUpdate.new
657
+ # made it before; any other non-Hash entry raises here, on the reactor,
658
+ # and becomes an error control_response — same observable behavior as
659
+ # Python.
614
660
  context = ToolPermissionContext.new(
615
661
  signal: signal,
616
662
  request_id: request_id,
617
- suggestions: (request_data[:permission_suggestions] || []).map { |s| PermissionUpdate.new(s) },
663
+ suggestions: (request_data[:permission_suggestions] || []).map { |s| PermissionUpdate.wrap(s || {}) },
618
664
  tool_use_id: request_data[:tool_use_id],
619
665
  agent_id: request_data[:agent_id],
620
666
  blocked_path: request_data[:blocked_path],
@@ -628,8 +674,10 @@ module ClaudeAgentSDK
628
674
  # so AR/PG calls inside it aren't intercepted by the Fiber scheduler;
629
675
  # with callback_scheduling: :inline it runs in place on this control-
630
676
  # request task, where control_cancel_request (task.stop) can actually
631
- # cancel it at suspension points.
632
- response = FiberBoundary.invoke(scheduling: @callback_scheduling, wrapper: @callback_wrapper) do
677
+ # cancel it at suspension points. exit / Interrupt from the callback
678
+ # re-raise here after the hop; handle_control_request answers the
679
+ # request before letting them propagate (FiberBoundary.invoke_callback).
680
+ response = FiberBoundary.invoke_callback(scheduling: @callback_scheduling, wrapper: @callback_wrapper) do
633
681
  @can_use_tool.call(request_data[:tool_name], request_data[:input], context)
634
682
  end
635
683
  # A worker may return a decision after the read loop invalidated the
@@ -643,9 +691,7 @@ module ClaudeAgentSDK
643
691
  behavior: 'allow',
644
692
  updatedInput: response.updated_input || original_input
645
693
  }
646
- if response.updated_permissions
647
- result[:updatedPermissions] = response.updated_permissions.map(&:to_h)
648
- end
694
+ result[:updatedPermissions] = response.updated_permissions.map(&:to_h) if response.updated_permissions
649
695
  result
650
696
  when PermissionResultDeny
651
697
  result = { behavior: 'deny', message: response.message }
@@ -661,7 +707,7 @@ module ClaudeAgentSDK
661
707
  untrack_callback_signal(request_id, signal)
662
708
  end
663
709
 
664
- def handle_hook_callback(request_data, request_id: nil)
710
+ def handle_hook_callback(request_data, request_id: nil) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength -- hook round-trip: timeout, callback, output conversion
665
711
  callback_id = request_data[:callback_id]
666
712
  callback = @hook_callbacks[callback_id]
667
713
  raise "No hook callback found for ID: #{callback_id}" unless callback
@@ -684,8 +730,11 @@ module ClaudeAgentSDK
684
730
  # genuine cooperative cancellation: the hook is interrupted at its next
685
731
  # suspension point and its ensure blocks run (Python parity — anyio
686
732
  # cancels the coroutine). A CPU-stuck inline hook cannot be timed out.
733
+ # All three variants go through FiberBoundary.invoke_callback, so exit
734
+ # / Interrupt from the hook reach handle_control_request, which answers
735
+ # the request before letting them propagate.
687
736
  unless @hook_callback_timeouts[callback_id]
688
- hook_output = FiberBoundary.invoke(scheduling: @callback_scheduling, wrapper: @callback_wrapper) do
737
+ hook_output = FiberBoundary.invoke_callback(scheduling: @callback_scheduling, wrapper: @callback_wrapper) do
689
738
  callback.call(hook_input, request_data[:tool_use_id], context)
690
739
  end
691
740
  end
@@ -709,13 +758,13 @@ module ClaudeAgentSDK
709
758
  Async::Task.current, timeout,
710
759
  on_timeout: -> { Async::TimeoutError.new('execution expired') }
711
760
  ) do
712
- FiberBoundary.invoke(scheduling: :inline, wrapper: @callback_wrapper) do
761
+ FiberBoundary.invoke_callback(scheduling: :inline, wrapper: @callback_wrapper) do
713
762
  callback.call(hook_input, request_data[:tool_use_id], context)
714
763
  end
715
764
  end
716
765
  else
717
766
  Async::Task.current.with_timeout(timeout) do
718
- FiberBoundary.invoke(wrapper: @callback_wrapper) do
767
+ FiberBoundary.invoke_callback(wrapper: @callback_wrapper) do
719
768
  callback.call(hook_input, request_data[:tool_use_id], context)
720
769
  end
721
770
  end
@@ -743,7 +792,7 @@ module ClaudeAgentSDK
743
792
  @callback_request_signals.delete(request_id)
744
793
  end
745
794
 
746
- def parse_hook_input(input_data)
795
+ def parse_hook_input(input_data) # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength -- one branch per hook event type
747
796
  event_name = input_data[:hook_event_name] || input_data['hook_event_name']
748
797
  fetch = lambda do |key|
749
798
  if input_data.key?(key)
@@ -976,11 +1025,9 @@ module ClaudeAgentSDK
976
1025
  { mcp_response: mcp_response }
977
1026
  end
978
1027
 
979
- def convert_hook_output_for_cli(hook_output)
1028
+ def convert_hook_output_for_cli(hook_output) # rubocop:disable Metrics/CyclomaticComplexity -- one optional field per hook output key
980
1029
  # Handle typed output objects
981
- if hook_output.respond_to?(:to_h) && !hook_output.is_a?(Hash)
982
- return hook_output.to_h
983
- end
1030
+ return hook_output.to_h if hook_output.respond_to?(:to_h) && !hook_output.is_a?(Hash)
984
1031
 
985
1032
  return {} unless hook_output.is_a?(Hash)
986
1033
 
@@ -1094,7 +1141,7 @@ module ClaudeAgentSDK
1094
1141
  end
1095
1142
  end
1096
1143
 
1097
- def handle_sdk_mcp_request(server_name, message)
1144
+ def handle_sdk_mcp_request(server_name, message) # rubocop:disable Metrics/CyclomaticComplexity, Metrics/MethodLength -- JSON-RPC method dispatch for SDK MCP servers
1098
1145
  # Carry this session's scheduling mode and callback wrapper across the
1099
1146
  # dispatch into the (possibly session-shared) SdkMcpServer via fiber
1100
1147
  # storage — set on the dispatching fiber, read back by the server's
@@ -1121,7 +1168,7 @@ module ClaudeAgentSDK
1121
1168
  jsonrpc: '2.0',
1122
1169
  id: message[:id],
1123
1170
  error: {
1124
- code: -32601,
1171
+ code: -32_601,
1125
1172
  message: "Server '#{server_name}' not found"
1126
1173
  }
1127
1174
  }
@@ -1152,14 +1199,14 @@ module ClaudeAgentSDK
1152
1199
  {
1153
1200
  jsonrpc: '2.0',
1154
1201
  id: message[:id],
1155
- error: { code: -32601, message: "Method '#{method}' not found" }
1202
+ error: { code: -32_601, message: "Method '#{method}' not found" }
1156
1203
  }
1157
1204
  end
1158
1205
  rescue StandardError => e
1159
1206
  {
1160
1207
  jsonrpc: '2.0',
1161
1208
  id: message[:id],
1162
- error: { code: -32603, message: e.message }
1209
+ error: { code: -32_603, message: e.message }
1163
1210
  }
1164
1211
  ensure
1165
1212
  dispatch_scope&.close
@@ -1388,6 +1435,7 @@ module ClaudeAgentSDK
1388
1435
  wrote_message = false
1389
1436
  stream.each do |message|
1390
1437
  break if @closed
1438
+
1391
1439
  serialized = message.is_a?(Hash) ? JSON.generate(message) : message.to_s
1392
1440
  writeln(serialized)
1393
1441
  wrote_message = true