claude-agent-sdk 0.17.0 → 0.18.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.
@@ -39,11 +39,79 @@ module ClaudeAgentSDK
39
39
  def self.notify_observers(observers, method, *args)
40
40
  observers.each do |obs|
41
41
  FiberBoundary.invoke { obs.send(method, *args) }
42
- rescue StandardError
42
+ rescue StandardError, ScriptError
43
+ # ScriptError too: NotImplementedError < ScriptError (not
44
+ # StandardError), and a stubbed observer must never mask the original
45
+ # error being notified or abort connect/teardown cleanup.
43
46
  nil
44
47
  end
45
48
  end
46
49
 
50
+ # Extract the user-visible prompt text from a streamed input item, or nil
51
+ # when there is none (non-user messages, tool_result-only content, …).
52
+ # Only Hash and JSON-string items are inspected; arbitrary objects written
53
+ # via to_s are never notified.
54
+ def self.extract_user_prompt_text(message)
55
+ data = case message
56
+ when Hash then message
57
+ when String
58
+ # Cheap prefilter: skip the full parse for items that cannot be
59
+ # user messages (e.g. multi-MB tool_result frames) — parsing
60
+ # would block the reactor fiber for the duration. False
61
+ # positives just cost one parse; correctness is unchanged.
62
+ return nil unless message.include?('user')
63
+
64
+ begin
65
+ JSON.parse(message)
66
+ rescue JSON::ParserError
67
+ nil
68
+ end
69
+ end
70
+ return nil unless data.is_a?(Hash)
71
+ return nil unless (data[:type] || data['type']) == 'user'
72
+
73
+ inner = data[:message] || data['message']
74
+ return nil unless inner.is_a?(Hash)
75
+
76
+ prompt_text_from_content(inner[:content] || inner['content'])
77
+ end
78
+
79
+ # Text from a user-message content payload: the string itself, or the
80
+ # newline-joined non-empty top-level text blocks. Returns nil (never '')
81
+ # when there is no extractable text — on_user_prompt('') would latch
82
+ # OTelObserver's first-prompt buffer while never setting the attribute,
83
+ # permanently suppressing later real prompts.
84
+ def self.prompt_text_from_content(content)
85
+ case content
86
+ when String
87
+ content.empty? ? nil : content
88
+ when Array
89
+ texts = content.filter_map do |block|
90
+ next unless block.is_a?(Hash)
91
+ next unless (block[:type] || block['type']) == 'text'
92
+
93
+ text = block[:text] || block['text']
94
+ text unless text.to_s.empty?
95
+ end
96
+ texts.empty? ? nil : texts.join("\n")
97
+ end
98
+ end
99
+
100
+ # Wrap a streaming-input enumerable so observers get on_user_prompt for
101
+ # each user message before it is written to stdin. Identity when no
102
+ # observers are configured.
103
+ def self.observing_prompt_stream(prompt, observers)
104
+ return prompt if observers.empty?
105
+
106
+ Enumerator.new do |yielder|
107
+ prompt.each do |message|
108
+ text = extract_user_prompt_text(message)
109
+ notify_observers(observers, :on_user_prompt, text) if text
110
+ yielder << message
111
+ end
112
+ end
113
+ end
114
+
47
115
  # Look up a value in a hash that may use symbol or string keys in camelCase or snake_case.
48
116
  # Returns the first non-nil value found, preserving false as a meaningful value.
49
117
  def self.flexible_fetch(hash, camel_key, snake_key)
@@ -54,35 +122,6 @@ module ClaudeAgentSDK
54
122
  val
55
123
  end
56
124
 
57
- # Query Claude Code for one-shot or unidirectional streaming interactions
58
- #
59
- # This function is ideal for simple, stateless queries where you don't need
60
- # bidirectional communication or conversation management.
61
- #
62
- # @param prompt [String, Enumerator] The prompt to send to Claude, or an Enumerator for streaming input
63
- # @param options [ClaudeAgentOptions] Optional configuration
64
- # @yield [Message] Each message from the conversation
65
- # @return [Enumerator] if no block given
66
- #
67
- # @example Simple query
68
- # ClaudeAgentSDK.query(prompt: "What is 2 + 2?") do |message|
69
- # puts message
70
- # end
71
- #
72
- # @example With options
73
- # options = ClaudeAgentSDK::ClaudeAgentOptions.new(
74
- # allowed_tools: ['Read', 'Bash'],
75
- # permission_mode: 'acceptEdits'
76
- # )
77
- # ClaudeAgentSDK.query(prompt: "Create a hello.rb file", options: options) do |msg|
78
- # puts msg.text if msg.is_a?(ClaudeAgentSDK::AssistantMessage)
79
- # end
80
- #
81
- # @example Streaming input
82
- # messages = Streaming.from_array(['Hello', 'What is 2+2?', 'Thanks!'])
83
- # ClaudeAgentSDK.query(prompt: messages) do |message|
84
- # puts message
85
- # end
86
125
  # List sessions for a directory (or all sessions)
87
126
  # @param directory [String, nil] Working directory to list sessions for
88
127
  # @param limit [Integer, nil] Maximum number of sessions to return
@@ -111,6 +150,26 @@ module ClaudeAgentSDK
111
150
  Sessions.get_session_messages(session_id: session_id, directory: directory, limit: limit, offset: offset)
112
151
  end
113
152
 
153
+ # List subagent IDs recorded for a session on local disk
154
+ # @param session_id [String] The session UUID
155
+ # @param directory [String, nil] Working directory to search in
156
+ # @return [Array<String>] Subagent IDs
157
+ def self.list_subagents(session_id:, directory: nil)
158
+ Sessions.list_subagents(session_id: session_id, directory: directory)
159
+ end
160
+
161
+ # Read a subagent's conversation messages from local disk
162
+ # @param session_id [String] The session UUID
163
+ # @param agent_id [String] The subagent ID (without the agent- prefix)
164
+ # @param directory [String, nil] Working directory to search in
165
+ # @param limit [Integer, nil] Maximum number of messages
166
+ # @param offset [Integer] Number of messages to skip
167
+ # @return [Array<SessionMessage>] Ordered messages from the subagent
168
+ def self.get_subagent_messages(session_id:, agent_id:, directory: nil, limit: nil, offset: 0)
169
+ Sessions.get_subagent_messages(session_id: session_id, agent_id: agent_id,
170
+ directory: directory, limit: limit, offset: offset)
171
+ end
172
+
114
173
  # Rename a session by appending a custom-title entry
115
174
  # @param session_id [String] UUID of the session to rename
116
175
  # @param title [String] New session title
@@ -246,8 +305,43 @@ module ClaudeAgentSDK
246
305
  include_subagents: include_subagents, batch_size: batch_size)
247
306
  end
248
307
 
249
- def self.query(prompt:, options: nil, &block)
250
- return enum_for(:query, prompt: prompt, options: options) unless block
308
+ # Query Claude Code for one-shot or unidirectional streaming interactions
309
+ #
310
+ # This function is ideal for simple, stateless queries where you don't need
311
+ # bidirectional communication or conversation management.
312
+ #
313
+ # @param prompt [String, Enumerator] The prompt to send to Claude, or an Enumerator for streaming input
314
+ # @param options [ClaudeAgentOptions] Optional configuration
315
+ # @yield [Message] Each message from the conversation
316
+ # @return [Enumerator] if no block given. Internal iteration only: consume
317
+ # with #each or each-driven Enumerable methods (#first, #take, #map,
318
+ # #to_a). External iteration (#next, #peek, #rewind) is NOT supported —
319
+ # message delivery runs inside the SDK's Async reactor, which cannot run
320
+ # on the Enumerator's fiber; #next raises or hangs depending on context.
321
+ # @note An attempted #next may still spawn the CLI subprocess before
322
+ # failing and leaves the query unusable.
323
+ #
324
+ # @example Simple query
325
+ # ClaudeAgentSDK.query(prompt: "What is 2 + 2?") do |message|
326
+ # puts message
327
+ # end
328
+ #
329
+ # @example With options
330
+ # options = ClaudeAgentSDK::ClaudeAgentOptions.new(
331
+ # allowed_tools: ['Read', 'Bash'],
332
+ # permission_mode: 'acceptEdits'
333
+ # )
334
+ # ClaudeAgentSDK.query(prompt: "Create a hello.rb file", options: options) do |msg|
335
+ # puts msg.text if msg.is_a?(ClaudeAgentSDK::AssistantMessage)
336
+ # end
337
+ #
338
+ # @example Streaming input
339
+ # messages = Streaming.from_array(['Hello', 'What is 2+2?', 'Thanks!'])
340
+ # ClaudeAgentSDK.query(prompt: messages) do |message|
341
+ # puts message
342
+ # end
343
+ def self.query(prompt:, options: nil, transport: nil, &block)
344
+ return enum_for(:query, prompt: prompt, options: options, transport: transport) unless block
251
345
 
252
346
  options ||= ClaudeAgentOptions.new
253
347
 
@@ -269,23 +363,31 @@ module ClaudeAgentSDK
269
363
  # Resolve callable observers into fresh instances (thread-safe for global defaults)
270
364
  resolved_observers = ClaudeAgentSDK.resolve_observers(configured_options.observers)
271
365
 
366
+ raise ArgumentError, 'transport must respond to #connect (see ClaudeAgentSDK::Transport)' if transport && !transport.respond_to?(:connect)
367
+
272
368
  Async do
273
369
  materialized = nil
274
- transport = nil
275
370
  query_handler = nil
276
371
  begin
277
- # Resume-from-store: when a session_store is set and resume/continue is
278
- # requested, load the session into a temp CLAUDE_CONFIG_DIR and repoint
279
- # options at it (env + --resume) BEFORE spawning. Returns options
280
- # unchanged when no materialization applies. query() always uses the
281
- # default subprocess transport, so no custom-transport gate is needed.
282
- materialized = SessionResume.materialize_resume_session(configured_options)
283
- configured_options = SessionResume.apply_materialized_options(configured_options, materialized) if materialized
284
-
285
- # Always use streaming mode with control protocol (matches Python SDK).
286
- # This sends agents via initialize request instead of CLI args,
287
- # avoiding OS ARG_MAX limits.
288
- transport = SubprocessCLITransport.new(configured_options)
372
+ if transport.nil?
373
+ # Resume-from-store: when a session_store is set and resume/continue
374
+ # is requested, load the session into a temp CLAUDE_CONFIG_DIR and
375
+ # repoint options at it (env + --resume) BEFORE spawning. Returns
376
+ # options unchanged when no materialization applies. Skipped
377
+ # entirely for an injected transport — the materialized
378
+ # env/--resume only apply to the CLI subprocess (Python parity:
379
+ # client.py skips materialization when a transport is supplied).
380
+ materialized = SessionResume.materialize_resume_session(configured_options)
381
+ configured_options = SessionResume.apply_materialized_options(configured_options, materialized) if materialized
382
+
383
+ # Always use streaming mode with control protocol (matches Python
384
+ # SDK). This sends agents via initialize request instead of CLI
385
+ # args, avoiding OS ARG_MAX limits.
386
+ transport = SubprocessCLITransport.new(configured_options)
387
+ end
388
+ # Deliberate deviation from Python: the ensure below also closes an
389
+ # injected transport whose #connect raised (Python leaves it
390
+ # unclosed); Transport#close must be idempotent.
289
391
  transport.connect
290
392
 
291
393
  # Extract SDK MCP servers
@@ -323,7 +425,8 @@ module ClaudeAgentSDK
323
425
  can_use_tool: configured_options.can_use_tool,
324
426
  hooks: hooks,
325
427
  agents: configured_options.agents,
326
- sdk_mcp_servers: sdk_mcp_servers
428
+ sdk_mcp_servers: sdk_mcp_servers,
429
+ skills: configured_options.skills
327
430
  )
328
431
 
329
432
  # Mirror transcripts to the session_store, if configured. Installed
@@ -355,11 +458,18 @@ module ClaudeAgentSDK
355
458
  session_id: ''
356
459
  }
357
460
  transport.write(JSON.generate(message) + "\n")
358
- query_handler.wait_for_result_and_end_input
461
+ # Background-spawn so messages stream to the user block while stdin
462
+ # close waits (without timeout) for the first result; a synchronous
463
+ # call would defer all delivery until the turn completes (mirrors
464
+ # Python's query.spawn_task(query.wait_for_result_and_end_input())).
465
+ query_handler.spawn_task { query_handler.wait_for_result_and_end_input }
359
466
  elsif prompt.is_a?(Enumerator) || prompt.respond_to?(:each)
360
- Async do
361
- query_handler.stream_input(prompt)
362
- end
467
+ # Tracked on the Query so close() stops it; an untracked Async task
468
+ # here kept the root reactor alive forever when the read loop died
469
+ # while the user enumerator was still blocked (matches Python's
470
+ # query.spawn_task(query.stream_input(prompt))).
471
+ observed_prompt = ClaudeAgentSDK.observing_prompt_stream(prompt, resolved_observers)
472
+ query_handler.spawn_task { query_handler.stream_input(observed_prompt) }
363
473
  end
364
474
 
365
475
  # Read and yield messages from the query handler (filters out control messages).
@@ -367,11 +477,20 @@ module ClaudeAgentSDK
367
477
  # inside it don't see the async gem's Fiber scheduler.
368
478
  query_handler.receive_messages do |data|
369
479
  message = MessageParser.parse(data)
370
- if message
371
- ClaudeAgentSDK.notify_observers(resolved_observers, :on_message, message)
372
- FiberBoundary.invoke { block.call(message) }
373
- end
480
+ next unless message
481
+
482
+ ClaudeAgentSDK.notify_observers(resolved_observers, :on_message, message)
483
+ signal = FiberBoundary.invoke_iteration(block, message)
484
+ break signal.value if signal.is_a?(FiberBoundary::Break)
374
485
  end
486
+ rescue StandardError => e
487
+ # One notify point for every error surfacing from query() — transport
488
+ # connect, initialize, stream errors re-raised from the message queue,
489
+ # parse errors, and user-block errors. StandardError only: Async::Stop
490
+ # is cancellation, not an error. Bare raise preserves the backtrace;
491
+ # the ensure below still fires on_close after on_error.
492
+ ClaudeAgentSDK.notify_observers(resolved_observers, :on_error, e)
493
+ raise
375
494
  ensure
376
495
  ClaudeAgentSDK.notify_observers(resolved_observers, :on_close)
377
496
  # query_handler.close stops the background read task and closes the
@@ -446,12 +565,49 @@ module ClaudeAgentSDK
446
565
  @materialized = nil
447
566
  end
448
567
 
568
+ # Block-scoped Client lifecycle, mirroring Python's
569
+ # `async with ClaudeSDKClient() as client` and File.open ergonomics:
570
+ # connects, yields the client, and always disconnects (block exceptions
571
+ # propagate). Kernel#Sync runs inline inside an existing reactor and
572
+ # creates one otherwise, so this works standalone too. Returns the
573
+ # block's value.
574
+ #
575
+ # @param prompt [String, Enumerator, nil] Optional initial prompt (same as #connect)
576
+ # @note In standalone (non-Async) use, `break` inside the block raises
577
+ # LocalJumpError (teardown still runs) — return a value instead.
578
+ # @example
579
+ # ClaudeAgentSDK::Client.open(options: options) do |client|
580
+ # client.query('Hello')
581
+ # client.receive_response { |msg| puts msg }
582
+ # end
583
+ def self.open(prompt = nil, options: nil, transport_class: SubprocessCLITransport, transport_args: {})
584
+ raise ArgumentError, 'Client.open requires a block' unless block_given?
585
+
586
+ Sync do
587
+ client = new(options: options, transport_class: transport_class, transport_args: transport_args)
588
+ # connect failures self-clean via connect's rescue -> disconnect ->
589
+ # raise, and disconnect is idempotent — no double-teardown.
590
+ client.connect(prompt)
591
+ begin
592
+ yield client
593
+ ensure
594
+ client.disconnect
595
+ end
596
+ end
597
+ end
598
+
449
599
  # Connect to Claude with optional initial prompt.
450
600
  #
451
601
  # Client always uses streaming mode for bidirectional communication. If you
452
602
  # pass a String, it will be sent as an initial user message after the
453
603
  # connection is established. If you pass an Enumerator, it should yield
454
- # JSONL messages (e.g., from ClaudeAgentSDK::Streaming.user_message).
604
+ # JSONL messages (e.g., from ClaudeAgentSDK::Streaming.user_message);
605
+ # the stream is consumed in the BACKGROUND (connect returns immediately)
606
+ # and stdin closes when it is exhausted, so the stream is the session's
607
+ # input — a later #query after exhaustion will fail. Enumerator code runs
608
+ # on the reactor: use a producer Thread + Thread::Queue for blocking
609
+ # reads (Queue#pop is scheduler-aware). Stream errors are reported via
610
+ # Observer#on_error and logged, not raised out of connect.
455
611
  #
456
612
  # @param prompt [String, Enumerator, nil] Initial prompt or message stream
457
613
  def connect(prompt = nil)
@@ -470,21 +626,35 @@ module ClaudeAgentSDK
470
626
  end
471
627
 
472
628
  # Fail fast on invalid session_store combinations before spawning the CLI.
629
+ # Configuration validation is a usage error, like the ArgumentErrors
630
+ # above — deliberately outside the on_error notify scope.
473
631
  SessionStores.validate_session_store_options(configured_options)
474
632
 
475
- # Resume-from-store: materialize the session from the store into a temp
476
- # CLAUDE_CONFIG_DIR BEFORE spawn, then repoint options at it. Skipped for
477
- # custom transports (the materialized env/--resume only apply to the CLI
478
- # subprocess). On any later connect failure, disconnect cleans up the dir.
479
- configured_options = materialize_resume(configured_options)
633
+ # Resolve observers before the first failable runtime step so
634
+ # connect-phase failures (including resume materialization) can be
635
+ # notified via on_error.
636
+ @resolved_observers = ClaudeAgentSDK.resolve_observers(@options.observers)
480
637
 
481
- # If anything after materialization fails, tear down (closes the
638
+ # If anything from materialization onward fails, tear down (closes the
482
639
  # subprocess and removes the materialized temp config dir) before
483
640
  # surfacing the error, so a partial connect never leaks a temp dir
484
641
  # holding a credential copy.
485
642
  begin
643
+ # Resume-from-store: materialize the session from the store into a
644
+ # temp CLAUDE_CONFIG_DIR BEFORE spawn, then repoint options at it.
645
+ # Inside the instrumented begin so store IO failures fire on_error
646
+ # (matching the one-shot query() path) and disconnect cleans up.
647
+ configured_options = materialize_resume(configured_options)
648
+
486
649
  connect_inner(configured_options, prompt)
487
- rescue Exception # rubocop:disable Lint/RescueException
650
+ rescue Exception => e # rubocop:disable Lint/RescueException
651
+ # Pre-handshake failures (@connected still false) are notified here;
652
+ # post-handshake String-prompt send failures were already notified by
653
+ # the instrumented #query — the gate keeps on_error exactly-once.
654
+ # (The enumerator branch streams in the background and cannot raise
655
+ # out of connect.) No on_close follows for pre-handshake failures
656
+ # (disconnect gates it on @connected): the session never opened.
657
+ notify_error(e) if e.is_a?(StandardError) && !@connected
488
658
  # Tear down the partial connect, but never let a cleanup failure (e.g. a
489
659
  # custom transport whose #close raises) mask the original connect error.
490
660
  # Rescue Exception (not StandardError) so reactor cancellation
@@ -493,42 +663,78 @@ module ClaudeAgentSDK
493
663
  # CLAUDE_CONFIG_DIR that holds the redacted .credentials.json copy.
494
664
  begin
495
665
  disconnect
496
- rescue StandardError => e
497
- warn "Claude SDK: cleanup after failed connect raised: #{e.message}"
666
+ rescue StandardError => cleanup_error
667
+ warn "Claude SDK: cleanup after failed connect raised: #{cleanup_error.message}"
498
668
  end
499
669
  raise
500
670
  end
501
671
  end
502
672
 
503
673
  # Send a query to Claude
504
- # @param prompt [String] The prompt to send
674
+ # @param prompt [String, Enumerable] The prompt to send — a String, or an
675
+ # Enumerable of message Hashes / JSONL Strings streamed inline (blocks
676
+ # until exhausted, like Python's async-for). Hashes lacking a
677
+ # session_id are stamped with the session_id: argument; JSONL Strings
678
+ # pass through VERBATIM — generate them with the matching session_id
679
+ # (Streaming.user_message defaults to 'default'). Bare Hashes are
680
+ # rejected (they would iterate as key-value pairs).
505
681
  # @param session_id [String] Session identifier
506
682
  def query(prompt, session_id: 'default')
507
683
  raise CLIConnectionError, 'Not connected. Call connect() first' unless @connected
684
+ # A bare Hash responds to #each and would silently iterate [key, value]
685
+ # pairs (Python's async-for over a dict raises TypeError).
686
+ raise ArgumentError, 'prompt must be a String or an Enumerable of message Hashes/JSONL Strings (got Hash)' if prompt.is_a?(Hash)
508
687
 
509
- ClaudeAgentSDK.notify_observers(@resolved_observers, :on_user_prompt, prompt)
510
- message = {
511
- type: 'user',
512
- message: { role: 'user', content: prompt },
513
- parent_tool_use_id: nil,
514
- session_id: session_id
515
- }
516
- writeln(JSON.generate(message))
688
+ begin
689
+ if prompt.is_a?(String)
690
+ ClaudeAgentSDK.notify_observers(@resolved_observers, :on_user_prompt, prompt)
691
+ message = {
692
+ type: 'user',
693
+ message: { role: 'user', content: prompt },
694
+ parent_tool_use_id: nil,
695
+ session_id: session_id
696
+ }
697
+ writeln(JSON.generate(message))
698
+ elsif prompt.respond_to?(:each)
699
+ # Inline iteration on the caller, Python client.py parity — NOT
700
+ # Query#stream_input, whose ensure always ends input after
701
+ # exhaustion (correct for connect-time sole-input streams, fatal
702
+ # for a mid-session query). Blocks until the iterable is exhausted,
703
+ # identical to Python's async-for.
704
+ stream_query_messages(prompt, session_id)
705
+ else
706
+ raise ArgumentError, "prompt must be a String or respond to #each (got #{prompt.class})"
707
+ end
708
+ rescue StandardError => e
709
+ notify_error(e)
710
+ raise
711
+ end
517
712
  end
518
713
 
519
714
  # Receive all messages from Claude
520
715
  # @yield [Message] Each message received
716
+ # @return [Enumerator] when no block is given (internal iteration only)
717
+ # @note #next/#peek either raise FiberError or hang depending on message
718
+ # timing, and can kill the session's read loop, leaving the client
719
+ # unusable; iterate with a block or each-driven Enumerable methods
720
+ # (#first, #take) inside the Async block instead.
521
721
  def receive_messages(&block)
522
722
  return enum_for(:receive_messages) unless block
523
723
 
524
724
  raise CLIConnectionError, 'Not connected. Call connect() first' unless @connected
525
725
 
526
- @query_handler.receive_messages do |data|
527
- message = MessageParser.parse(data)
528
- if message
726
+ begin
727
+ @query_handler.receive_messages do |data|
728
+ message = MessageParser.parse(data)
729
+ next unless message
730
+
529
731
  ClaudeAgentSDK.notify_observers(@resolved_observers, :on_message, message)
530
- FiberBoundary.invoke { block.call(message) }
732
+ signal = FiberBoundary.invoke_iteration(block, message)
733
+ break signal.value if signal.is_a?(FiberBoundary::Break)
531
734
  end
735
+ rescue StandardError => e
736
+ notify_error(e)
737
+ raise
532
738
  end
533
739
  end
534
740
 
@@ -539,16 +745,23 @@ module ClaudeAgentSDK
539
745
 
540
746
  raise CLIConnectionError, 'Not connected. Call connect() first' unless @connected
541
747
 
542
- # Keep `break` on the same fiber as the underlying dequeue. Going through
543
- # Client#receive_messages would put the FiberBoundary hop above the break
544
- # and hang in Client mode — the CLI keeps stdin open and never emits `:end`.
545
- @query_handler.receive_messages do |data|
546
- message = MessageParser.parse(data)
547
- next unless message
748
+ # Keep loop control on the same fiber as the underlying dequeue: both
749
+ # the SDK's ResultMessage break and the user's translated break happen
750
+ # here, never inside the FiberBoundary hop (break in a proc on a
751
+ # foreign thread raises LocalJumpError).
752
+ begin
753
+ @query_handler.receive_messages do |data|
754
+ message = MessageParser.parse(data)
755
+ next unless message
548
756
 
549
- ClaudeAgentSDK.notify_observers(@resolved_observers, :on_message, message)
550
- FiberBoundary.invoke { block.call(message) }
551
- break if message.is_a?(ResultMessage)
757
+ ClaudeAgentSDK.notify_observers(@resolved_observers, :on_message, message)
758
+ signal = FiberBoundary.invoke_iteration(block, message)
759
+ break signal.value if signal.is_a?(FiberBoundary::Break)
760
+ break if message.is_a?(ResultMessage)
761
+ end
762
+ rescue StandardError => e
763
+ notify_error(e)
764
+ raise
552
765
  end
553
766
  end
554
767
 
@@ -684,7 +897,8 @@ module ClaudeAgentSDK
684
897
 
685
898
  # The connect body, wrapped by #connect so a failure triggers cleanup.
686
899
  def connect_inner(configured_options, prompt)
687
- # Client always uses streaming mode; keep stdin open for bidirectional communication.
900
+ # Client always uses streaming mode; keep stdin open for bidirectional
901
+ # communication. Observers were already resolved by #connect.
688
902
  @transport = @transport_class.new(configured_options, **@transport_args)
689
903
  @transport.connect
690
904
 
@@ -711,7 +925,8 @@ module ClaudeAgentSDK
711
925
  hooks: hooks,
712
926
  sdk_mcp_servers: sdk_mcp_servers,
713
927
  agents: configured_options.agents,
714
- exclude_dynamic_sections: exclude_dynamic_sections
928
+ exclude_dynamic_sections: exclude_dynamic_sections,
929
+ skills: configured_options.skills
715
930
  )
716
931
 
717
932
  # Mirror transcripts to the session_store, if configured.
@@ -721,9 +936,6 @@ module ClaudeAgentSDK
721
936
  @query_handler.start
722
937
  @query_handler.initialize_protocol
723
938
 
724
- # Resolve callable observers into fresh instances (thread-safe for global defaults)
725
- @resolved_observers = ClaudeAgentSDK.resolve_observers(@options.observers)
726
-
727
939
  @connected = true
728
940
 
729
941
  # Optionally send initial prompt/messages after connection is ready.
@@ -733,12 +945,66 @@ module ClaudeAgentSDK
733
945
  when String
734
946
  query(prompt)
735
947
  else
736
- prompt.each do |message_json|
737
- writeln(message_json.to_s)
948
+ # Stream in the background, exactly like query()'s Enumerator path
949
+ # (Python client.py: query.spawn_task(query.stream_input(prompt))).
950
+ # The old inline `prompt.each` blocked connect until the stream was
951
+ # exhausted — an interactive stream that waits for a response before
952
+ # yielding deadlocked connect — and serialized Hash messages with
953
+ # to_s (Ruby inspect, not JSON). stream_input JSON-generates Hashes
954
+ # and is tracked on the Query so close() stops it. Stream errors are
955
+ # notified to observers once, then swallowed-with-warn by
956
+ # stream_input (Python parity) — they no longer abort connect.
957
+ observed = ClaudeAgentSDK.observing_prompt_stream(prompt, @resolved_observers)
958
+ notifying = error_notifying_stream(observed)
959
+ @query_handler.spawn_task { @query_handler.stream_input(notifying) }
960
+ end
961
+ end
962
+
963
+ # Wrap a stream so a raising user enumerator fires on_error exactly once
964
+ # before stream_input's swallow-with-warn handling takes over.
965
+ def error_notifying_stream(stream)
966
+ Enumerator.new do |yielder|
967
+ stream.each { |message| yielder << message }
968
+ rescue StandardError => e
969
+ notify_error(e)
970
+ raise
971
+ end
972
+ end
973
+
974
+ # Stream an iterable of message Hashes / JSONL Strings as session input,
975
+ # stamping session_id on Hashes that lack one (key-presence check, both
976
+ # key styles — an explicit nil is preserved, mirroring Python's
977
+ # `"session_id" not in msg`). Strings pass through verbatim (Ruby
978
+ # superset: Streaming.user_message emits pre-serialized JSONL; no
979
+ # parse-stamp-regenerate, which would block the reactor on huge frames).
980
+ def stream_query_messages(prompt, session_id)
981
+ prompt.each do |msg|
982
+ case msg
983
+ when Hash
984
+ msg = msg.merge(session_id: session_id) unless msg.key?(:session_id) || msg.key?('session_id')
985
+ if (text = ClaudeAgentSDK.extract_user_prompt_text(msg))
986
+ ClaudeAgentSDK.notify_observers(@resolved_observers, :on_user_prompt, text)
987
+ end
988
+ writeln(JSON.generate(msg))
989
+ when String
990
+ if (text = ClaudeAgentSDK.extract_user_prompt_text(msg))
991
+ ClaudeAgentSDK.notify_observers(@resolved_observers, :on_user_prompt, text)
992
+ end
993
+ writeln(msg)
994
+ else
995
+ # No to_s fallback — silently serializing arbitrary objects is the
996
+ # exact inspect-garbage bug class this method exists to prevent.
997
+ raise ArgumentError, "stream items must be Hashes or JSONL Strings (got #{msg.class})"
738
998
  end
739
999
  end
740
1000
  end
741
1001
 
1002
+ # Notify observers of an error surfacing to the consumer. `|| []` keeps a
1003
+ # mis-scoped call before connect harmless instead of NoMethodError on nil.
1004
+ def notify_error(error)
1005
+ ClaudeAgentSDK.notify_observers(@resolved_observers || [], :on_error, error)
1006
+ end
1007
+
742
1008
  # Build and install the transcript-mirror batcher on the query handler when
743
1009
  # a session_store is configured, via the shared SessionResume helper (also
744
1010
  # used by the one-shot query() path).
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: claude-agent-sdk
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.17.0
4
+ version: 0.18.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Community Contributors
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-06-10 00:00:00.000000000 Z
11
+ date: 2026-06-12 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: async
@@ -28,16 +28,22 @@ dependencies:
28
28
  name: mcp
29
29
  requirement: !ruby/object:Gem::Requirement
30
30
  requirements:
31
- - - "~>"
31
+ - - ">="
32
+ - !ruby/object:Gem::Version
33
+ version: '0.6'
34
+ - - "<"
32
35
  - !ruby/object:Gem::Version
33
- version: '0.4'
36
+ version: '1'
34
37
  type: :runtime
35
38
  prerelease: false
36
39
  version_requirements: !ruby/object:Gem::Requirement
37
40
  requirements:
38
- - - "~>"
41
+ - - ">="
42
+ - !ruby/object:Gem::Version
43
+ version: '0.6'
44
+ - - "<"
39
45
  - !ruby/object:Gem::Version
40
- version: '0.4'
46
+ version: '1'
41
47
  - !ruby/object:Gem::Dependency
42
48
  name: bundler
43
49
  requirement: !ruby/object:Gem::Requirement