claude-agent-sdk 1.1.0 → 1.2.1

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/.yardopts +10 -0
  3. data/CHANGELOG.md +99 -0
  4. data/README.md +43 -31
  5. data/docs/cli-installer.md +26 -4
  6. data/docs/client.md +29 -11
  7. data/docs/configuration.md +164 -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 +83 -108
  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/option_forms.rb +379 -0
  22. data/lib/claude_agent_sdk/query/run_lifecycle.rb +373 -0
  23. data/lib/claude_agent_sdk/query.rb +266 -364
  24. data/lib/claude_agent_sdk/railtie.rb +27 -2
  25. data/lib/claude_agent_sdk/sdk_mcp_server.rb +78 -26
  26. data/lib/claude_agent_sdk/session_assembly.rb +311 -0
  27. data/lib/claude_agent_sdk/session_mutations.rb +112 -92
  28. data/lib/claude_agent_sdk/session_resume.rb +360 -43
  29. data/lib/claude_agent_sdk/session_store.rb +31 -2
  30. data/lib/claude_agent_sdk/sessions.rb +720 -138
  31. data/lib/claude_agent_sdk/subprocess_cli_transport.rb +228 -29
  32. data/lib/claude_agent_sdk/testing/session_store_conformance.rb +18 -7
  33. data/lib/claude_agent_sdk/transcript_mirror_batcher.rb +45 -37
  34. data/lib/claude_agent_sdk/transport.rb +28 -12
  35. data/lib/claude_agent_sdk/types/attributes.rb +9 -0
  36. data/lib/claude_agent_sdk/types/base.rb +85 -15
  37. data/lib/claude_agent_sdk/types/hooks.rb +73 -0
  38. data/lib/claude_agent_sdk/types/mcp.rb +37 -1
  39. data/lib/claude_agent_sdk/types/option_values.rb +186 -4
  40. data/lib/claude_agent_sdk/types/options.rb +35 -5
  41. data/lib/claude_agent_sdk/types/permissions.rb +18 -9
  42. data/lib/claude_agent_sdk/version.rb +1 -1
  43. data/lib/claude_agent_sdk.rb +223 -418
  44. data/lib/generators/claude_agent_sdk/install/templates/claude_agent_sdk.rb.tt +6 -0
  45. data/sig/claude_agent_sdk/types/hooks.rbs +6 -3
  46. data/sig/claude_agent_sdk/types/option_values.rbs +23 -6
  47. data/sig/claude_agent_sdk/types/options.rbs +11 -7
  48. data/sig/claude_agent_sdk/types/permissions.rbs +4 -2
  49. metadata +9 -4
@@ -19,6 +19,8 @@ require_relative 'claude_agent_sdk/transcript_mirror_batcher'
19
19
  require_relative 'claude_agent_sdk/session_resume'
20
20
  require_relative 'claude_agent_sdk/session_mutations'
21
21
  require_relative 'claude_agent_sdk/fiber_boundary'
22
+ require_relative 'claude_agent_sdk/session_assembly'
23
+ require_relative 'claude_agent_sdk/option_forms'
22
24
  require_relative 'claude_agent_sdk/option_warnings'
23
25
  require_relative 'claude_agent_sdk/deprecation'
24
26
  # Rails apps only: Bundler.require runs after `require 'rails'`, so the
@@ -66,20 +68,14 @@ module ClaudeAgentSDK # rubocop:disable Metrics/ModuleLength -- the public entry
66
68
  # strips the instance from exactly these entries.
67
69
  # @api private
68
70
  def self.extract_sdk_mcp_servers(mcp_servers)
69
- return {} unless mcp_servers.is_a?(Hash)
70
-
71
- servers = {}
72
- mcp_servers.each do |name, config|
73
- config = config.to_h if config.is_a?(Type)
74
- next unless config.is_a?(Hash) && (config[:type] || config['type']).to_s == 'sdk'
75
-
76
- servers[name] = config.key?(:instance) ? config[:instance] : config['instance']
77
- end
78
- servers
71
+ OptionForms.sdk_mcp_servers(mcp_servers)
79
72
  end
80
73
 
81
74
  # Internal: normalize hook lists for the control protocol. An absent or
82
- # disabled event must not become an empty registration in initialize.
75
+ # disabled event must not become an empty registration in initialize. An
76
+ # event written as a String under one key and as a Symbol under another
77
+ # ('PreToolUse' and :PreToolUse) is one event: its matcher lists are
78
+ # joined in the order they were written, never replaced.
83
79
  # @api private
84
80
  def self.convert_hooks_to_internal_format(hooks)
85
81
  return nil unless hooks
@@ -94,7 +90,7 @@ module ClaudeAgentSDK # rubocop:disable Metrics/ModuleLength -- the public entry
94
90
  config[:timeout] = matcher.timeout if matcher.timeout
95
91
  entries << config
96
92
  end
97
- internal_hooks[event.to_s] = entries unless entries.empty?
93
+ (internal_hooks[event.to_s] ||= []).concat(entries) unless entries.empty?
98
94
  end
99
95
  internal_hooks.empty? ? nil : internal_hooks
100
96
  end
@@ -135,17 +131,7 @@ module ClaudeAgentSDK # rubocop:disable Metrics/ModuleLength -- the public entry
135
131
  # Shared by Client#connect and the one-shot query() path.
136
132
  # @api private
137
133
  def self.extract_exclude_dynamic_sections(system_prompt)
138
- if system_prompt.is_a?(SystemPromptPreset)
139
- eds = system_prompt.exclude_dynamic_sections
140
- return eds if [true, false].include?(eds)
141
- elsif system_prompt.is_a?(Hash)
142
- type = system_prompt[:type] || system_prompt['type']
143
- if type == 'preset'
144
- eds = system_prompt.fetch(:exclude_dynamic_sections) { system_prompt['exclude_dynamic_sections'] }
145
- return eds if [true, false].include?(eds)
146
- end
147
- end
148
- nil
134
+ OptionForms.exclude_dynamic_sections(system_prompt)
149
135
  end
150
136
 
151
137
  # Internal: pull snapshot out of a preset or custom system prompt for the
@@ -155,32 +141,46 @@ module ClaudeAgentSDK # rubocop:disable Metrics/ModuleLength -- the public entry
155
141
  # must not collapse it to nil. Shared by Client#connect and query().
156
142
  # @api private
157
143
  def self.extract_system_prompt_snapshot(system_prompt)
158
- case system_prompt
159
- when SystemPromptPreset, SystemPromptCustom
160
- snapshot = system_prompt.snapshot
161
- return snapshot if [true, false].include?(snapshot)
162
- when Hash
163
- type = system_prompt[:type] || system_prompt['type']
164
- if %w[preset custom].include?(type)
165
- snapshot = system_prompt.fetch(:snapshot) { system_prompt['snapshot'] }
166
- return snapshot if [true, false].include?(snapshot)
167
- end
168
- end
169
- nil
144
+ OptionForms.system_prompt_snapshot(system_prompt)
170
145
  end
171
146
 
172
147
  # Safely call a method on each observer, suppressing any errors.
173
148
  # Each observer is invoked through FiberBoundary so that user code runs
174
149
  # on a plain thread (no Fiber scheduler) even when called from inside
175
150
  # the SDK's Async reactor — or in place when scheduling is :inline.
151
+ #
152
+ # What the observer raises, and what its callback_wrapper raises, is
153
+ # contained where it happens: inside the hop, on the execution context
154
+ # the observer runs on. The rescue must not sit around the hop, because
155
+ # the calling fiber WAITS there, and an exception raised into a waiting
156
+ # fiber is not the observer's — a caller's `task.with_timeout` /
157
+ # `Timeout.timeout` deadline is delivered exactly that way. Rescued out
158
+ # here, an expired deadline was swallowed as if the observer had failed,
159
+ # and the turn carried on. So the wrapper is composed inside the rescued
160
+ # body rather than handed to FiberBoundary.invoke, which would run it
161
+ # outside that rescue.
162
+ #
163
+ # Known residue with `scheduling: :inline` (and with no scheduler at all):
164
+ # the observer runs on the calling fiber itself, so a deadline that lands
165
+ # while the observer is suspended is raised inside the observer's own
166
+ # frames, cannot be told from the observer's own timeout, and is still
167
+ # swallowed here.
176
168
  # @api private
177
169
  def self.notify_observers(observers, method, *args, scheduling: :thread, wrapper: nil)
178
170
  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.
171
+ FiberBoundary.invoke(scheduling: scheduling) do
172
+ invocation = proc { obs.send(method, *args) }
173
+ wrapper ? wrapper.call(invocation) : invocation.call
174
+ rescue StandardError, ScriptError
175
+ # ScriptError too: NotImplementedError < ScriptError (not
176
+ # StandardError), and a stubbed observer must never mask the original
177
+ # error being notified or abort connect/teardown cleanup.
178
+ nil
179
+ end
180
+ rescue ScriptError
181
+ # A ScriptError raised around the body rather than in it (the hop's own
182
+ # plumbing). No deadline is a ScriptError, so nothing a caller injects
183
+ # into the waiting fiber is caught here.
184
184
  nil
185
185
  end
186
186
  end
@@ -690,164 +690,85 @@ module ClaudeAgentSDK # rubocop:disable Metrics/ModuleLength -- the public entry
690
690
  # Fail fast on invalid session_store combinations before spawning the CLI.
691
691
  SessionStores.validate_session_store_options(configured_options)
692
692
 
693
- # Resolve callable observers into fresh instances (thread-safe for global defaults)
694
- resolved_observers = ClaudeAgentSDK.resolve_observers(configured_options.observers)
695
-
696
- # Where user callbacks run (see ClaudeAgentOptions#callback_scheduling)
697
- # and the middleware wrapped around them (#callback_wrapper).
698
- callback_scheduling = configured_options.callback_scheduling || :thread
699
- callback_wrapper = configured_options.callback_wrapper
700
- ClaudeAgentSDK.check_inline_isolation(callback_scheduling)
693
+ # Resolve callable observers into fresh instances (thread-safe for global
694
+ # defaults), bound to where user callbacks run (see
695
+ # ClaudeAgentOptions#callback_scheduling) and the middleware wrapped
696
+ # around them (#callback_wrapper).
697
+ dispatch = Dispatch.new(ClaudeAgentSDK.resolve_observers(configured_options.observers),
698
+ scheduling: configured_options.callback_scheduling || :thread,
699
+ wrapper: configured_options.callback_wrapper)
700
+ ClaudeAgentSDK.check_inline_isolation(dispatch.scheduling)
701
701
 
702
702
  if transport && !transport.respond_to?(:connect)
703
703
  raise ArgumentError, 'transport must respond to #connect (see ClaudeAgentSDK::Transport)'
704
704
  end
705
705
 
706
- Async(&FiberBoundary.capture_otel_context do # rubocop:disable Metrics/BlockLength -- the reactor task body of query()
707
- materialized = nil
708
- query_handler = nil
709
- begin
710
- if transport.nil?
711
- # Resume-from-store: when a session_store is set and resume/continue
712
- # is requested, load the session into a temp CLAUDE_CONFIG_DIR and
713
- # repoint options at it (env + --resume) BEFORE spawning. Returns
714
- # options unchanged when no materialization applies. Skipped
715
- # entirely for an injected transport — the materialized
716
- # env/--resume only apply to the CLI subprocess (Python parity:
717
- # client.py skips materialization when a transport is supplied).
718
- materialized = SessionResume.materialize_resume_session(configured_options)
719
- if materialized
720
- configured_options = SessionResume.apply_materialized_options(configured_options, materialized)
721
- end
722
-
723
- # Always use streaming mode with control protocol (matches Python
724
- # SDK). This sends agents via initialize request instead of CLI
725
- # args, avoiding OS ARG_MAX limits.
726
- transport = SubprocessCLITransport.new(configured_options)
727
- end
728
- # Deliberate deviation from Python: the ensure below also closes an
729
- # injected transport whose #connect raised (Python leaves it
730
- # unclosed); Transport#close must be idempotent.
731
- transport.connect
732
-
733
- # Extract SDK MCP servers
734
- sdk_mcp_servers = extract_sdk_mcp_servers(configured_options.mcp_servers)
735
-
736
- hooks = convert_hooks_to_internal_format(configured_options.hooks)
737
-
738
- # Create Query handler for control protocol
739
- query_handler = Query.new(
740
- transport: transport,
741
- is_streaming_mode: true,
742
- can_use_tool: configured_options.can_use_tool,
743
- hooks: hooks,
744
- agents: configured_options.agents,
745
- sdk_mcp_servers: sdk_mcp_servers,
746
- exclude_dynamic_sections: ClaudeAgentSDK.extract_exclude_dynamic_sections(configured_options.system_prompt),
747
- system_prompt_snapshot: ClaudeAgentSDK.extract_system_prompt_snapshot(configured_options.system_prompt),
748
- skills: configured_options.skills,
749
- forward_subagent_text: configured_options.forward_subagent_text?,
750
- agent_progress_summaries: configured_options.agent_progress_summaries,
751
- callback_scheduling: callback_scheduling,
752
- callback_wrapper: callback_wrapper,
753
- verbatim_prompts: configured_options.verbatim_prompts?,
754
- run_end_ceiling_ms: Query.run_end_ceiling_ms(configured_options.env)
755
- )
756
-
757
- # Mirror transcripts to the session_store, if configured. Installed
758
- # before #start so the read loop captures transcript_mirror frames.
759
- if configured_options.session_store
760
- query_handler.set_transcript_mirror_batcher(
761
- SessionResume.build_mirror_batcher(
762
- store: configured_options.session_store,
763
- env: configured_options.env,
764
- on_error: ->(key, message) { query_handler.report_mirror_error(key, message) },
765
- eager: configured_options.session_store_flush.to_s == 'eager',
766
- callback_wrapper: callback_wrapper
767
- )
768
- )
769
- end
770
-
771
- # Start reading messages in background
772
- query_handler.start
773
-
774
- # Initialize the control protocol (sends agents)
775
- query_handler.initialize_protocol
776
-
777
- # Send prompt(s) as user messages, then close stdin
778
- if prompt.is_a?(String)
779
- ClaudeAgentSDK.notify_observers(resolved_observers, :on_user_prompt, prompt,
780
- scheduling: callback_scheduling, wrapper: callback_wrapper)
781
- message = {
782
- type: 'user',
783
- message: { role: 'user', content: prompt },
784
- parent_tool_use_id: nil,
785
- session_id: ''
786
- }
787
- transport.write("#{Query.serialize_user_message(message, configured_options.verbatim_prompts?)}\n")
788
- # Background-spawn so messages stream to the user block while stdin
789
- # close waits (without timeout) for the first result; a synchronous
790
- # call would defer all delivery until the turn completes (mirrors
791
- # Python's query.spawn_task(query.wait_for_result_and_end_input())).
792
- query_handler.spawn_task { query_handler.wait_for_result_and_end_input }
793
- elsif prompt.is_a?(Enumerator) || prompt.respond_to?(:each)
794
- # Tracked on the Query so close() stops it; an untracked Async task
795
- # here kept the root reactor alive forever when the read loop died
796
- # while the user enumerator was still blocked (matches Python's
797
- # query.spawn_task(query.stream_input(prompt))).
798
- observed_prompt = ClaudeAgentSDK.observing_prompt_stream(
799
- prompt, resolved_observers, scheduling: callback_scheduling, wrapper: callback_wrapper
800
- )
801
- query_handler.spawn_task { query_handler.stream_input(observed_prompt) }
802
- end
803
-
804
- # Read and yield messages from the query handler (filters out control messages).
805
- # User block is invoked through FiberBoundary so ActiveRecord / PG calls
806
- # inside it don't see the async gem's Fiber scheduler (default :thread
807
- # mode; :inline runs it in place on the reactor fiber).
808
- query_handler.receive_messages do |data|
809
- message = MessageParser.parse(data)
810
- next unless message
811
-
812
- ClaudeAgentSDK.notify_observers(resolved_observers, :on_message, message,
813
- scheduling: callback_scheduling, wrapper: callback_wrapper)
814
- signal = FiberBoundary.invoke_iteration(block, message, scheduling: callback_scheduling,
815
- wrapper: callback_wrapper)
816
- break signal.value if signal.is_a?(FiberBoundary::Break)
817
- end
818
- rescue StandardError => e
819
- # One notify point for every error surfacing from query() — transport
820
- # connect, initialize, stream errors re-raised from the message queue,
821
- # parse errors, and user-block errors. StandardError only: Async::Stop
822
- # is cancellation, not an error. Bare raise preserves the backtrace;
823
- # the ensure below still fires on_close after on_error.
824
- ClaudeAgentSDK.notify_observers(resolved_observers, :on_error, e,
825
- scheduling: callback_scheduling, wrapper: callback_wrapper)
826
- raise
827
- ensure
828
- ClaudeAgentSDK.notify_observers(resolved_observers, :on_close,
829
- scheduling: callback_scheduling, wrapper: callback_wrapper)
830
- # query_handler.close stops the background read task and closes the
831
- # transport (flushing the mirror batcher first). Fall back to a bare
832
- # transport close when the handler was never built.
833
- begin
834
- if query_handler
835
- query_handler.close
836
- elsif transport
837
- transport.close
838
- end
839
- ensure
840
- # Remove the materialized resume temp dir (which holds a redacted
841
- # .credentials.json copy) AFTER the subprocess has exited, even when
842
- # close itself raises — unless the mirror dropped batches: the store
843
- # copy is then incomplete and the temp dir holds the only copy of
844
- # the dropped turns, so it is preserved (scrubbed of credentials)
845
- # with a warning instead of deleted.
846
- if materialized
847
- query_handler&.mirror_batches_dropped? ? materialized.preserve_transcripts : materialized.cleanup
848
- end
849
- end
706
+ # Acquires nothing yet. An injected transport is used as it is: it was
707
+ # built before a store-backed resume could be materialized for it, so
708
+ # none is (Python parity: client.py skips materialization when a
709
+ # transport is supplied). Without one the session constructs the
710
+ # subprocess transport itself, after materialization.
711
+ session = SessionAssembly.new(
712
+ configured_options,
713
+ dispatch: dispatch,
714
+ transport_source: transport ? { instance: transport } : { class: SubprocessCLITransport, args: {} }
715
+ )
716
+
717
+ # finished: false tells Async that a waiter handles this task's failure
718
+ # (the .wait at the end re-raises it), as Kernel#Sync does for its own
719
+ # task. Without it a task that fails before anyone waits for it — always
720
+ # the case outside a reactor, where Async runs the whole task before it
721
+ # returns — is also logged as "Task may have ended with unhandled
722
+ # exception", message and backtrace included, although the caller gets
723
+ # the same error raised and may well rescue it.
724
+ Async(finished: false, &FiberBoundary.capture_otel_context do
725
+ # Resume materialization, the transport's connect and the handshake
726
+ # all happen in here: inside the notified region.
727
+ session.connect
728
+
729
+ # Send prompt(s) as user messages, then close stdin
730
+ if prompt.is_a?(String)
731
+ dispatch.notify(:on_user_prompt, prompt)
732
+ # verbatim_prompts is read now, after the notification, from the
733
+ # options the session runs on.
734
+ session.write_prompt(prompt, session_id: '', verbatim: :current)
735
+ # Background-spawn so messages stream to the user block while stdin
736
+ # close waits (without timeout) for the first result; a synchronous
737
+ # call would defer all delivery until the turn completes (mirrors
738
+ # Python's query.spawn_task(query.wait_for_result_and_end_input())).
739
+ query_handler = session.query_handler
740
+ query_handler.spawn_task { query_handler.wait_for_result_and_end_input }
741
+ elsif prompt.is_a?(Enumerator) || prompt.respond_to?(:each)
742
+ # Tracked on the Query so close() stops it; an untracked Async task
743
+ # here kept the root reactor alive forever when the read loop died
744
+ # while the user enumerator was still blocked (matches Python's
745
+ # query.spawn_task(query.stream_input(prompt))).
746
+ session.stream_prompt_in_background(prompt)
850
747
  end
748
+
749
+ # Read and yield messages from the query handler (filters out control messages).
750
+ # User block is invoked through FiberBoundary so ActiveRecord / PG calls
751
+ # inside it don't see the async gem's Fiber scheduler (default :thread
752
+ # mode; :inline runs it in place on the reactor fiber).
753
+ session.deliver(block)
754
+ rescue StandardError => e
755
+ # One notify point for every error surfacing from query() — transport
756
+ # connect, initialize, stream errors re-raised from the message queue,
757
+ # parse errors, and user-block errors. StandardError only: Async::Stop
758
+ # is cancellation, not an error. Bare raise preserves the backtrace;
759
+ # the ensure below still fires on_close after on_error.
760
+ dispatch.notify(:on_error, e)
761
+ raise
762
+ ensure
763
+ dispatch.notify(:on_close)
764
+ # Closing the query handler stops the background read task and closes
765
+ # the transport (flushing the mirror batcher first); a transport
766
+ # without a handler — its #connect raised, or the handshake never got
767
+ # that far — is closed on its own. That includes an injected one: a
768
+ # deliberate deviation from Python, which leaves it unclosed
769
+ # (Transport#close must be idempotent). The materialized resume temp
770
+ # dir is dealt with after that, even when the close raised.
771
+ session.close_resources(always_close_transport: false)
851
772
  end).wait
852
773
  end
853
774
 
@@ -903,16 +824,13 @@ module ClaudeAgentSDK # rubocop:disable Metrics/ModuleLength -- the public entry
903
824
  # The Client class always uses streaming mode for bidirectional communication.
904
825
  #
905
826
  # @example Basic usage
906
- # Async do
907
- # client = ClaudeAgentSDK::Client.new
908
- # client.connect # No arguments needed - automatically uses streaming mode
909
- #
827
+ # # Client.open connects, yields the client and always disconnects,
828
+ # # also when the block raises.
829
+ # ClaudeAgentSDK::Client.open do |client|
910
830
  # client.query("What is the capital of France?")
911
831
  # client.receive_response do |msg|
912
832
  # puts msg if msg.is_a?(ClaudeAgentSDK::AssistantMessage)
913
833
  # end
914
- #
915
- # client.disconnect
916
834
  # end
917
835
  #
918
836
  # @example With hooks
@@ -932,11 +850,13 @@ module ClaudeAgentSDK # rubocop:disable Metrics/ModuleLength -- the public entry
932
850
  # }
933
851
  # )
934
852
  # client = ClaudeAgentSDK::Client.new(options: options)
935
- class Client # rubocop:disable Metrics/ClassLength -- public session API: lifecycle, control methods and their Ruby aliases
853
+ class Client
936
854
  # The session's control-protocol handler (nil until #connect).
937
855
  #
938
856
  # @api private
939
- attr_reader :query_handler
857
+ def query_handler
858
+ @session&.query_handler
859
+ end
940
860
 
941
861
  # @param options [ClaudeAgentOptions, nil] Configuration options
942
862
  # @param transport_class [Class] Transport class to use (must implement Transport interface).
@@ -944,14 +864,17 @@ module ClaudeAgentSDK # rubocop:disable Metrics/ModuleLength -- the public entry
944
864
  # @param transport_args [Hash] Additional keyword arguments passed to transport_class.new(options, **transport_args)
945
865
  def initialize(options: nil, transport_class: SubprocessCLITransport, transport_args: {})
946
866
  @options = options || ClaudeAgentOptions.new
947
- @callback_scheduling = @options.callback_scheduling || :thread
948
- @callback_wrapper = @options.callback_wrapper
867
+ # One for the life of the client. Scheduling and wrapper are captured
868
+ # here; the observers are resolved, and put in, on each #connect.
869
+ @dispatch = Dispatch.new([], scheduling: @options.callback_scheduling || :thread,
870
+ wrapper: @options.callback_wrapper)
949
871
  @transport_class = transport_class
950
872
  @transport_args = transport_args
951
- @transport = nil
952
- @query_handler = nil
873
+ # Owns the transport, the query handler and the materialized resume dir
874
+ # of the current connection; nil while there is none. Kept after a
875
+ # disconnect that raised, until one completes (see #disconnect).
876
+ @session = nil
953
877
  @connected = false
954
- @materialized = nil
955
878
  end
956
879
 
957
880
  # Block-scoped Client lifecycle, mirroring Python's
@@ -1020,22 +943,29 @@ module ClaudeAgentSDK # rubocop:disable Metrics/ModuleLength -- the public entry
1020
943
  # Resolve observers before the first failable runtime step so
1021
944
  # connect-phase failures (including resume materialization) can be
1022
945
  # notified via on_error.
1023
- @resolved_observers = ClaudeAgentSDK.resolve_observers(@options.observers)
946
+ @dispatch.observers = ClaudeAgentSDK.resolve_observers(@options.observers)
947
+
948
+ ClaudeAgentSDK.check_inline_isolation(@dispatch.scheduling)
1024
949
 
1025
- ClaudeAgentSDK.check_inline_isolation(@callback_scheduling)
950
+ # Acquires nothing yet. Assigned before the begin, so the disconnect in
951
+ # the rescue below finds whatever the session went on to acquire. The
952
+ # transport is constructed by the session, after materialization.
953
+ @session = SessionAssembly.new(configured_options, dispatch: @dispatch,
954
+ transport_source: { class: @transport_class,
955
+ args: @transport_args })
1026
956
 
1027
957
  # If anything from materialization onward fails, tear down (closes the
1028
958
  # subprocess and removes the materialized temp config dir) before
1029
959
  # surfacing the error, so a partial connect never leaks a temp dir
1030
960
  # holding a credential copy.
1031
961
  begin
1032
- # Resume-from-store: materialize the session from the store into a
1033
- # temp CLAUDE_CONFIG_DIR BEFORE spawn, then repoint options at it.
1034
- # Inside the instrumented begin so store IO failures fire on_error
1035
- # (matching the one-shot query() path) and disconnect cleans up.
1036
- configured_options = materialize_resume(configured_options)
962
+ # Resume materialization (store IO) happens in here too: inside the
963
+ # instrumented begin, so its failures fire on_error (matching the
964
+ # one-shot query() path) and disconnect cleans up.
965
+ @session.connect
966
+ @connected = true
1037
967
 
1038
- connect_inner(configured_options, prompt)
968
+ send_initial_prompt(prompt)
1039
969
  rescue Exception => e # rubocop:disable Lint/RescueException
1040
970
  # Pre-handshake failures (@connected still false) are notified here;
1041
971
  # post-handshake String-prompt send failures were already notified by
@@ -1043,17 +973,22 @@ module ClaudeAgentSDK # rubocop:disable Metrics/ModuleLength -- the public entry
1043
973
  # (The enumerator branch streams in the background and cannot raise
1044
974
  # out of connect.) No on_close follows for pre-handshake failures
1045
975
  # (disconnect gates it on @connected): the session never opened.
1046
- notify_error(e) if e.is_a?(StandardError) && !@connected
1047
- # Tear down the partial connect, but never let a cleanup failure (e.g. a
1048
- # custom transport whose #close raises) mask the original connect error.
1049
- # Rescue Exception (not StandardError) so reactor cancellation
1050
- # (Async::Stop < Exception) after materialize_resume set @materialized
1051
- # still runs disconnect -> @materialized.cleanup, never leaking the temp
1052
- # CLAUDE_CONFIG_DIR that holds the redacted .credentials.json copy.
1053
976
  begin
1054
- disconnect
1055
- rescue StandardError => cleanup_error
1056
- warn "Claude SDK: cleanup after failed connect raised: #{cleanup_error.message}"
977
+ @dispatch.notify(:on_error, e) if e.is_a?(StandardError) && !@connected
978
+ ensure
979
+ # Tear down the partial connect, but never let a cleanup failure (e.g. a
980
+ # custom transport whose #close raises) mask the original connect error.
981
+ # Rescue Exception (not StandardError) so reactor cancellation
982
+ # (Async::Stop < Exception) after the resume was materialized still
983
+ # runs disconnect, which removes the temp CLAUDE_CONFIG_DIR holding
984
+ # the redacted .credentials.json copy.
985
+ # In an ensure: an exception raised into this fiber while it waits
986
+ # for the on_error observer (a caller's deadline) must not skip it.
987
+ begin
988
+ disconnect
989
+ rescue StandardError => cleanup_error
990
+ warn "Claude SDK: cleanup after failed connect raised: #{cleanup_error.message}"
991
+ end
1057
992
  end
1058
993
  raise
1059
994
  end
@@ -1078,15 +1013,8 @@ module ClaudeAgentSDK # rubocop:disable Metrics/ModuleLength -- the public entry
1078
1013
 
1079
1014
  begin
1080
1015
  if prompt.is_a?(String)
1081
- ClaudeAgentSDK.notify_observers(@resolved_observers, :on_user_prompt, prompt,
1082
- scheduling: @callback_scheduling, wrapper: @callback_wrapper)
1083
- message = {
1084
- type: 'user',
1085
- message: { role: 'user', content: prompt },
1086
- parent_tool_use_id: nil,
1087
- session_id: session_id
1088
- }
1089
- writeln(Query.serialize_user_message(message, @verbatim_prompts))
1016
+ @dispatch.notify(:on_user_prompt, prompt)
1017
+ @session.write_prompt(prompt, session_id: session_id)
1090
1018
  elsif prompt.respond_to?(:each)
1091
1019
  # Inline iteration on the caller, Python client.py parity — NOT
1092
1020
  # Query#stream_input, whose ensure always ends input after
@@ -1098,7 +1026,7 @@ module ClaudeAgentSDK # rubocop:disable Metrics/ModuleLength -- the public entry
1098
1026
  raise ArgumentError, "prompt must be a String or respond to #each (got #{prompt.class})"
1099
1027
  end
1100
1028
  rescue StandardError => e
1101
- notify_error(e)
1029
+ @dispatch.notify(:on_error, e)
1102
1030
  raise
1103
1031
  end
1104
1032
  end
@@ -1116,18 +1044,9 @@ module ClaudeAgentSDK # rubocop:disable Metrics/ModuleLength -- the public entry
1116
1044
  raise CLIConnectionError, 'Not connected. Call connect() first' unless @connected
1117
1045
 
1118
1046
  begin
1119
- @query_handler.receive_messages do |data|
1120
- message = MessageParser.parse(data)
1121
- next unless message
1122
-
1123
- ClaudeAgentSDK.notify_observers(@resolved_observers, :on_message, message,
1124
- scheduling: @callback_scheduling, wrapper: @callback_wrapper)
1125
- signal = FiberBoundary.invoke_iteration(block, message, scheduling: @callback_scheduling,
1126
- wrapper: @callback_wrapper)
1127
- break signal.value if signal.is_a?(FiberBoundary::Break)
1128
- end
1047
+ @session.deliver(block)
1129
1048
  rescue StandardError => e
1130
- notify_error(e)
1049
+ @dispatch.notify(:on_error, e)
1131
1050
  raise
1132
1051
  end
1133
1052
  end
@@ -1139,24 +1058,10 @@ module ClaudeAgentSDK # rubocop:disable Metrics/ModuleLength -- the public entry
1139
1058
 
1140
1059
  raise CLIConnectionError, 'Not connected. Call connect() first' unless @connected
1141
1060
 
1142
- # Keep loop control on the same fiber as the underlying dequeue: both
1143
- # the SDK's ResultMessage break and the user's translated break happen
1144
- # here, never inside the FiberBoundary hop (break in a proc on a
1145
- # foreign thread raises LocalJumpError).
1146
1061
  begin
1147
- @query_handler.receive_messages do |data|
1148
- message = MessageParser.parse(data)
1149
- next unless message
1150
-
1151
- ClaudeAgentSDK.notify_observers(@resolved_observers, :on_message, message,
1152
- scheduling: @callback_scheduling, wrapper: @callback_wrapper)
1153
- signal = FiberBoundary.invoke_iteration(block, message, scheduling: @callback_scheduling,
1154
- wrapper: @callback_wrapper)
1155
- break signal.value if signal.is_a?(FiberBoundary::Break)
1156
- break if message.is_a?(ResultMessage)
1157
- end
1062
+ @session.deliver(block, until_result: true)
1158
1063
  rescue StandardError => e
1159
- notify_error(e)
1064
+ @dispatch.notify(:on_error, e)
1160
1065
  raise
1161
1066
  end
1162
1067
  end
@@ -1165,7 +1070,7 @@ module ClaudeAgentSDK # rubocop:disable Metrics/ModuleLength -- the public entry
1165
1070
  def interrupt
1166
1071
  raise CLIConnectionError, 'Not connected. Call connect() first' unless @connected
1167
1072
 
1168
- @query_handler.interrupt
1073
+ query_handler.interrupt
1169
1074
  end
1170
1075
 
1171
1076
  # Change permission mode during conversation
@@ -1173,7 +1078,7 @@ module ClaudeAgentSDK # rubocop:disable Metrics/ModuleLength -- the public entry
1173
1078
  def set_permission_mode(mode)
1174
1079
  raise CLIConnectionError, 'Not connected. Call connect() first' unless @connected
1175
1080
 
1176
- @query_handler.set_permission_mode(mode)
1081
+ query_handler.set_permission_mode(mode)
1177
1082
  end
1178
1083
 
1179
1084
  # Ruby-style spelling of #set_permission_mode: `client.permission_mode = 'plan'`.
@@ -1187,7 +1092,7 @@ module ClaudeAgentSDK # rubocop:disable Metrics/ModuleLength -- the public entry
1187
1092
  def set_model(model)
1188
1093
  raise CLIConnectionError, 'Not connected. Call connect() first' unless @connected
1189
1094
 
1190
- @query_handler.set_model(model)
1095
+ query_handler.set_model(model)
1191
1096
  end
1192
1097
 
1193
1098
  # Ruby-style spelling of #set_model: `client.model = 'claude-opus-5'`.
@@ -1200,7 +1105,7 @@ module ClaudeAgentSDK # rubocop:disable Metrics/ModuleLength -- the public entry
1200
1105
  def reconnect_mcp_server(server_name)
1201
1106
  raise CLIConnectionError, 'Not connected. Call connect() first' unless @connected
1202
1107
 
1203
- @query_handler.reconnect_mcp_server(server_name)
1108
+ query_handler.reconnect_mcp_server(server_name)
1204
1109
  end
1205
1110
 
1206
1111
  # Enable or disable an MCP server
@@ -1209,7 +1114,7 @@ module ClaudeAgentSDK # rubocop:disable Metrics/ModuleLength -- the public entry
1209
1114
  def toggle_mcp_server(server_name, enabled)
1210
1115
  raise CLIConnectionError, 'Not connected. Call connect() first' unless @connected
1211
1116
 
1212
- @query_handler.toggle_mcp_server(server_name, enabled)
1117
+ query_handler.toggle_mcp_server(server_name, enabled)
1213
1118
  end
1214
1119
 
1215
1120
  # Stop a running background task
@@ -1217,7 +1122,7 @@ module ClaudeAgentSDK # rubocop:disable Metrics/ModuleLength -- the public entry
1217
1122
  def stop_task(task_id)
1218
1123
  raise CLIConnectionError, 'Not connected. Call connect() first' unless @connected
1219
1124
 
1220
- @query_handler.stop_task(task_id)
1125
+ query_handler.stop_task(task_id)
1221
1126
  end
1222
1127
 
1223
1128
  # Background in-flight foreground tasks (Bash commands and subagents) — the
@@ -1243,7 +1148,7 @@ module ClaudeAgentSDK # rubocop:disable Metrics/ModuleLength -- the public entry
1243
1148
  def background_tasks(tool_use_id: nil)
1244
1149
  raise CLIConnectionError, 'Not connected. Call connect() first' unless @connected
1245
1150
 
1246
- @query_handler.background_tasks(tool_use_id: tool_use_id)
1151
+ query_handler.background_tasks(tool_use_id: tool_use_id)
1247
1152
  end
1248
1153
 
1249
1154
  # Rewind files to a previous checkpoint (v0.1.15+)
@@ -1253,13 +1158,13 @@ module ClaudeAgentSDK # rubocop:disable Metrics/ModuleLength -- the public entry
1253
1158
  def rewind_files(user_message_uuid)
1254
1159
  raise CLIConnectionError, 'Not connected. Call connect() first' unless @connected
1255
1160
 
1256
- @query_handler.rewind_files(user_message_uuid)
1161
+ query_handler.rewind_files(user_message_uuid)
1257
1162
  end
1258
1163
 
1259
1164
  # Get server initialization info
1260
1165
  # @return [Hash, nil] Server info or nil
1261
1166
  def server_info
1262
- @query_handler&.initialization_result
1167
+ query_handler&.initialization_result
1263
1168
  end
1264
1169
 
1265
1170
  # Get a breakdown of current context window usage by category.
@@ -1269,7 +1174,7 @@ module ClaudeAgentSDK # rubocop:disable Metrics/ModuleLength -- the public entry
1269
1174
  def get_context_usage
1270
1175
  raise CLIConnectionError, 'Not connected. Call connect() first' unless @connected
1271
1176
 
1272
- @query_handler.get_context_usage
1177
+ query_handler.get_context_usage
1273
1178
  end
1274
1179
 
1275
1180
  # Ruby-style spelling of #get_context_usage.
@@ -1283,7 +1188,7 @@ module ClaudeAgentSDK # rubocop:disable Metrics/ModuleLength -- the public entry
1283
1188
  def get_mcp_status
1284
1189
  raise CLIConnectionError, 'Not connected. Call connect() first' unless @connected
1285
1190
 
1286
- @query_handler.get_mcp_status
1191
+ query_handler.get_mcp_status
1287
1192
  end
1288
1193
 
1289
1194
  # Ruby-style spelling of #get_mcp_status.
@@ -1314,121 +1219,54 @@ module ClaudeAgentSDK # rubocop:disable Metrics/ModuleLength -- the public entry
1314
1219
  # holds, in every scheduling mode, for a streaming-input enumerator that
1315
1220
  # calls disconnect: it is iterated on the reactor inside a task the close
1316
1221
  # stops, so it unwinds with Async::Stop once the teardown has completed.
1222
+ #
1223
+ # Interrupted while an observer's on_close runs — the caller's deadline
1224
+ # expires, or its task is stopped — disconnect still completes the
1225
+ # teardown, then lets the interruption propagate.
1317
1226
  def disconnect
1318
- if @connected
1319
- ClaudeAgentSDK.notify_observers(@resolved_observers || [], :on_close,
1320
- scheduling: @callback_scheduling, wrapper: @callback_wrapper)
1321
- end
1322
1227
  # Tear down whatever exists — robust to a partial/failed connect, where
1323
- # @connected is still false but a transport and/or materialized temp dir
1324
- # were already created. #close on the query handler also closes the
1325
- # transport (flushing the mirror batcher first); the extra @transport
1326
- # close covers a failure before the query handler was built (idempotent).
1228
+ # @connected is still false but the session already holds a transport
1229
+ # and/or a materialized temp dir.
1327
1230
  #
1328
- # The nested ensures guarantee that even a raising close (e.g. a custom
1329
- # transport whose #close raises) still runs the transport close, resets
1330
- # state, and removes the materialized temp dir (which holds a redacted
1331
- # .credentials.json copy) — so disconnect can never leave the client
1332
- # half-open or leak the temp dir. The original error still propagates.
1333
- # Keep a handle on the query handler past the nil-out below: whether the
1334
- # mirror dropped batches is only final AFTER #close ran its last flush,
1335
- # and the materialized-dir decision at the bottom needs to ask it.
1336
- query_handler = @query_handler
1337
- begin
1338
- @query_handler&.close
1339
- ensure
1340
- @query_handler = nil
1341
- begin
1342
- @transport&.close
1343
- ensure
1344
- @transport = nil
1345
- @connected = false
1346
- # Remove the materialized resume temp dir AFTER the subprocess
1347
- # exited — unless the mirror dropped batches: the store copy is then
1348
- # incomplete and the temp dir holds the only copy of the dropped
1349
- # turns, so it is preserved (scrubbed of credentials) with a warning
1350
- # instead of deleted.
1351
- if @materialized
1352
- if query_handler&.mirror_batches_dropped?
1353
- @materialized.preserve_transcripts
1354
- else
1355
- @materialized.cleanup
1356
- end
1357
- @materialized = nil
1358
- end
1359
- end
1231
+ # Notified inside the method body, with the teardown in its ensure: an
1232
+ # exception raised into this fiber while it waits for an observer (a
1233
+ # caller's deadline) propagates, and must not skip the teardown.
1234
+ @dispatch.notify(:on_close) if @connected
1235
+ ensure
1236
+ # SessionAssembly#close_resources closes the query handler (which
1237
+ # closes the transport, flushing the mirror batcher first), closes the
1238
+ # transport in an ensure of its own (it covers a failure before the
1239
+ # handler was built, and a handler whose #close raised) and decides
1240
+ # what happens to the materialized temp dir, which holds a redacted
1241
+ # .credentials.json copy. Whatever one of those raises, the others
1242
+ # still run — so disconnect can never leave the client half-open. The
1243
+ # original error still propagates.
1244
+ #
1245
+ # The client is disconnected as soon as both closes are behind, before
1246
+ # the temp dir is dealt with: its removal can take a while and lets
1247
+ # other tasks run, and what they call meanwhile has to be refused as
1248
+ # "Not connected" (and a disconnect of theirs must not notify on_close
1249
+ # again) rather than reach a session whose resources are gone.
1250
+ #
1251
+ # The session is let go of only once all of it was disposed of. When
1252
+ # the teardown raises — a close failed, or the removal of the temp dir
1253
+ # was cut short by the caller's deadline or a stop — the session stays,
1254
+ # holding whatever it could not dispose of, so that the next disconnect
1255
+ # finishes the job (what was already closed is not closed again). It
1256
+ # is not the client's session any more once a reconnect replaced it.
1257
+ session = @session
1258
+ if session
1259
+ session.close_resources(always_close_transport: true) { @connected = false }
1260
+ @session = nil if @session.equal?(session)
1261
+ else
1262
+ @connected = false
1360
1263
  end
1361
1264
  end
1362
1265
 
1363
1266
  private
1364
1267
 
1365
- # Resume-from-store: when a session_store is set (and a subprocess transport
1366
- # is in use), materialize the session into a temp CLAUDE_CONFIG_DIR and
1367
- # return options repointed at it (env + --resume). Returns the options
1368
- # unchanged when no materialization applies. Skipped for non-subprocess
1369
- # transports — the materialized env/--resume only affect the CLI subprocess.
1370
- # Ancestry (<=), not identity: a SubprocessCLITransport subclass spawns the
1371
- # CLI with the same env/--resume semantics, and the transport is constructed
1372
- # AFTER materialization, so the repointed options do reach it.
1373
- def materialize_resume(options)
1374
- subprocess_transport = @transport_class.is_a?(Class) && @transport_class <= SubprocessCLITransport
1375
- return options unless options.session_store && subprocess_transport
1376
-
1377
- @materialized = SessionResume.materialize_resume_session(options)
1378
- @materialized ? SessionResume.apply_materialized_options(options, @materialized) : options
1379
- end
1380
-
1381
- # The connect body, wrapped by #connect so a failure triggers cleanup.
1382
- def connect_inner(configured_options, prompt) # rubocop:disable Metrics/MethodLength -- connect sequence kept in order; #connect wraps it for cleanup
1383
- # Client always uses streaming mode; keep stdin open for bidirectional
1384
- # communication. Observers were already resolved by #connect.
1385
- @transport = @transport_class.new(configured_options, **@transport_args)
1386
- @transport.connect
1387
-
1388
- # Extract SDK MCP servers
1389
- sdk_mcp_servers = ClaudeAgentSDK.extract_sdk_mcp_servers(configured_options.mcp_servers)
1390
-
1391
- # Convert hooks to internal format
1392
- hooks = ClaudeAgentSDK.convert_hooks_to_internal_format(configured_options.hooks)
1393
-
1394
- # Extract exclude_dynamic_sections and snapshot from the system prompt
1395
- # for the initialize request (older CLIs ignore unknown initialize fields)
1396
- exclude_dynamic_sections = ClaudeAgentSDK.extract_exclude_dynamic_sections(configured_options.system_prompt)
1397
- system_prompt_snapshot = ClaudeAgentSDK.extract_system_prompt_snapshot(configured_options.system_prompt)
1398
-
1399
- # Captured once, so String and streamed prompts in one session are
1400
- # stamped alike (the Query stamps the streamed ones with this value).
1401
- @verbatim_prompts = configured_options.verbatim_prompts?
1402
-
1403
- # Create Query handler
1404
- @query_handler = Query.new(
1405
- transport: @transport,
1406
- is_streaming_mode: true,
1407
- can_use_tool: configured_options.can_use_tool,
1408
- hooks: hooks,
1409
- sdk_mcp_servers: sdk_mcp_servers,
1410
- agents: configured_options.agents,
1411
- exclude_dynamic_sections: exclude_dynamic_sections,
1412
- system_prompt_snapshot: system_prompt_snapshot,
1413
- skills: configured_options.skills,
1414
- forward_subagent_text: configured_options.forward_subagent_text?,
1415
- agent_progress_summaries: configured_options.agent_progress_summaries,
1416
- callback_scheduling: @callback_scheduling,
1417
- callback_wrapper: @callback_wrapper,
1418
- verbatim_prompts: @verbatim_prompts,
1419
- run_end_ceiling_ms: Query.run_end_ceiling_ms(configured_options.env)
1420
- )
1421
-
1422
- # Mirror transcripts to the session_store, if configured.
1423
- install_transcript_mirror(configured_options)
1424
-
1425
- # Start query handler and initialize
1426
- @query_handler.start
1427
- @query_handler.initialize_protocol
1428
-
1429
- @connected = true
1430
-
1431
- # Optionally send initial prompt/messages after connection is ready.
1268
+ # Optionally send the initial prompt/messages once the connection is ready.
1269
+ def send_initial_prompt(prompt)
1432
1270
  case prompt
1433
1271
  when nil
1434
1272
  nil
@@ -1447,9 +1285,7 @@ module ClaudeAgentSDK # rubocop:disable Metrics/ModuleLength -- the public entry
1447
1285
  # Observer#on_error contract; notifying a swallowed error would mark
1448
1286
  # a still-live OTel trace as failed). Same behavior as query()'s
1449
1287
  # streaming path.
1450
- observed = ClaudeAgentSDK.observing_prompt_stream(prompt, @resolved_observers,
1451
- scheduling: @callback_scheduling, wrapper: @callback_wrapper)
1452
- @query_handler.spawn_task { @query_handler.stream_input(observed) }
1288
+ @session.stream_prompt_in_background(prompt)
1453
1289
  end
1454
1290
  end
1455
1291
 
@@ -1461,22 +1297,22 @@ module ClaudeAgentSDK # rubocop:disable Metrics/ModuleLength -- the public entry
1461
1297
  # parse-stamp-regenerate, which would block the reactor on huge frames),
1462
1298
  # except that verbatim_prompts must mark them `client_composed`, so with
1463
1299
  # that option on they are parsed and re-serialized (Query.stamp_user_message).
1300
+ # Every message is stamped with the value captured at connect, like the
1301
+ # String prompts of the same session.
1464
1302
  def stream_query_messages(prompt, session_id)
1465
1303
  prompt.each do |msg|
1466
1304
  case msg
1467
1305
  when Hash
1468
1306
  msg = msg.merge(session_id: session_id) unless msg.key?(:session_id) || msg.key?('session_id')
1469
1307
  if (text = ClaudeAgentSDK.extract_user_prompt_text(msg))
1470
- ClaudeAgentSDK.notify_observers(@resolved_observers, :on_user_prompt, text,
1471
- scheduling: @callback_scheduling, wrapper: @callback_wrapper)
1308
+ @dispatch.notify(:on_user_prompt, text)
1472
1309
  end
1473
- writeln(Query.serialize_user_message(msg, @verbatim_prompts))
1310
+ @session.write_message(msg)
1474
1311
  when String
1475
1312
  if (text = ClaudeAgentSDK.extract_user_prompt_text(msg))
1476
- ClaudeAgentSDK.notify_observers(@resolved_observers, :on_user_prompt, text,
1477
- scheduling: @callback_scheduling, wrapper: @callback_wrapper)
1313
+ @dispatch.notify(:on_user_prompt, text)
1478
1314
  end
1479
- writeln(Query.serialize_user_message(msg, @verbatim_prompts))
1315
+ @session.write_message(msg)
1480
1316
  else
1481
1317
  # No to_s fallback — silently serializing arbitrary objects is the
1482
1318
  # exact inspect-garbage bug class this method exists to prevent.
@@ -1484,36 +1320,5 @@ module ClaudeAgentSDK # rubocop:disable Metrics/ModuleLength -- the public entry
1484
1320
  end
1485
1321
  end
1486
1322
  end
1487
-
1488
- # Notify observers of an error surfacing to the consumer. `|| []` keeps a
1489
- # mis-scoped call before connect harmless instead of NoMethodError on nil.
1490
- def notify_error(error)
1491
- ClaudeAgentSDK.notify_observers(@resolved_observers || [], :on_error, error,
1492
- scheduling: @callback_scheduling, wrapper: @callback_wrapper)
1493
- end
1494
-
1495
- # Build and install the transcript-mirror batcher on the query handler when
1496
- # a session_store is configured, via the shared SessionResume helper (also
1497
- # used by the one-shot query() path).
1498
- def install_transcript_mirror(options)
1499
- return unless options.session_store
1500
-
1501
- batcher = SessionResume.build_mirror_batcher(
1502
- store: options.session_store,
1503
- env: options.env,
1504
- on_error: ->(key, message) { @query_handler.report_mirror_error(key, message) },
1505
- eager: options.session_store_flush.to_s == 'eager',
1506
- callback_wrapper: @callback_wrapper
1507
- )
1508
- @query_handler.set_transcript_mirror_batcher(batcher)
1509
- end
1510
-
1511
- def writeln(string)
1512
- write string.end_with?("\n") ? string : "#{string}\n"
1513
- end
1514
-
1515
- def write(string)
1516
- @transport.write(string)
1517
- end
1518
1323
  end
1519
1324
  end