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
@@ -57,11 +57,18 @@ module ClaudeAgentSDK
57
57
  # the interlock keeps a reload from unloading code under it until the
58
58
  # whole SDK call returns.
59
59
  # 3. Otherwise (production: no reloading, concurrency allowed) — run the
60
- # callback inside `Rails.application.executor.wrap`.
60
+ # callback inside the executor (see .run_in_executor).
61
61
  #
62
62
  # The configuration is read on every call, so the same wrapper is correct
63
63
  # in every environment.
64
64
  #
65
+ # The wrapper reports nothing to `Rails.error`, in any branch. An exception
66
+ # that escapes a callback reaches the code that called the SDK, whose own
67
+ # layer (the request middleware, ActiveJob) reports it with its context;
68
+ # the failures the SDK handles itself — a hook or tool error answered to
69
+ # the CLI, a swallowed observer error, a cancellation — are not errors of
70
+ # the application.
71
+ #
65
72
  # @return [Proc] a callable suitable for `callback_wrapper:`
66
73
  # @example config/initializers/claude_agent_sdk.rb
67
74
  # ClaudeAgentSDK.configure do |config|
@@ -71,7 +78,7 @@ module ClaudeAgentSDK
71
78
  lambda do |invocation|
72
79
  app = ::Rails.application
73
80
  next invocation.call if app.nil? || app.executor.active?
74
- next app.executor.wrap { invocation.call } unless executor_locks?(app.config)
81
+ next run_in_executor(app.executor, invocation) unless executor_locks?(app.config)
75
82
 
76
83
  begin
77
84
  invocation.call
@@ -81,6 +88,24 @@ module ClaudeAgentSDK
81
88
  end
82
89
  end
83
90
 
91
+ # `executor.wrap { invocation.call }` minus its error report. `wrap`
92
+ # rescues what passes through it (every Exception on Rails 8.1, 8.0.2+
93
+ # and 7.2.3+; StandardError before) and reports it to `Rails.error` as
94
+ # unhandled, from a callback thread that has none of the caller's
95
+ # context — and Rails then skips the same exception as already reported
96
+ # when it reaches the request or job. It reported the SDK's own
97
+ # cancellations and the callback failures the SDK answers or swallows,
98
+ # too. `run!` / `complete!` run the same hooks.
99
+ def self.run_in_executor(executor, invocation)
100
+ execution = executor.run!
101
+ begin
102
+ invocation.call
103
+ ensure
104
+ execution.complete!
105
+ end
106
+ end
107
+ private_class_method :run_in_executor
108
+
84
109
  # Whether railties registered a process-wide lock hook on the executor
85
110
  # (Rails::Application::Finisher, initializer
86
111
  # :configure_executor_for_concurrency).
@@ -36,6 +36,9 @@ module ClaudeAgentSDK
36
36
  # mangled into nonsense parameter lists ("additionalProperties" as a
37
37
  # required string param). A $ref-only schema without type: 'object' remains
38
38
  # indistinguishable from a params hash — declare the type alongside $ref.
39
+ # For the same reason { type: :object } is this prebuilt accept-any-object
40
+ # schema even though :object is also a shorthand type: a simple schema with
41
+ # an object parameter literally named type has to spell it { type: Hash }.
39
42
  # @api private
40
43
  def self.prebuilt_json_schema?(schema)
41
44
  return false unless schema.is_a?(Hash)
@@ -67,13 +70,16 @@ module ClaudeAgentSDK
67
70
  # @api private
68
71
  def self.ruby_type_to_json_schema(type)
69
72
  # Class#=== matches instances, not the class object used in { id: Integer }.
70
- type = { String => :string, Integer => :integer, Float => :float,
71
- TrueClass => :boolean, FalseClass => :boolean }.fetch(type, type)
73
+ type = { String => :string, Integer => :integer, Float => :float, TrueClass => :boolean,
74
+ FalseClass => :boolean, Array => :array, Hash => :object }.fetch(type, type)
72
75
  case type
73
76
  when :string, String then { type: 'string' }
74
77
  when :integer, Integer then { type: 'integer' }
75
78
  when :float, Float, :number then { type: 'number' }
76
79
  when :boolean, TrueClass, FalseClass then { type: 'boolean' }
80
+ # The class or the Symbol only. An Array or Hash VALUE ([String], a nested
81
+ # schema fragment) is not a shorthand and falls through like before.
82
+ when :array, :object then { type: type.to_s }
77
83
  else { type: 'string' } # rubocop:disable Lint/DuplicateBranch -- default fallback; the :string arm stays explicit
78
84
  end
79
85
  end
@@ -301,13 +307,25 @@ module ClaudeAgentSDK
301
307
  ClaudeAgentSDK.normalize_tool_result(tool.handler.call(arguments))
302
308
  end
303
309
 
304
- # Guard before flexible_fetch: it raises on non-Hash inputs.
305
- content = result.is_a?(Hash) ? ClaudeAgentSDK.flexible_fetch(result, 'content', 'content') : nil
306
- return error_tool_result("Tool '#{name}' must return a hash with :content key") unless content
310
+ # Guard before flexible_fetch: it raises on non-Hash inputs. This first
311
+ # diagnostic is about the KEY: a present false or nil is a wrong value
312
+ # and gets the Array diagnostic below, like any other non-Array.
313
+ unless result.is_a?(Hash) && (result.key?(:content) || result.key?('content'))
314
+ return error_tool_result("Tool '#{name}' must return a hash with :content key")
315
+ end
316
+
317
+ content = ClaudeAgentSDK.flexible_fetch(result, 'content', 'content')
318
+ # A String or a single block here is not a result an MCP client accepts.
319
+ unless content.is_a?(Array)
320
+ return error_tool_result("Tool '#{name}' must return :content as an Array of content blocks " \
321
+ "(got #{content.class})")
322
+ end
307
323
 
308
324
  result
309
- rescue StandardError => e
310
- # Bare e.message like Python's str(e) — no prefix.
325
+ rescue *FiberBoundary::CALLBACK_FAILURES => e
326
+ # Bare e.message like Python's str(e) — no prefix. The list, not just
327
+ # StandardError: a handler's NotImplementedError, LoadError or
328
+ # SystemStackError is a tool failure the model should read as well.
311
329
  error_tool_result(e.message)
312
330
  end
313
331
 
@@ -412,7 +430,7 @@ module ClaudeAgentSDK
412
430
  end
413
431
 
414
432
  # Create dynamic Tool classes from tool definitions
415
- def create_tool_classes(tools) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength -- builds each dynamic MCP::Tool subclass inline
433
+ def create_tool_classes(tools) # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity -- builds each dynamic MCP::Tool subclass inline
416
434
  # Captured so the dynamic class can resolve the effective scheduling
417
435
  # mode at call time — same pattern as prompt classes.
418
436
  sdk_server = self
@@ -444,16 +462,22 @@ module ClaudeAgentSDK
444
462
  end
445
463
 
446
464
  def input_schema_value
447
- # Full-schema construction: the gem JSON-round-trips and
448
- # validates against the draft4 metaschema. additionalProperties/
449
- # enum/description survive. Empty required arrays are stripped —
450
- # draft4's metaschema mandates non-empty required (Python's
451
- # modern jsonschema accepts []). Schemas the draft4 metaschema
452
- # rejects (numeric exclusiveMinimum, $ref/$defs — valid modern
453
- # JSON Schema that Python accepts) fall back to a permissive
454
- # schema with a one-time warning: the tool keeps working with
455
- # argument validation disabled instead of being permanently
456
- # uncallable while tools/list advertises it as healthy.
465
+ # Full-schema construction: the gem JSON-round-trips the schema
466
+ # and validates it against the JSON Schema 2020-12 metaschema
467
+ # (every mcp version this gem supports; older ones used draft4).
468
+ # additionalProperties/enum/description survive, and so do a
469
+ # numeric exclusiveMinimum and same-document $ref/$defs. Empty
470
+ # required arrays are stripped — a holdover from draft4, whose
471
+ # metaschema mandated a non-empty required.
472
+ #
473
+ # A schema the gem refuses (ArgumentError: a draft4-style
474
+ # boolean exclusiveMinimum, an unknown type, a $ref that leaves
475
+ # the document, an invalid pattern, ...) falls back to a
476
+ # permissive schema with a warning, once per tool: the tool
477
+ # keeps working with argument validation disabled instead of
478
+ # being permanently uncallable while tools/list advertises it
479
+ # as healthy. (The warning's "not draft4-compatible" wording
480
+ # dates from the draft4 days.)
457
481
  @input_schema_value ||= begin
458
482
  schema = ClaudeAgentSDK.normalize_tool_schema(@tool_def.input_schema)
459
483
  schema = schema.except(:required) if schema[:required].is_a?(Array) && schema[:required].empty?
@@ -493,11 +517,21 @@ module ClaudeAgentSDK
493
517
 
494
518
  # Guard BEFORE flexible_fetch: on a non-Hash it raises
495
519
  # TypeError/NoMethodError, surfacing garbage instead of the
496
- # friendly message.
497
- raise "Tool '#{@tool_def.name}' must return a hash with :content key" unless result.is_a?(Hash)
520
+ # friendly message. This first diagnostic is about the KEY: a
521
+ # present false or nil is a wrong value and gets the Array
522
+ # diagnostic below, like any other non-Array.
523
+ unless result.is_a?(Hash) && (result.key?(:content) || result.key?('content'))
524
+ raise "Tool '#{@tool_def.name}' must return a hash with :content key"
525
+ end
498
526
 
499
527
  content = ClaudeAgentSDK.flexible_fetch(result, 'content', 'content')
500
- raise "Tool '#{@tool_def.name}' must return a hash with :content key" if content.nil?
528
+ # A String or a single block Hash would go out as it is, and the
529
+ # CLI rejects that frame against its schema: the model is told
530
+ # the SERVER returned a malformed result and never sees the text.
531
+ unless content.is_a?(Array)
532
+ raise "Tool '#{@tool_def.name}' must return :content as an Array of content blocks " \
533
+ "(got #{content.class})"
534
+ end
501
535
 
502
536
  is_error = ClaudeAgentSDK.flexible_fetch(result, 'isError', 'is_error')
503
537
  structured_content = ClaudeAgentSDK.flexible_fetch(result, 'structuredContent', 'structured_content')
@@ -507,12 +541,15 @@ module ClaudeAgentSDK
507
541
  error: !!is_error,
508
542
  structured_content: structured_content
509
543
  )
510
- rescue StandardError => e
544
+ rescue *FiberBoundary::CALLBACK_FAILURES => e
511
545
  # Report handler failures in-band HERE rather than letting them
512
546
  # reach the gem: mcp >= 1.2 deliberately drops e.message from
513
547
  # its "Internal error calling tool X" wrapper (CWE-209), which
514
548
  # would hide the text the model needs to self-correct. Bare
515
549
  # e.message like Python's str(e) and #call_tool — no prefix.
550
+ # The list, not just StandardError: the gem rescues only
551
+ # StandardError, so a handler's NotImplementedError / LoadError /
552
+ # SystemStackError would pass it unanswered.
516
553
  # Nothing gem-internal can be swallowed here today: handlers get
517
554
  # no server_context, so MCP::CancelledError never originates
518
555
  # inside this method. Revisit if cancellation is ever plumbed in.
@@ -606,7 +643,17 @@ module ClaudeAgentSDK
606
643
  #
607
644
  # @param name [String] Unique identifier for the tool
608
645
  # @param description [String] Human-readable description
609
- # @param input_schema [Hash] Schema defining input parameters
646
+ # @param input_schema [Hash] Schema defining input parameters: a full JSON
647
+ # Schema (+{ type: 'object', properties: ... }+), or the shorthand
648
+ # +{ name: type }+, in which every parameter is required and each type is
649
+ # +String+ / +:string+, +Integer+ / +:integer+, +Float+ / +:float+ /
650
+ # +:number+, +TrueClass+ / +FalseClass+ / +:boolean+, +Array+ / +:array+
651
+ # or +Hash+ / +:object+
652
+ # @param annotations [Hash, nil] MCP tool annotations (+title+,
653
+ # +readOnlyHint+, ...). +maxResultSizeChars+ is also forwarded as
654
+ # +_meta['anthropic/maxResultSizeChars']+, the form the CLI reads
655
+ # @param meta [Hash, nil] The tool's +_meta+. Merged with the size hint
656
+ # derived from +annotations+; a key given here wins
610
657
  # @param handler [Proc] Block that implements the tool logic. It returns a
611
658
  # String, sent to Claude as a single text block, or a Hash with a
612
659
  # +:content+ Array of MCP content blocks plus optional +:is_error+ /
@@ -641,11 +688,16 @@ module ClaudeAgentSDK
641
688
  def self.create_tool(name, description, input_schema, annotations: nil, meta: nil, &handler)
642
689
  raise ArgumentError, 'Block required for tool handler' unless handler
643
690
 
644
- # Auto-populate _meta with maxResultSizeChars from annotations if present
691
+ # Auto-populate _meta with maxResultSizeChars from annotations if present.
692
+ # An explicit meta: is merged with that hint rather than replacing it (an
693
+ # unrelated key used to drop it); a size key the caller sets itself wins,
694
+ # in either spelling — adding the String key beside a Symbol one would
695
+ # put the same JSON key in the frame twice.
696
+ size_key = 'anthropic/maxResultSizeChars'
645
697
  resolved_meta = meta
646
- if resolved_meta.nil? && annotations
698
+ if annotations.is_a?(Hash) && (meta.nil? || (meta.is_a?(Hash) && meta.keys.none? { |key| key.to_s == size_key }))
647
699
  max_chars = annotations[:maxResultSizeChars] || annotations['maxResultSizeChars']
648
- resolved_meta = { 'anthropic/maxResultSizeChars' => max_chars } if max_chars
700
+ resolved_meta = { size_key => max_chars }.merge(meta || {}) if max_chars
649
701
  end
650
702
 
651
703
  # tools/call arrives from the CLI with the name as a JSON String; the mcp
@@ -0,0 +1,311 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'fiber_boundary'
4
+ require_relative 'message_parser'
5
+ require_relative 'query'
6
+ require_relative 'session_resume'
7
+ require_relative 'subprocess_cli_transport'
8
+
9
+ module ClaudeAgentSDK
10
+ # The three things every dispatch of a session's user code needs — its
11
+ # resolved observers, where callbacks run (callback_scheduling) and the
12
+ # middleware around them (callback_wrapper) — held in one place, so that
13
+ # no call site threads them by hand. A context holder, nothing deeper: each
14
+ # method is the module function it names, called with the triple.
15
+ #
16
+ # Scheduling and wrapper are fixed for the life of the object. The
17
+ # observers can be replaced, and each call below reads the ones there are
18
+ # at that moment: query() never replaces them; Client keeps one Dispatch
19
+ # for its lifetime and puts in the observers it resolves on each connect,
20
+ # so a receive loop still running from before a reconnect notifies the
21
+ # current ones.
22
+ #
23
+ # @api private
24
+ class Dispatch
25
+ # For what takes the pair as its own arguments (Query, the transcript
26
+ # mirror batcher).
27
+ attr_reader :scheduling, :wrapper
28
+
29
+ # Replace the observers, with a new Array: the one being replaced may be
30
+ # in the middle of a notification, and is never changed in place.
31
+ attr_writer :observers
32
+
33
+ def initialize(observers, scheduling:, wrapper:)
34
+ @observers = observers
35
+ @scheduling = scheduling
36
+ @wrapper = wrapper
37
+ end
38
+
39
+ # See ClaudeAgentSDK.notify_observers.
40
+ def notify(method, *)
41
+ ClaudeAgentSDK.notify_observers(@observers, method, *, scheduling: @scheduling, wrapper: @wrapper)
42
+ end
43
+
44
+ # See ClaudeAgentSDK.observing_prompt_stream.
45
+ def observing_stream(prompt)
46
+ ClaudeAgentSDK.observing_prompt_stream(prompt, @observers, scheduling: @scheduling, wrapper: @wrapper)
47
+ end
48
+
49
+ # See FiberBoundary.invoke_iteration.
50
+ def invoke_iteration(block, message)
51
+ FiberBoundary.invoke_iteration(block, message, scheduling: @scheduling, wrapper: @wrapper)
52
+ end
53
+ end
54
+
55
+ # The resources of one session and what is done with them: acquiring the
56
+ # transport, the Query on top of it and (for a store-backed resume) the
57
+ # materialized config dir; writing prompts; delivering messages; disposing
58
+ # of all three.
59
+ #
60
+ # This is implementation shared by the two entry points, ClaudeAgentSDK.query
61
+ # and Client, not a lifecycle of its own. When to connect, what observers are
62
+ # told, which error wins and whether stdin is closed after a prompt differ
63
+ # between the two and stay with them; so nothing here notifies on_error or
64
+ # on_close, and nothing here rescues.
65
+ #
66
+ # @api private
67
+ class SessionAssembly
68
+ # For #write_prompt: stamp with the verbatim_prompts value #connect captured.
69
+ CAPTURED = :captured
70
+
71
+ # The session's control-protocol handler: nil before #connect and after
72
+ # #close_resources. The only state that is read from outside.
73
+ attr_reader :query_handler
74
+
75
+ # Acquires nothing. +transport_source+ says where the transport comes from:
76
+ #
77
+ # { instance: transport } one the caller built (query(transport:))
78
+ # { class: klass, args: kwargs } one to construct as klass.new(options, **kwargs)
79
+ #
80
+ # +configured_options+ are the options after can_use_tool routing and
81
+ # validation; #connect may replace them with a copy repointed at a
82
+ # materialized resume.
83
+ def initialize(configured_options, dispatch:, transport_source:)
84
+ @options = configured_options
85
+ @dispatch = dispatch
86
+ @transport_source = transport_source
87
+ @transport = nil
88
+ @query_handler = nil
89
+ @materialized = nil
90
+ @verbatim_prompts = false
91
+ end
92
+
93
+ # Acquire, in order: the materialized resume (when it applies) and the
94
+ # options repointed at it, the transport, its connection, the Query, the
95
+ # transcript mirror (before the read loop starts, so that it sees every
96
+ # transcript_mirror frame), the read loop, the handshake.
97
+ #
98
+ # Each resource is recorded before the next step that can fail and nothing
99
+ # is rolled back here: whatever a step raises propagates, and the caller's
100
+ # own rescue calls #close_resources.
101
+ def connect
102
+ @transport = acquire_transport
103
+ @transport.connect
104
+
105
+ # Read once, here: after the transport connected (its #connect may still
106
+ # change the options it was given) and before the Query is built. The
107
+ # Query stamps streamed prompts with this value, and #write_prompt /
108
+ # #write_message stamp theirs with it too unless told otherwise, so one
109
+ # session marks all of them alike.
110
+ @verbatim_prompts = @options.verbatim_prompts?
111
+ @query_handler = build_query_handler
112
+ install_transcript_mirror
113
+
114
+ @query_handler.start
115
+ @query_handler.initialize_protocol
116
+ end
117
+
118
+ # Write a String prompt as one user message. The caller notifies
119
+ # on_user_prompt first: the order is its own, and so is what may happen to
120
+ # the options in between.
121
+ #
122
+ # verbatim: CAPTURED the value #connect captured (Client)
123
+ # verbatim: :current read from the options the session runs on, now
124
+ # (query(): after its on_user_prompt notification)
125
+ # verbatim: true / false that value
126
+ #
127
+ # The options the session runs on are the repointed copy when a
128
+ # store-backed resume was materialized, the configured ones otherwise.
129
+ def write_prompt(prompt, session_id:, verbatim: CAPTURED)
130
+ message = {
131
+ type: 'user',
132
+ message: { role: 'user', content: prompt },
133
+ parent_tool_use_id: nil,
134
+ session_id: session_id
135
+ }
136
+ writeln(Query.serialize_user_message(message, verbatim_value(verbatim)))
137
+ end
138
+
139
+ # Write one user message that already has its shape (a Hash, or a JSONL
140
+ # String), stamped with the captured verbatim_prompts value. Serialized
141
+ # first: a message that cannot be marked raises before anything is written.
142
+ def write_message(message)
143
+ writeln(Query.serialize_user_message(message, @verbatim_prompts))
144
+ end
145
+
146
+ # Stream an Enumerable prompt as the session's input, in the background.
147
+ # The task is tracked on the Query, so closing it stops the stream;
148
+ # Query#stream_input closes stdin once the stream is exhausted and its
149
+ # last run is over, and swallows the stream's own errors with a warning.
150
+ # Observers get on_user_prompt for each user message before it is written.
151
+ def stream_prompt_in_background(prompt)
152
+ handler = @query_handler
153
+ observed = @dispatch.observing_stream(prompt)
154
+ handler.spawn_task { handler.stream_input(observed) }
155
+ end
156
+
157
+ # Deliver the session's messages to +block+: parse each frame, notify
158
+ # on_message, then invoke the block across the FiberBoundary. Returns the
159
+ # value of the block's `break` when it broke. With until_result: true the
160
+ # ResultMessage is the last message delivered.
161
+ #
162
+ # Loop control stays on this fiber, the one that dequeues: both breaks
163
+ # happen here, never inside the hop (a `break` in a proc on another thread
164
+ # raises LocalJumpError; Dispatch#invoke_iteration hands it back as a
165
+ # Break). Errors propagate; notifying on_error is the caller's.
166
+ def deliver(block, until_result: false)
167
+ @query_handler.receive_messages do |data|
168
+ message = MessageParser.parse(data)
169
+ next unless message
170
+
171
+ @dispatch.notify(:on_message, message)
172
+ signal = @dispatch.invoke_iteration(block, message)
173
+ break signal.value if signal.is_a?(FiberBoundary::Break)
174
+ break if until_result && message.is_a?(ResultMessage)
175
+ end
176
+ end
177
+
178
+ # Dispose of whatever #connect acquired, however far it got: close the
179
+ # Query (which flushes the transcript mirror and closes the transport),
180
+ # close the transport, decide what happens to the materialized resume dir.
181
+ #
182
+ # always_close_transport: true the transport is closed in an ensure of
183
+ # its own, also when closing the Query
184
+ # raised (Client)
185
+ # always_close_transport: false the transport is closed only when no
186
+ # Query was built (query())
187
+ #
188
+ # Transport#close is idempotent, so the second close is harmless. The
189
+ # nested ensures run every later step when an earlier one raises, and the
190
+ # last error raised is the one that propagates.
191
+ #
192
+ # A block, when given, is called once both closes are behind and before
193
+ # the materialized dir is dealt with — also when a close raised. From
194
+ # there on nothing of the session can be used any more, while removing
195
+ # the directory can still take a while (it retries, sleeping, when the
196
+ # directory is busy) and lets other tasks run meanwhile: the caller marks
197
+ # itself disconnected in the block, so that calls made in that window are
198
+ # refused instead of reaching a session that is half gone.
199
+ def close_resources(always_close_transport:)
200
+ # Kept past the nil-out below: whether the mirror dropped batches is
201
+ # final only after #close ran its last flush.
202
+ handler = @query_handler
203
+ begin
204
+ handler&.close
205
+ ensure
206
+ @query_handler = nil
207
+ begin
208
+ @transport&.close if always_close_transport || handler.nil?
209
+ ensure
210
+ @transport = nil
211
+ yield if block_given?
212
+ dispose_of_materialized_resume(handler)
213
+ end
214
+ end
215
+ end
216
+
217
+ private
218
+
219
+ # An injected transport was built before any repointing could reach it,
220
+ # so nothing is materialized for it. A class is constructed after
221
+ # materialization, with the repointed options.
222
+ def acquire_transport
223
+ return @transport_source.fetch(:instance) if @transport_source.key?(:instance)
224
+
225
+ transport_class = @transport_source.fetch(:class)
226
+ materialize_resume if @options.session_store && spawns_cli_locally?(transport_class)
227
+ transport_class.new(@options, **@transport_source.fetch(:args))
228
+ end
229
+
230
+ # The materialized config dir and --resume reach a transport only through
231
+ # the options it is constructed with, and mean something only to one that
232
+ # spawns the CLI on this host with them: SubprocessCLITransport or a
233
+ # subclass of it (ancestry, not identity). The source may be a duck-typed
234
+ # factory rather than a Class.
235
+ def spawns_cli_locally?(transport_class)
236
+ transport_class.is_a?(Class) && transport_class <= SubprocessCLITransport
237
+ end
238
+
239
+ # Resume-from-store: load the session from the store into a temp
240
+ # CLAUDE_CONFIG_DIR and repoint the options at it (env + --resume). The
241
+ # directory is recorded before the repointing, so a failure there still
242
+ # leaves it to #close_resources.
243
+ def materialize_resume
244
+ @materialized = SessionResume.materialize_resume_session(@options)
245
+ @options = SessionResume.apply_materialized_options(@options, @materialized) if @materialized
246
+ end
247
+
248
+ # The one place a Query is built from options. Streaming mode with the
249
+ # control protocol, always (as in the Python SDK): agents travel in the
250
+ # initialize request rather than in CLI arguments, clear of ARG_MAX.
251
+ def build_query_handler
252
+ Query.new(
253
+ transport: @transport,
254
+ is_streaming_mode: true,
255
+ can_use_tool: @options.can_use_tool,
256
+ hooks: ClaudeAgentSDK.convert_hooks_to_internal_format(@options.hooks),
257
+ sdk_mcp_servers: ClaudeAgentSDK.extract_sdk_mcp_servers(@options.mcp_servers),
258
+ agents: @options.agents,
259
+ exclude_dynamic_sections: ClaudeAgentSDK.extract_exclude_dynamic_sections(@options.system_prompt),
260
+ system_prompt_snapshot: ClaudeAgentSDK.extract_system_prompt_snapshot(@options.system_prompt),
261
+ skills: @options.skills,
262
+ forward_subagent_text: @options.forward_subagent_text?,
263
+ agent_progress_summaries: @options.agent_progress_summaries,
264
+ callback_scheduling: @dispatch.scheduling,
265
+ callback_wrapper: @dispatch.wrapper,
266
+ verbatim_prompts: @verbatim_prompts,
267
+ run_end_ceiling_ms: Query.run_end_ceiling_ms(@options.env)
268
+ )
269
+ end
270
+
271
+ # Mirror transcripts to the session_store, if one is configured.
272
+ def install_transcript_mirror
273
+ return unless @options.session_store
274
+
275
+ handler = @query_handler
276
+ handler.set_transcript_mirror_batcher(
277
+ SessionResume.build_mirror_batcher(
278
+ store: @options.session_store,
279
+ env: @options.env,
280
+ on_error: ->(key, message) { handler.report_mirror_error(key, message) },
281
+ eager: @options.session_store_flush.to_s == 'eager',
282
+ callback_wrapper: @dispatch.wrapper
283
+ )
284
+ )
285
+ end
286
+
287
+ def verbatim_value(verbatim)
288
+ case verbatim
289
+ when CAPTURED then @verbatim_prompts
290
+ when :current then @options.verbatim_prompts?
291
+ else verbatim
292
+ end
293
+ end
294
+
295
+ def writeln(string)
296
+ @transport.write(string.end_with?("\n") ? string : "#{string}\n")
297
+ end
298
+
299
+ # The materialized resume dir holds a redacted .credentials.json copy, so
300
+ # it is removed once the subprocess has exited — unless the mirror dropped
301
+ # batches: the store copy is then incomplete and the dir holds the only
302
+ # copy of the dropped turns, so it is preserved (scrubbed of credentials)
303
+ # with a warning instead.
304
+ def dispose_of_materialized_resume(handler)
305
+ return unless @materialized
306
+
307
+ handler&.mirror_batches_dropped? ? @materialized.preserve_transcripts : @materialized.cleanup
308
+ @materialized = nil
309
+ end
310
+ end
311
+ end