claude-agent-sdk 1.2.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.
@@ -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,16 +68,7 @@ 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
@@ -138,18 +131,7 @@ module ClaudeAgentSDK # rubocop:disable Metrics/ModuleLength -- the public entry
138
131
  # Shared by Client#connect and the one-shot query() path.
139
132
  # @api private
140
133
  def self.extract_exclude_dynamic_sections(system_prompt)
141
- if system_prompt.is_a?(SystemPromptPreset)
142
- eds = system_prompt.exclude_dynamic_sections
143
- return eds if [true, false].include?(eds)
144
- elsif system_prompt.is_a?(Hash)
145
- # The tag may be a Symbol (type: :preset), as CommandBuilder reads it.
146
- type = (system_prompt[:type] || system_prompt['type']).to_s
147
- if type == 'preset'
148
- eds = system_prompt.fetch(:exclude_dynamic_sections) { system_prompt['exclude_dynamic_sections'] }
149
- return eds if [true, false].include?(eds)
150
- end
151
- end
152
- nil
134
+ OptionForms.exclude_dynamic_sections(system_prompt)
153
135
  end
154
136
 
155
137
  # Internal: pull snapshot out of a preset or custom system prompt for the
@@ -159,18 +141,7 @@ module ClaudeAgentSDK # rubocop:disable Metrics/ModuleLength -- the public entry
159
141
  # must not collapse it to nil. Shared by Client#connect and query().
160
142
  # @api private
161
143
  def self.extract_system_prompt_snapshot(system_prompt)
162
- case system_prompt
163
- when SystemPromptPreset, SystemPromptCustom
164
- snapshot = system_prompt.snapshot
165
- return snapshot if [true, false].include?(snapshot)
166
- when Hash
167
- type = (system_prompt[:type] || system_prompt['type']).to_s
168
- if %w[preset custom].include?(type)
169
- snapshot = system_prompt.fetch(:snapshot) { system_prompt['snapshot'] }
170
- return snapshot if [true, false].include?(snapshot)
171
- end
172
- end
173
- nil
144
+ OptionForms.system_prompt_snapshot(system_prompt)
174
145
  end
175
146
 
176
147
  # Safely call a method on each observer, suppressing any errors.
@@ -719,19 +690,30 @@ module ClaudeAgentSDK # rubocop:disable Metrics/ModuleLength -- the public entry
719
690
  # Fail fast on invalid session_store combinations before spawning the CLI.
720
691
  SessionStores.validate_session_store_options(configured_options)
721
692
 
722
- # Resolve callable observers into fresh instances (thread-safe for global defaults)
723
- resolved_observers = ClaudeAgentSDK.resolve_observers(configured_options.observers)
724
-
725
- # Where user callbacks run (see ClaudeAgentOptions#callback_scheduling)
726
- # and the middleware wrapped around them (#callback_wrapper).
727
- callback_scheduling = configured_options.callback_scheduling || :thread
728
- callback_wrapper = configured_options.callback_wrapper
729
- 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)
730
701
 
731
702
  if transport && !transport.respond_to?(:connect)
732
703
  raise ArgumentError, 'transport must respond to #connect (see ClaudeAgentSDK::Transport)'
733
704
  end
734
705
 
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
+
735
717
  # finished: false tells Async that a waiter handles this task's failure
736
718
  # (the .wait at the end re-raises it), as Kernel#Sync does for its own
737
719
  # task. Without it a task that fails before anyone waits for it — always
@@ -739,151 +721,54 @@ module ClaudeAgentSDK # rubocop:disable Metrics/ModuleLength -- the public entry
739
721
  # returns — is also logged as "Task may have ended with unhandled
740
722
  # exception", message and backtrace included, although the caller gets
741
723
  # the same error raised and may well rescue it.
742
- Async(finished: false, &FiberBoundary.capture_otel_context do # rubocop:disable Metrics/BlockLength -- the reactor task body of query()
743
- materialized = nil
744
- query_handler = nil
745
- begin
746
- if transport.nil?
747
- # Resume-from-store: when a session_store is set and resume/continue
748
- # is requested, load the session into a temp CLAUDE_CONFIG_DIR and
749
- # repoint options at it (env + --resume) BEFORE spawning. Returns
750
- # options unchanged when no materialization applies. Skipped
751
- # entirely for an injected transport — the materialized
752
- # env/--resume only apply to the CLI subprocess (Python parity:
753
- # client.py skips materialization when a transport is supplied).
754
- materialized = SessionResume.materialize_resume_session(configured_options)
755
- if materialized
756
- configured_options = SessionResume.apply_materialized_options(configured_options, materialized)
757
- end
758
-
759
- # Always use streaming mode with control protocol (matches Python
760
- # SDK). This sends agents via initialize request instead of CLI
761
- # args, avoiding OS ARG_MAX limits.
762
- transport = SubprocessCLITransport.new(configured_options)
763
- end
764
- # Deliberate deviation from Python: the ensure below also closes an
765
- # injected transport whose #connect raised (Python leaves it
766
- # unclosed); Transport#close must be idempotent.
767
- transport.connect
768
-
769
- # Extract SDK MCP servers
770
- sdk_mcp_servers = extract_sdk_mcp_servers(configured_options.mcp_servers)
771
-
772
- hooks = convert_hooks_to_internal_format(configured_options.hooks)
773
-
774
- # Create Query handler for control protocol
775
- query_handler = Query.new(
776
- transport: transport,
777
- is_streaming_mode: true,
778
- can_use_tool: configured_options.can_use_tool,
779
- hooks: hooks,
780
- agents: configured_options.agents,
781
- sdk_mcp_servers: sdk_mcp_servers,
782
- exclude_dynamic_sections: ClaudeAgentSDK.extract_exclude_dynamic_sections(configured_options.system_prompt),
783
- system_prompt_snapshot: ClaudeAgentSDK.extract_system_prompt_snapshot(configured_options.system_prompt),
784
- skills: configured_options.skills,
785
- forward_subagent_text: configured_options.forward_subagent_text?,
786
- agent_progress_summaries: configured_options.agent_progress_summaries,
787
- callback_scheduling: callback_scheduling,
788
- callback_wrapper: callback_wrapper,
789
- verbatim_prompts: configured_options.verbatim_prompts?,
790
- run_end_ceiling_ms: Query.run_end_ceiling_ms(configured_options.env)
791
- )
792
-
793
- # Mirror transcripts to the session_store, if configured. Installed
794
- # before #start so the read loop captures transcript_mirror frames.
795
- if configured_options.session_store
796
- query_handler.set_transcript_mirror_batcher(
797
- SessionResume.build_mirror_batcher(
798
- store: configured_options.session_store,
799
- env: configured_options.env,
800
- on_error: ->(key, message) { query_handler.report_mirror_error(key, message) },
801
- eager: configured_options.session_store_flush.to_s == 'eager',
802
- callback_wrapper: callback_wrapper
803
- )
804
- )
805
- end
806
-
807
- # Start reading messages in background
808
- query_handler.start
809
-
810
- # Initialize the control protocol (sends agents)
811
- query_handler.initialize_protocol
812
-
813
- # Send prompt(s) as user messages, then close stdin
814
- if prompt.is_a?(String)
815
- ClaudeAgentSDK.notify_observers(resolved_observers, :on_user_prompt, prompt,
816
- scheduling: callback_scheduling, wrapper: callback_wrapper)
817
- message = {
818
- type: 'user',
819
- message: { role: 'user', content: prompt },
820
- parent_tool_use_id: nil,
821
- session_id: ''
822
- }
823
- transport.write("#{Query.serialize_user_message(message, configured_options.verbatim_prompts?)}\n")
824
- # Background-spawn so messages stream to the user block while stdin
825
- # close waits (without timeout) for the first result; a synchronous
826
- # call would defer all delivery until the turn completes (mirrors
827
- # Python's query.spawn_task(query.wait_for_result_and_end_input())).
828
- query_handler.spawn_task { query_handler.wait_for_result_and_end_input }
829
- elsif prompt.is_a?(Enumerator) || prompt.respond_to?(:each)
830
- # Tracked on the Query so close() stops it; an untracked Async task
831
- # here kept the root reactor alive forever when the read loop died
832
- # while the user enumerator was still blocked (matches Python's
833
- # query.spawn_task(query.stream_input(prompt))).
834
- observed_prompt = ClaudeAgentSDK.observing_prompt_stream(
835
- prompt, resolved_observers, scheduling: callback_scheduling, wrapper: callback_wrapper
836
- )
837
- query_handler.spawn_task { query_handler.stream_input(observed_prompt) }
838
- end
839
-
840
- # Read and yield messages from the query handler (filters out control messages).
841
- # User block is invoked through FiberBoundary so ActiveRecord / PG calls
842
- # inside it don't see the async gem's Fiber scheduler (default :thread
843
- # mode; :inline runs it in place on the reactor fiber).
844
- query_handler.receive_messages do |data|
845
- message = MessageParser.parse(data)
846
- next unless message
847
-
848
- ClaudeAgentSDK.notify_observers(resolved_observers, :on_message, message,
849
- scheduling: callback_scheduling, wrapper: callback_wrapper)
850
- signal = FiberBoundary.invoke_iteration(block, message, scheduling: callback_scheduling,
851
- wrapper: callback_wrapper)
852
- break signal.value if signal.is_a?(FiberBoundary::Break)
853
- end
854
- rescue StandardError => e
855
- # One notify point for every error surfacing from query() — transport
856
- # connect, initialize, stream errors re-raised from the message queue,
857
- # parse errors, and user-block errors. StandardError only: Async::Stop
858
- # is cancellation, not an error. Bare raise preserves the backtrace;
859
- # the ensure below still fires on_close after on_error.
860
- ClaudeAgentSDK.notify_observers(resolved_observers, :on_error, e,
861
- scheduling: callback_scheduling, wrapper: callback_wrapper)
862
- raise
863
- ensure
864
- ClaudeAgentSDK.notify_observers(resolved_observers, :on_close,
865
- scheduling: callback_scheduling, wrapper: callback_wrapper)
866
- # query_handler.close stops the background read task and closes the
867
- # transport (flushing the mirror batcher first). Fall back to a bare
868
- # transport close when the handler was never built.
869
- begin
870
- if query_handler
871
- query_handler.close
872
- elsif transport
873
- transport.close
874
- end
875
- ensure
876
- # Remove the materialized resume temp dir (which holds a redacted
877
- # .credentials.json copy) AFTER the subprocess has exited, even when
878
- # close itself raises — unless the mirror dropped batches: the store
879
- # copy is then incomplete and the temp dir holds the only copy of
880
- # the dropped turns, so it is preserved (scrubbed of credentials)
881
- # with a warning instead of deleted.
882
- if materialized
883
- query_handler&.mirror_batches_dropped? ? materialized.preserve_transcripts : materialized.cleanup
884
- end
885
- end
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)
886
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)
887
772
  end).wait
888
773
  end
889
774
 
@@ -965,11 +850,13 @@ module ClaudeAgentSDK # rubocop:disable Metrics/ModuleLength -- the public entry
965
850
  # }
966
851
  # )
967
852
  # client = ClaudeAgentSDK::Client.new(options: options)
968
- class Client # rubocop:disable Metrics/ClassLength -- public session API: lifecycle, control methods and their Ruby aliases
853
+ class Client
969
854
  # The session's control-protocol handler (nil until #connect).
970
855
  #
971
856
  # @api private
972
- attr_reader :query_handler
857
+ def query_handler
858
+ @session&.query_handler
859
+ end
973
860
 
974
861
  # @param options [ClaudeAgentOptions, nil] Configuration options
975
862
  # @param transport_class [Class] Transport class to use (must implement Transport interface).
@@ -977,14 +864,17 @@ module ClaudeAgentSDK # rubocop:disable Metrics/ModuleLength -- the public entry
977
864
  # @param transport_args [Hash] Additional keyword arguments passed to transport_class.new(options, **transport_args)
978
865
  def initialize(options: nil, transport_class: SubprocessCLITransport, transport_args: {})
979
866
  @options = options || ClaudeAgentOptions.new
980
- @callback_scheduling = @options.callback_scheduling || :thread
981
- @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)
982
871
  @transport_class = transport_class
983
872
  @transport_args = transport_args
984
- @transport = nil
985
- @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
986
877
  @connected = false
987
- @materialized = nil
988
878
  end
989
879
 
990
880
  # Block-scoped Client lifecycle, mirroring Python's
@@ -1053,22 +943,29 @@ module ClaudeAgentSDK # rubocop:disable Metrics/ModuleLength -- the public entry
1053
943
  # Resolve observers before the first failable runtime step so
1054
944
  # connect-phase failures (including resume materialization) can be
1055
945
  # notified via on_error.
1056
- @resolved_observers = ClaudeAgentSDK.resolve_observers(@options.observers)
946
+ @dispatch.observers = ClaudeAgentSDK.resolve_observers(@options.observers)
947
+
948
+ ClaudeAgentSDK.check_inline_isolation(@dispatch.scheduling)
1057
949
 
1058
- 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 })
1059
956
 
1060
957
  # If anything from materialization onward fails, tear down (closes the
1061
958
  # subprocess and removes the materialized temp config dir) before
1062
959
  # surfacing the error, so a partial connect never leaks a temp dir
1063
960
  # holding a credential copy.
1064
961
  begin
1065
- # Resume-from-store: materialize the session from the store into a
1066
- # temp CLAUDE_CONFIG_DIR BEFORE spawn, then repoint options at it.
1067
- # Inside the instrumented begin so store IO failures fire on_error
1068
- # (matching the one-shot query() path) and disconnect cleans up.
1069
- 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
1070
967
 
1071
- connect_inner(configured_options, prompt)
968
+ send_initial_prompt(prompt)
1072
969
  rescue Exception => e # rubocop:disable Lint/RescueException
1073
970
  # Pre-handshake failures (@connected still false) are notified here;
1074
971
  # post-handshake String-prompt send failures were already notified by
@@ -1077,14 +974,14 @@ module ClaudeAgentSDK # rubocop:disable Metrics/ModuleLength -- the public entry
1077
974
  # out of connect.) No on_close follows for pre-handshake failures
1078
975
  # (disconnect gates it on @connected): the session never opened.
1079
976
  begin
1080
- notify_error(e) if e.is_a?(StandardError) && !@connected
977
+ @dispatch.notify(:on_error, e) if e.is_a?(StandardError) && !@connected
1081
978
  ensure
1082
979
  # Tear down the partial connect, but never let a cleanup failure (e.g. a
1083
980
  # custom transport whose #close raises) mask the original connect error.
1084
981
  # Rescue Exception (not StandardError) so reactor cancellation
1085
- # (Async::Stop < Exception) after materialize_resume set @materialized
1086
- # still runs disconnect -> @materialized.cleanup, never leaking the temp
1087
- # CLAUDE_CONFIG_DIR that holds the redacted .credentials.json copy.
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.
1088
985
  # In an ensure: an exception raised into this fiber while it waits
1089
986
  # for the on_error observer (a caller's deadline) must not skip it.
1090
987
  begin
@@ -1116,15 +1013,8 @@ module ClaudeAgentSDK # rubocop:disable Metrics/ModuleLength -- the public entry
1116
1013
 
1117
1014
  begin
1118
1015
  if prompt.is_a?(String)
1119
- ClaudeAgentSDK.notify_observers(@resolved_observers, :on_user_prompt, prompt,
1120
- scheduling: @callback_scheduling, wrapper: @callback_wrapper)
1121
- message = {
1122
- type: 'user',
1123
- message: { role: 'user', content: prompt },
1124
- parent_tool_use_id: nil,
1125
- session_id: session_id
1126
- }
1127
- writeln(Query.serialize_user_message(message, @verbatim_prompts))
1016
+ @dispatch.notify(:on_user_prompt, prompt)
1017
+ @session.write_prompt(prompt, session_id: session_id)
1128
1018
  elsif prompt.respond_to?(:each)
1129
1019
  # Inline iteration on the caller, Python client.py parity — NOT
1130
1020
  # Query#stream_input, whose ensure always ends input after
@@ -1136,7 +1026,7 @@ module ClaudeAgentSDK # rubocop:disable Metrics/ModuleLength -- the public entry
1136
1026
  raise ArgumentError, "prompt must be a String or respond to #each (got #{prompt.class})"
1137
1027
  end
1138
1028
  rescue StandardError => e
1139
- notify_error(e)
1029
+ @dispatch.notify(:on_error, e)
1140
1030
  raise
1141
1031
  end
1142
1032
  end
@@ -1154,18 +1044,9 @@ module ClaudeAgentSDK # rubocop:disable Metrics/ModuleLength -- the public entry
1154
1044
  raise CLIConnectionError, 'Not connected. Call connect() first' unless @connected
1155
1045
 
1156
1046
  begin
1157
- @query_handler.receive_messages do |data|
1158
- message = MessageParser.parse(data)
1159
- next unless message
1160
-
1161
- ClaudeAgentSDK.notify_observers(@resolved_observers, :on_message, message,
1162
- scheduling: @callback_scheduling, wrapper: @callback_wrapper)
1163
- signal = FiberBoundary.invoke_iteration(block, message, scheduling: @callback_scheduling,
1164
- wrapper: @callback_wrapper)
1165
- break signal.value if signal.is_a?(FiberBoundary::Break)
1166
- end
1047
+ @session.deliver(block)
1167
1048
  rescue StandardError => e
1168
- notify_error(e)
1049
+ @dispatch.notify(:on_error, e)
1169
1050
  raise
1170
1051
  end
1171
1052
  end
@@ -1177,24 +1058,10 @@ module ClaudeAgentSDK # rubocop:disable Metrics/ModuleLength -- the public entry
1177
1058
 
1178
1059
  raise CLIConnectionError, 'Not connected. Call connect() first' unless @connected
1179
1060
 
1180
- # Keep loop control on the same fiber as the underlying dequeue: both
1181
- # the SDK's ResultMessage break and the user's translated break happen
1182
- # here, never inside the FiberBoundary hop (break in a proc on a
1183
- # foreign thread raises LocalJumpError).
1184
1061
  begin
1185
- @query_handler.receive_messages do |data|
1186
- message = MessageParser.parse(data)
1187
- next unless message
1188
-
1189
- ClaudeAgentSDK.notify_observers(@resolved_observers, :on_message, message,
1190
- scheduling: @callback_scheduling, wrapper: @callback_wrapper)
1191
- signal = FiberBoundary.invoke_iteration(block, message, scheduling: @callback_scheduling,
1192
- wrapper: @callback_wrapper)
1193
- break signal.value if signal.is_a?(FiberBoundary::Break)
1194
- break if message.is_a?(ResultMessage)
1195
- end
1062
+ @session.deliver(block, until_result: true)
1196
1063
  rescue StandardError => e
1197
- notify_error(e)
1064
+ @dispatch.notify(:on_error, e)
1198
1065
  raise
1199
1066
  end
1200
1067
  end
@@ -1203,7 +1070,7 @@ module ClaudeAgentSDK # rubocop:disable Metrics/ModuleLength -- the public entry
1203
1070
  def interrupt
1204
1071
  raise CLIConnectionError, 'Not connected. Call connect() first' unless @connected
1205
1072
 
1206
- @query_handler.interrupt
1073
+ query_handler.interrupt
1207
1074
  end
1208
1075
 
1209
1076
  # Change permission mode during conversation
@@ -1211,7 +1078,7 @@ module ClaudeAgentSDK # rubocop:disable Metrics/ModuleLength -- the public entry
1211
1078
  def set_permission_mode(mode)
1212
1079
  raise CLIConnectionError, 'Not connected. Call connect() first' unless @connected
1213
1080
 
1214
- @query_handler.set_permission_mode(mode)
1081
+ query_handler.set_permission_mode(mode)
1215
1082
  end
1216
1083
 
1217
1084
  # Ruby-style spelling of #set_permission_mode: `client.permission_mode = 'plan'`.
@@ -1225,7 +1092,7 @@ module ClaudeAgentSDK # rubocop:disable Metrics/ModuleLength -- the public entry
1225
1092
  def set_model(model)
1226
1093
  raise CLIConnectionError, 'Not connected. Call connect() first' unless @connected
1227
1094
 
1228
- @query_handler.set_model(model)
1095
+ query_handler.set_model(model)
1229
1096
  end
1230
1097
 
1231
1098
  # Ruby-style spelling of #set_model: `client.model = 'claude-opus-5'`.
@@ -1238,7 +1105,7 @@ module ClaudeAgentSDK # rubocop:disable Metrics/ModuleLength -- the public entry
1238
1105
  def reconnect_mcp_server(server_name)
1239
1106
  raise CLIConnectionError, 'Not connected. Call connect() first' unless @connected
1240
1107
 
1241
- @query_handler.reconnect_mcp_server(server_name)
1108
+ query_handler.reconnect_mcp_server(server_name)
1242
1109
  end
1243
1110
 
1244
1111
  # Enable or disable an MCP server
@@ -1247,7 +1114,7 @@ module ClaudeAgentSDK # rubocop:disable Metrics/ModuleLength -- the public entry
1247
1114
  def toggle_mcp_server(server_name, enabled)
1248
1115
  raise CLIConnectionError, 'Not connected. Call connect() first' unless @connected
1249
1116
 
1250
- @query_handler.toggle_mcp_server(server_name, enabled)
1117
+ query_handler.toggle_mcp_server(server_name, enabled)
1251
1118
  end
1252
1119
 
1253
1120
  # Stop a running background task
@@ -1255,7 +1122,7 @@ module ClaudeAgentSDK # rubocop:disable Metrics/ModuleLength -- the public entry
1255
1122
  def stop_task(task_id)
1256
1123
  raise CLIConnectionError, 'Not connected. Call connect() first' unless @connected
1257
1124
 
1258
- @query_handler.stop_task(task_id)
1125
+ query_handler.stop_task(task_id)
1259
1126
  end
1260
1127
 
1261
1128
  # Background in-flight foreground tasks (Bash commands and subagents) — the
@@ -1281,7 +1148,7 @@ module ClaudeAgentSDK # rubocop:disable Metrics/ModuleLength -- the public entry
1281
1148
  def background_tasks(tool_use_id: nil)
1282
1149
  raise CLIConnectionError, 'Not connected. Call connect() first' unless @connected
1283
1150
 
1284
- @query_handler.background_tasks(tool_use_id: tool_use_id)
1151
+ query_handler.background_tasks(tool_use_id: tool_use_id)
1285
1152
  end
1286
1153
 
1287
1154
  # Rewind files to a previous checkpoint (v0.1.15+)
@@ -1291,13 +1158,13 @@ module ClaudeAgentSDK # rubocop:disable Metrics/ModuleLength -- the public entry
1291
1158
  def rewind_files(user_message_uuid)
1292
1159
  raise CLIConnectionError, 'Not connected. Call connect() first' unless @connected
1293
1160
 
1294
- @query_handler.rewind_files(user_message_uuid)
1161
+ query_handler.rewind_files(user_message_uuid)
1295
1162
  end
1296
1163
 
1297
1164
  # Get server initialization info
1298
1165
  # @return [Hash, nil] Server info or nil
1299
1166
  def server_info
1300
- @query_handler&.initialization_result
1167
+ query_handler&.initialization_result
1301
1168
  end
1302
1169
 
1303
1170
  # Get a breakdown of current context window usage by category.
@@ -1307,7 +1174,7 @@ module ClaudeAgentSDK # rubocop:disable Metrics/ModuleLength -- the public entry
1307
1174
  def get_context_usage
1308
1175
  raise CLIConnectionError, 'Not connected. Call connect() first' unless @connected
1309
1176
 
1310
- @query_handler.get_context_usage
1177
+ query_handler.get_context_usage
1311
1178
  end
1312
1179
 
1313
1180
  # Ruby-style spelling of #get_context_usage.
@@ -1321,7 +1188,7 @@ module ClaudeAgentSDK # rubocop:disable Metrics/ModuleLength -- the public entry
1321
1188
  def get_mcp_status
1322
1189
  raise CLIConnectionError, 'Not connected. Call connect() first' unless @connected
1323
1190
 
1324
- @query_handler.get_mcp_status
1191
+ query_handler.get_mcp_status
1325
1192
  end
1326
1193
 
1327
1194
  # Ruby-style spelling of #get_mcp_status.
@@ -1358,125 +1225,48 @@ module ClaudeAgentSDK # rubocop:disable Metrics/ModuleLength -- the public entry
1358
1225
  # teardown, then lets the interruption propagate.
1359
1226
  def disconnect
1360
1227
  # Tear down whatever exists — robust to a partial/failed connect, where
1361
- # @connected is still false but a transport and/or materialized temp dir
1362
- # were already created. #close on the query handler also closes the
1363
- # transport (flushing the mirror batcher first); the extra @transport
1364
- # 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.
1365
1230
  #
1366
- # The nested ensures guarantee that even a raising close (e.g. a custom
1367
- # transport whose #close raises) still runs the transport close, resets
1368
- # state, and removes the materialized temp dir (which holds a redacted
1369
- # .credentials.json copy) — so disconnect can never leave the client
1370
- # half-open or leak the temp dir. The original error still propagates.
1371
- # Keep a handle on the query handler past the nil-out below: whether the
1372
- # mirror dropped batches is only final AFTER #close ran its last flush,
1373
- # and the materialized-dir decision at the bottom needs to ask it.
1374
- query_handler = @query_handler
1375
- begin
1376
- # Notified inside the begin: an exception raised into this fiber while
1377
- # it waits for an observer (a caller's deadline) propagates, and must
1378
- # not skip the teardown below.
1379
- if @connected
1380
- ClaudeAgentSDK.notify_observers(@resolved_observers || [], :on_close,
1381
- scheduling: @callback_scheduling, wrapper: @callback_wrapper)
1382
- end
1383
- ensure
1384
- begin
1385
- @query_handler&.close
1386
- ensure
1387
- @query_handler = nil
1388
- begin
1389
- @transport&.close
1390
- ensure
1391
- @transport = nil
1392
- @connected = false
1393
- # Remove the materialized resume temp dir AFTER the subprocess
1394
- # exited — unless the mirror dropped batches: the store copy is then
1395
- # incomplete and the temp dir holds the only copy of the dropped
1396
- # turns, so it is preserved (scrubbed of credentials) with a warning
1397
- # instead of deleted.
1398
- if @materialized
1399
- if query_handler&.mirror_batches_dropped?
1400
- @materialized.preserve_transcripts
1401
- else
1402
- @materialized.cleanup
1403
- end
1404
- @materialized = nil
1405
- end
1406
- end
1407
- 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
1408
1263
  end
1409
1264
  end
1410
1265
 
1411
1266
  private
1412
1267
 
1413
- # Resume-from-store: when a session_store is set (and a subprocess transport
1414
- # is in use), materialize the session into a temp CLAUDE_CONFIG_DIR and
1415
- # return options repointed at it (env + --resume). Returns the options
1416
- # unchanged when no materialization applies. Skipped for non-subprocess
1417
- # transports — the materialized env/--resume only affect the CLI subprocess.
1418
- # Ancestry (<=), not identity: a SubprocessCLITransport subclass spawns the
1419
- # CLI with the same env/--resume semantics, and the transport is constructed
1420
- # AFTER materialization, so the repointed options do reach it.
1421
- def materialize_resume(options)
1422
- subprocess_transport = @transport_class.is_a?(Class) && @transport_class <= SubprocessCLITransport
1423
- return options unless options.session_store && subprocess_transport
1424
-
1425
- @materialized = SessionResume.materialize_resume_session(options)
1426
- @materialized ? SessionResume.apply_materialized_options(options, @materialized) : options
1427
- end
1428
-
1429
- # The connect body, wrapped by #connect so a failure triggers cleanup.
1430
- def connect_inner(configured_options, prompt) # rubocop:disable Metrics/MethodLength -- connect sequence kept in order; #connect wraps it for cleanup
1431
- # Client always uses streaming mode; keep stdin open for bidirectional
1432
- # communication. Observers were already resolved by #connect.
1433
- @transport = @transport_class.new(configured_options, **@transport_args)
1434
- @transport.connect
1435
-
1436
- # Extract SDK MCP servers
1437
- sdk_mcp_servers = ClaudeAgentSDK.extract_sdk_mcp_servers(configured_options.mcp_servers)
1438
-
1439
- # Convert hooks to internal format
1440
- hooks = ClaudeAgentSDK.convert_hooks_to_internal_format(configured_options.hooks)
1441
-
1442
- # Extract exclude_dynamic_sections and snapshot from the system prompt
1443
- # for the initialize request (older CLIs ignore unknown initialize fields)
1444
- exclude_dynamic_sections = ClaudeAgentSDK.extract_exclude_dynamic_sections(configured_options.system_prompt)
1445
- system_prompt_snapshot = ClaudeAgentSDK.extract_system_prompt_snapshot(configured_options.system_prompt)
1446
-
1447
- # Captured once, so String and streamed prompts in one session are
1448
- # stamped alike (the Query stamps the streamed ones with this value).
1449
- @verbatim_prompts = configured_options.verbatim_prompts?
1450
-
1451
- # Create Query handler
1452
- @query_handler = Query.new(
1453
- transport: @transport,
1454
- is_streaming_mode: true,
1455
- can_use_tool: configured_options.can_use_tool,
1456
- hooks: hooks,
1457
- sdk_mcp_servers: sdk_mcp_servers,
1458
- agents: configured_options.agents,
1459
- exclude_dynamic_sections: exclude_dynamic_sections,
1460
- system_prompt_snapshot: system_prompt_snapshot,
1461
- skills: configured_options.skills,
1462
- forward_subagent_text: configured_options.forward_subagent_text?,
1463
- agent_progress_summaries: configured_options.agent_progress_summaries,
1464
- callback_scheduling: @callback_scheduling,
1465
- callback_wrapper: @callback_wrapper,
1466
- verbatim_prompts: @verbatim_prompts,
1467
- run_end_ceiling_ms: Query.run_end_ceiling_ms(configured_options.env)
1468
- )
1469
-
1470
- # Mirror transcripts to the session_store, if configured.
1471
- install_transcript_mirror(configured_options)
1472
-
1473
- # Start query handler and initialize
1474
- @query_handler.start
1475
- @query_handler.initialize_protocol
1476
-
1477
- @connected = true
1478
-
1479
- # 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)
1480
1270
  case prompt
1481
1271
  when nil
1482
1272
  nil
@@ -1495,9 +1285,7 @@ module ClaudeAgentSDK # rubocop:disable Metrics/ModuleLength -- the public entry
1495
1285
  # Observer#on_error contract; notifying a swallowed error would mark
1496
1286
  # a still-live OTel trace as failed). Same behavior as query()'s
1497
1287
  # streaming path.
1498
- observed = ClaudeAgentSDK.observing_prompt_stream(prompt, @resolved_observers,
1499
- scheduling: @callback_scheduling, wrapper: @callback_wrapper)
1500
- @query_handler.spawn_task { @query_handler.stream_input(observed) }
1288
+ @session.stream_prompt_in_background(prompt)
1501
1289
  end
1502
1290
  end
1503
1291
 
@@ -1509,22 +1297,22 @@ module ClaudeAgentSDK # rubocop:disable Metrics/ModuleLength -- the public entry
1509
1297
  # parse-stamp-regenerate, which would block the reactor on huge frames),
1510
1298
  # except that verbatim_prompts must mark them `client_composed`, so with
1511
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.
1512
1302
  def stream_query_messages(prompt, session_id)
1513
1303
  prompt.each do |msg|
1514
1304
  case msg
1515
1305
  when Hash
1516
1306
  msg = msg.merge(session_id: session_id) unless msg.key?(:session_id) || msg.key?('session_id')
1517
1307
  if (text = ClaudeAgentSDK.extract_user_prompt_text(msg))
1518
- ClaudeAgentSDK.notify_observers(@resolved_observers, :on_user_prompt, text,
1519
- scheduling: @callback_scheduling, wrapper: @callback_wrapper)
1308
+ @dispatch.notify(:on_user_prompt, text)
1520
1309
  end
1521
- writeln(Query.serialize_user_message(msg, @verbatim_prompts))
1310
+ @session.write_message(msg)
1522
1311
  when String
1523
1312
  if (text = ClaudeAgentSDK.extract_user_prompt_text(msg))
1524
- ClaudeAgentSDK.notify_observers(@resolved_observers, :on_user_prompt, text,
1525
- scheduling: @callback_scheduling, wrapper: @callback_wrapper)
1313
+ @dispatch.notify(:on_user_prompt, text)
1526
1314
  end
1527
- writeln(Query.serialize_user_message(msg, @verbatim_prompts))
1315
+ @session.write_message(msg)
1528
1316
  else
1529
1317
  # No to_s fallback — silently serializing arbitrary objects is the
1530
1318
  # exact inspect-garbage bug class this method exists to prevent.
@@ -1532,36 +1320,5 @@ module ClaudeAgentSDK # rubocop:disable Metrics/ModuleLength -- the public entry
1532
1320
  end
1533
1321
  end
1534
1322
  end
1535
-
1536
- # Notify observers of an error surfacing to the consumer. `|| []` keeps a
1537
- # mis-scoped call before connect harmless instead of NoMethodError on nil.
1538
- def notify_error(error)
1539
- ClaudeAgentSDK.notify_observers(@resolved_observers || [], :on_error, error,
1540
- scheduling: @callback_scheduling, wrapper: @callback_wrapper)
1541
- end
1542
-
1543
- # Build and install the transcript-mirror batcher on the query handler when
1544
- # a session_store is configured, via the shared SessionResume helper (also
1545
- # used by the one-shot query() path).
1546
- def install_transcript_mirror(options)
1547
- return unless options.session_store
1548
-
1549
- batcher = SessionResume.build_mirror_batcher(
1550
- store: options.session_store,
1551
- env: options.env,
1552
- on_error: ->(key, message) { @query_handler.report_mirror_error(key, message) },
1553
- eager: options.session_store_flush.to_s == 'eager',
1554
- callback_wrapper: @callback_wrapper
1555
- )
1556
- @query_handler.set_transcript_mirror_batcher(batcher)
1557
- end
1558
-
1559
- def writeln(string)
1560
- write string.end_with?("\n") ? string : "#{string}\n"
1561
- end
1562
-
1563
- def write(string)
1564
- @transport.write(string)
1565
- end
1566
1323
  end
1567
1324
  end