claude_agent 0.7.12 → 0.7.13

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 (56) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/rules/testing.md +51 -10
  3. data/.claude/settings.json +1 -0
  4. data/ARCHITECTURE.md +237 -0
  5. data/CHANGELOG.md +45 -0
  6. data/CLAUDE.md +2 -0
  7. data/README.md +46 -1
  8. data/Rakefile +17 -0
  9. data/SPEC.md +214 -125
  10. data/lib/claude_agent/client/commands.rb +225 -0
  11. data/lib/claude_agent/client.rb +4 -204
  12. data/lib/claude_agent/content_blocks/generic_block.rb +39 -0
  13. data/lib/claude_agent/content_blocks/image_content_block.rb +54 -0
  14. data/lib/claude_agent/content_blocks/server_tool_result_block.rb +22 -0
  15. data/lib/claude_agent/content_blocks/server_tool_use_block.rb +48 -0
  16. data/lib/claude_agent/content_blocks/text_block.rb +19 -0
  17. data/lib/claude_agent/content_blocks/thinking_block.rb +19 -0
  18. data/lib/claude_agent/content_blocks/tool_result_block.rb +25 -0
  19. data/lib/claude_agent/content_blocks/tool_use_block.rb +134 -0
  20. data/lib/claude_agent/content_blocks.rb +8 -335
  21. data/lib/claude_agent/control_protocol/commands.rb +304 -0
  22. data/lib/claude_agent/control_protocol/lifecycle.rb +113 -0
  23. data/lib/claude_agent/control_protocol/messaging.rb +166 -0
  24. data/lib/claude_agent/control_protocol/primitives.rb +168 -0
  25. data/lib/claude_agent/control_protocol/request_handling.rb +231 -0
  26. data/lib/claude_agent/control_protocol.rb +27 -882
  27. data/lib/claude_agent/event_handler.rb +1 -0
  28. data/lib/claude_agent/get_session_info.rb +86 -0
  29. data/lib/claude_agent/hooks.rb +23 -2
  30. data/lib/claude_agent/list_sessions.rb +22 -13
  31. data/lib/claude_agent/message_parser.rb +26 -4
  32. data/lib/claude_agent/messages/conversation.rb +138 -0
  33. data/lib/claude_agent/messages/generic.rb +39 -0
  34. data/lib/claude_agent/messages/hook_lifecycle.rb +158 -0
  35. data/lib/claude_agent/messages/result.rb +80 -0
  36. data/lib/claude_agent/messages/streaming.rb +84 -0
  37. data/lib/claude_agent/messages/system.rb +67 -0
  38. data/lib/claude_agent/messages/task_lifecycle.rb +240 -0
  39. data/lib/claude_agent/messages/tool_lifecycle.rb +95 -0
  40. data/lib/claude_agent/messages.rb +11 -829
  41. data/lib/claude_agent/options/serializer.rb +194 -0
  42. data/lib/claude_agent/options.rb +11 -176
  43. data/lib/claude_agent/sandbox_settings.rb +3 -0
  44. data/lib/claude_agent/session.rb +0 -204
  45. data/lib/claude_agent/session_mutations.rb +148 -0
  46. data/lib/claude_agent/types/mcp.rb +30 -0
  47. data/lib/claude_agent/types/models.rb +146 -0
  48. data/lib/claude_agent/types/operations.rb +38 -0
  49. data/lib/claude_agent/types/sessions.rb +50 -0
  50. data/lib/claude_agent/types/tools.rb +32 -0
  51. data/lib/claude_agent/types.rb +6 -264
  52. data/lib/claude_agent/v2_session.rb +207 -0
  53. data/lib/claude_agent/version.rb +1 -1
  54. data/lib/claude_agent.rb +37 -3
  55. data/sig/claude_agent.rbs +144 -13
  56. metadata +33 -1
@@ -0,0 +1,304 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ClaudeAgent
4
+ class ControlProtocol
5
+ # Public control commands: interrupt, model/permission changes,
6
+ # MCP management, task control, and query methods.
7
+ module Commands
8
+ # Send an interrupt request
9
+ # @return [void]
10
+ def interrupt
11
+ send_control_request(subtype: "interrupt")
12
+ end
13
+
14
+ # Change the permission mode
15
+ # @param mode [String] New permission mode
16
+ # @return [Hash] Response
17
+ def set_permission_mode(mode)
18
+ send_control_request(subtype: "set_permission_mode", mode: mode)
19
+ end
20
+
21
+ # Change the model
22
+ # @param model [String, nil] New model name
23
+ # @return [Hash] Response
24
+ def set_model(model)
25
+ send_control_request(subtype: "set_model", model: model)
26
+ end
27
+
28
+ # Rewind files to a previous state
29
+ # @param user_message_id [String] UUID of user message to rewind to
30
+ # @param dry_run [Boolean] If true, preview changes without modifying files
31
+ # @return [RewindFilesResult] Result with rewind information
32
+ def rewind_files(user_message_id, dry_run: false)
33
+ request = { user_message_id: user_message_id }
34
+ request[:dry_run] = dry_run if dry_run
35
+
36
+ response = send_control_request(subtype: "rewind_files", **request)
37
+
38
+ RewindFilesResult.new(
39
+ can_rewind: response["canRewind"] || response["can_rewind"] || false,
40
+ error: response["error"],
41
+ files_changed: response["filesChanged"] || response["files_changed"],
42
+ insertions: response["insertions"],
43
+ deletions: response["deletions"]
44
+ )
45
+ end
46
+
47
+ # Set maximum thinking tokens (TypeScript SDK parity)
48
+ # @param tokens [Integer, nil] Max thinking tokens (nil to reset)
49
+ # @return [Hash] Response
50
+ def set_max_thinking_tokens(tokens)
51
+ send_control_request(subtype: "set_max_thinking_tokens", max_thinking_tokens: tokens)
52
+ end
53
+
54
+ # Get available slash commands (TypeScript SDK parity)
55
+ # @return [Array<SlashCommand>]
56
+ def supported_commands
57
+ response = send_control_request(subtype: "supported_commands")
58
+ (response["commands"] || []).map do |cmd|
59
+ SlashCommand.new(
60
+ name: cmd["name"],
61
+ description: cmd["description"],
62
+ argument_hint: cmd["argumentHint"]
63
+ )
64
+ end
65
+ end
66
+
67
+ # Get full initialization result (TypeScript SDK parity)
68
+ #
69
+ # Sends the supported_commands request and maps the full response including
70
+ # commands, output style, available output styles, models, and account info.
71
+ #
72
+ # @return [InitializationResult]
73
+ def initialization_result
74
+ response = send_control_request(subtype: "supported_commands")
75
+
76
+ commands = (response["commands"] || []).map do |cmd|
77
+ SlashCommand.new(
78
+ name: cmd["name"],
79
+ description: cmd["description"],
80
+ argument_hint: cmd["argumentHint"]
81
+ )
82
+ end
83
+
84
+ models = (response["models"] || []).map do |model|
85
+ ModelInfo.new(
86
+ value: model["value"],
87
+ display_name: model["displayName"],
88
+ description: model["description"],
89
+ supports_effort: model["supportsEffort"],
90
+ supported_effort_levels: model["supportedEffortLevels"],
91
+ supports_adaptive_thinking: model["supportsAdaptiveThinking"],
92
+ supports_fast_mode: model["supportsFastMode"],
93
+ supports_auto_mode: model["supportsAutoMode"]
94
+ )
95
+ end
96
+
97
+ account_data = response["account"]
98
+ account = if account_data
99
+ AccountInfo.new(
100
+ email: account_data["email"],
101
+ organization: account_data["organization"],
102
+ subscription_type: account_data["subscriptionType"],
103
+ token_source: account_data["tokenSource"],
104
+ api_key_source: account_data["apiKeySource"]
105
+ )
106
+ end
107
+
108
+ agents = (response["agents"] || []).map do |agent|
109
+ AgentInfo.new(
110
+ name: agent["name"],
111
+ description: agent["description"],
112
+ model: agent["model"]
113
+ )
114
+ end
115
+
116
+ InitializationResult.new(
117
+ commands: commands,
118
+ output_style: response["output_style"],
119
+ available_output_styles: response["available_output_styles"] || [],
120
+ models: models,
121
+ account: account,
122
+ agents: agents,
123
+ fast_mode_state: response["fast_mode_state"]
124
+ )
125
+ end
126
+
127
+ # Get available models (TypeScript SDK parity)
128
+ # @return [Array<ModelInfo>]
129
+ def supported_models
130
+ response = send_control_request(subtype: "supported_models")
131
+ (response["models"] || []).map do |model|
132
+ ModelInfo.new(
133
+ value: model["value"],
134
+ display_name: model["displayName"],
135
+ description: model["description"],
136
+ supports_effort: model["supportsEffort"],
137
+ supported_effort_levels: model["supportedEffortLevels"],
138
+ supports_adaptive_thinking: model["supportsAdaptiveThinking"],
139
+ supports_fast_mode: model["supportsFastMode"],
140
+ supports_auto_mode: model["supportsAutoMode"]
141
+ )
142
+ end
143
+ end
144
+
145
+ # Get available agents (TypeScript SDK v0.2.63 parity)
146
+ # @return [Array<AgentInfo>]
147
+ def supported_agents
148
+ response = send_control_request(subtype: "supported_agents")
149
+ (response["agents"] || []).map do |agent|
150
+ AgentInfo.new(
151
+ name: agent["name"],
152
+ description: agent["description"],
153
+ model: agent["model"]
154
+ )
155
+ end
156
+ end
157
+
158
+ # Get MCP server status (TypeScript SDK parity)
159
+ # @return [Array<McpServerStatus>]
160
+ def mcp_server_status
161
+ response = send_control_request(subtype: "mcp_server_status")
162
+ (response["servers"] || []).map do |server|
163
+ McpServerStatus.new(
164
+ name: server["name"],
165
+ status: server["status"],
166
+ server_info: server["serverInfo"],
167
+ error: server["error"],
168
+ config: server["config"],
169
+ scope: server["scope"],
170
+ tools: server["tools"]
171
+ )
172
+ end
173
+ end
174
+
175
+ # Get account information (TypeScript SDK parity)
176
+ # @return [AccountInfo]
177
+ def account_info
178
+ response = send_control_request(subtype: "account_info")
179
+ AccountInfo.new(
180
+ email: response["email"],
181
+ organization: response["organization"],
182
+ subscription_type: response["subscriptionType"],
183
+ token_source: response["tokenSource"],
184
+ api_key_source: response["apiKeySource"]
185
+ )
186
+ end
187
+
188
+ # Reconnect to an MCP server (TypeScript SDK parity)
189
+ #
190
+ # Attempts to reconnect to a disconnected or errored MCP server.
191
+ #
192
+ # @param server_name [String] Name of the MCP server to reconnect
193
+ # @return [Hash] Response from the CLI
194
+ #
195
+ # @example
196
+ # protocol.mcp_reconnect("my-server")
197
+ #
198
+ def mcp_reconnect(server_name)
199
+ send_control_request(subtype: "mcp_reconnect", serverName: server_name)
200
+ end
201
+
202
+ # Enable or disable an MCP server (TypeScript SDK parity)
203
+ #
204
+ # Toggles an MCP server on or off without removing its configuration.
205
+ #
206
+ # @param server_name [String] Name of the MCP server to toggle
207
+ # @param enabled [Boolean] Whether to enable (true) or disable (false) the server
208
+ # @return [Hash] Response from the CLI
209
+ #
210
+ # @example Enable a server
211
+ # protocol.mcp_toggle("my-server", enabled: true)
212
+ #
213
+ # @example Disable a server
214
+ # protocol.mcp_toggle("my-server", enabled: false)
215
+ #
216
+ def mcp_toggle(server_name, enabled:)
217
+ send_control_request(subtype: "mcp_toggle", serverName: server_name, enabled: enabled)
218
+ end
219
+
220
+ # Initiate OAuth authentication for an MCP server (TypeScript SDK v0.2.52 parity)
221
+ #
222
+ # @param server_name [String] Name of the MCP server to authenticate
223
+ # @return [Hash] Response from the CLI
224
+ #
225
+ # @example
226
+ # protocol.mcp_authenticate("my-remote-server")
227
+ #
228
+ def mcp_authenticate(server_name)
229
+ send_control_request(subtype: "mcp_authenticate", serverName: server_name)
230
+ end
231
+
232
+ # Clear stored auth credentials for an MCP server (TypeScript SDK v0.2.52 parity)
233
+ #
234
+ # @param server_name [String] Name of the MCP server to clear auth for
235
+ # @return [Hash] Response from the CLI
236
+ #
237
+ # @example
238
+ # protocol.mcp_clear_auth("my-remote-server")
239
+ #
240
+ def mcp_clear_auth(server_name)
241
+ send_control_request(subtype: "mcp_clear_auth", serverName: server_name)
242
+ end
243
+
244
+ # Stop a running background task (TypeScript SDK parity)
245
+ #
246
+ # Sends a stop signal to a running task. A task_notification message
247
+ # with status 'stopped' will be emitted when the task stops.
248
+ #
249
+ # @param task_id [String] The task ID from task_notification events
250
+ # @return [void]
251
+ #
252
+ # @example
253
+ # protocol.stop_task("task-123")
254
+ #
255
+ def stop_task(task_id)
256
+ send_control_request(subtype: "stop_task", task_id: task_id)
257
+ end
258
+
259
+ # Apply flag settings (TypeScript SDK v0.2.50 parity)
260
+ #
261
+ # Merges the provided settings into the flag settings layer.
262
+ #
263
+ # @param settings [Hash] Settings to merge into the flag layer
264
+ # @return [Hash] Response from the CLI
265
+ #
266
+ # @example
267
+ # protocol.apply_flag_settings({ "model" => "claude-sonnet-4-5-20250514" })
268
+ #
269
+ def apply_flag_settings(settings)
270
+ send_control_request(subtype: "apply_flag_settings", settings: settings)
271
+ end
272
+
273
+ # Dynamically set MCP servers for this session (TypeScript SDK parity)
274
+ #
275
+ # This replaces the current set of dynamically-added MCP servers.
276
+ # Servers that are removed will be disconnected, and new servers will be connected.
277
+ #
278
+ # @param servers [Hash] Map of server name to configuration
279
+ # @return [McpSetServersResult] Result with added, removed, and errors
280
+ #
281
+ # @example
282
+ # result = protocol.set_mcp_servers({
283
+ # "my-server" => { type: "stdio", command: "node", args: ["server.js"] }
284
+ # })
285
+ # puts "Added: #{result.added}"
286
+ # puts "Removed: #{result.removed}"
287
+ #
288
+ def set_mcp_servers(servers)
289
+ # Convert servers hash to format expected by CLI
290
+ servers_config = servers.reject do |_, config|
291
+ config.is_a?(Hash) && (config[:type] == "sdk" || config["type"] == "sdk")
292
+ end
293
+
294
+ response = send_control_request(subtype: "mcp_set_servers", servers: servers_config)
295
+
296
+ McpSetServersResult.new(
297
+ added: response["added"] || [],
298
+ removed: response["removed"] || [],
299
+ errors: response["errors"] || {}
300
+ )
301
+ end
302
+ end
303
+ end
304
+ end
@@ -0,0 +1,113 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ClaudeAgent
4
+ class ControlProtocol
5
+ # Connection lifecycle: start, stop, abort, and the background reader loop.
6
+ module Lifecycle
7
+ # Start the control protocol (initialize connection)
8
+ # @param streaming [Boolean] Whether to use streaming mode
9
+ # @param prompt [String, nil] Initial prompt for non-streaming mode
10
+ # @return [Hash, nil] Server info from initialization
11
+ def start(streaming: true, prompt: nil)
12
+ logger.info("protocol") { "Starting control protocol (streaming=#{streaming})" }
13
+ @transport.connect(streaming: streaming, prompt: prompt)
14
+ @running = true
15
+
16
+ # Start background reader thread
17
+ @reader_thread = Thread.new { reader_loop }
18
+ logger.debug("protocol") { "Reader thread started" }
19
+
20
+ # Always send initialize in streaming mode (Python/TypeScript SDK parity)
21
+ if streaming
22
+ @server_info = send_initialize
23
+ logger.info("protocol") { "Initialize complete" }
24
+ end
25
+
26
+ @server_info
27
+ end
28
+
29
+ # Stop the control protocol
30
+ # @return [void]
31
+ def stop
32
+ logger.info("protocol") { "Stopping control protocol" }
33
+ @running = false
34
+ @transport.end_input
35
+ @reader_thread&.join(5)
36
+ @transport.close
37
+ end
38
+
39
+ # Abort all pending operations (TypeScript SDK parity)
40
+ #
41
+ # This method:
42
+ # 1. Stops the reader loop
43
+ # 2. Fails all pending requests with AbortError
44
+ # 3. Terminates the transport
45
+ #
46
+ # @return [void]
47
+ def abort!
48
+ @running = false
49
+
50
+ # Drain permission queue so reader thread unblocks
51
+ @permission_queue&.drain!(reason: "Operation aborted")
52
+
53
+ # Send cancel requests for pending operations (protocol courtesy)
54
+ @mutex.synchronize do
55
+ @pending_requests.each_key do |request_id|
56
+ begin
57
+ write_message({ type: "control_cancel_request", request_id: request_id })
58
+ rescue
59
+ # Ignore transport errors during abort — fire-and-forget
60
+ end
61
+ end
62
+ end
63
+
64
+ # Fail all pending requests
65
+ @mutex.synchronize do
66
+ @pending_requests.each_key do |request_id|
67
+ @pending_results[request_id] = {
68
+ "subtype" => "error",
69
+ "error" => "Operation aborted"
70
+ }
71
+ end
72
+ @condition.broadcast
73
+ end
74
+
75
+ # Terminate the transport
76
+ @transport.terminate if @transport.respond_to?(:terminate)
77
+ end
78
+
79
+ private
80
+
81
+ # Background thread that reads messages and routes them
82
+ def reader_loop
83
+ @transport.read_messages do |raw|
84
+ # Check abort signal on each iteration
85
+ if @abort_signal&.aborted?
86
+ @running = false
87
+ break
88
+ end
89
+
90
+ break unless @running
91
+
92
+ if raw["type"] == "control_request"
93
+ logger.debug("protocol") { "Control request received: #{raw.dig("request", "subtype")}" }
94
+ handle_control_request(raw)
95
+ elsif raw["type"] == "control_response"
96
+ logger.debug("protocol") { "Control response received: #{raw.dig("response", "request_id")}" }
97
+ handle_control_response(raw)
98
+ else
99
+ # SDK message - queue for consumer
100
+ logger.debug("protocol") { "Queued message: #{raw["type"]}" }
101
+ @message_queue.push(raw)
102
+ end
103
+ end
104
+ rescue IOError, Errno::EPIPE
105
+ logger.debug("protocol") { "Reader thread exiting: transport closed" }
106
+ @running = false
107
+ rescue AbortError
108
+ logger.debug("protocol") { "Reader thread exiting: abort signal" }
109
+ @running = false
110
+ end
111
+ end
112
+ end
113
+ end
@@ -0,0 +1,166 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ClaudeAgent
4
+ class ControlProtocol
5
+ # User message sending, iteration, and streaming conversation support.
6
+ module Messaging
7
+ # Send a user message
8
+ # @param content [String, Array] Message content
9
+ # @param session_id [String] Session ID
10
+ # @param uuid [String, nil] Message UUID for file checkpointing
11
+ # @return [void]
12
+ def send_user_message(content, session_id: "default", uuid: nil)
13
+ message = {
14
+ type: "user",
15
+ message: { role: "user", content: content },
16
+ session_id: session_id
17
+ }
18
+ message[:uuid] = uuid if uuid
19
+ write_message(message)
20
+ end
21
+
22
+ # Iterate over incoming messages (SDK messages only, not control)
23
+ # @yield [Message] Parsed message objects
24
+ # @return [Enumerator] If no block given
25
+ # @raise [AbortError] If abort signal is triggered
26
+ def each_message
27
+ return enum_for(:each_message) unless block_given?
28
+
29
+ while @running || !@message_queue.empty?
30
+ # Check abort signal
31
+ @abort_signal&.check!
32
+
33
+ begin
34
+ raw = @message_queue.pop(true)
35
+ message = @parser.parse(raw)
36
+ yield message
37
+ rescue ThreadError
38
+ # Queue empty, wait a bit
39
+ sleep 0.01
40
+ rescue AbortError
41
+ # Re-raise abort errors
42
+ raise
43
+ rescue => e
44
+ logger.warn("protocol") { "Message parse error: #{e.message}" }
45
+ end
46
+ end
47
+ end
48
+
49
+ # Receive messages until a ResultMessage is received
50
+ # @yield [Message] Parsed message objects
51
+ # @return [Enumerator] If no block given
52
+ def receive_response
53
+ return enum_for(:receive_response) unless block_given?
54
+
55
+ each_message do |message|
56
+ yield message
57
+ break if message.is_a?(ResultMessage)
58
+ end
59
+ end
60
+
61
+ # Stream user input from an enumerable (TypeScript SDK parity)
62
+ #
63
+ # Sends each message from the input stream to Claude. Messages can be:
64
+ # - String: Sent as user message content
65
+ # - Hash: Must have :content key, optionally :session_id and :uuid
66
+ # - UserMessage: Sent directly
67
+ #
68
+ # @param stream [Enumerable] Input stream of messages
69
+ # @param session_id [String] Default session ID for messages
70
+ # @return [void]
71
+ # @raise [AbortError] If abort signal is triggered
72
+ #
73
+ # @example With strings
74
+ # protocol.stream_input(["Hello", "How are you?"])
75
+ #
76
+ # @example With hashes
77
+ # protocol.stream_input([
78
+ # { content: "Hello", uuid: "msg-1" },
79
+ # { content: "Follow up", session_id: "custom" }
80
+ # ])
81
+ #
82
+ def stream_input(stream, session_id: "default")
83
+ stream.each do |message|
84
+ # Check abort signal before each message
85
+ @abort_signal&.check!
86
+
87
+ case message
88
+ when String
89
+ send_user_message(message, session_id: session_id)
90
+ when Hash
91
+ content = message[:content] || message["content"]
92
+ msg_session = message[:session_id] || message["session_id"] || session_id
93
+ uuid = message[:uuid] || message["uuid"]
94
+ send_user_message(content, session_id: msg_session, uuid: uuid)
95
+ when UserMessage, UserMessageReplay
96
+ send_user_message(message.content, session_id: message.session_id || session_id, uuid: message.uuid)
97
+ else
98
+ raise ArgumentError, "Unknown message type in stream: #{message.class}"
99
+ end
100
+ end
101
+ end
102
+
103
+ # Stream user input and receive responses (TypeScript SDK parity)
104
+ #
105
+ # Sends messages from the input stream in a background thread while
106
+ # yielding responses in the foreground. This enables concurrent input/output.
107
+ #
108
+ # @param stream [Enumerable] Input stream of messages
109
+ # @param session_id [String] Default session ID for messages
110
+ # @yield [Message] Received messages
111
+ # @return [Enumerator] If no block given
112
+ # @raise [AbortError] If abort signal is triggered
113
+ #
114
+ # @example
115
+ # messages = ["Hello", "Tell me more"]
116
+ # protocol.stream_conversation(messages) do |msg|
117
+ # case msg
118
+ # when ClaudeAgent::AssistantMessage
119
+ # puts msg.text
120
+ # when ClaudeAgent::ResultMessage
121
+ # puts "Done!"
122
+ # end
123
+ # end
124
+ #
125
+ def stream_conversation(stream, session_id: "default", &block)
126
+ return enum_for(:stream_conversation, stream, session_id: session_id) unless block_given?
127
+
128
+ # Track errors from the sender thread
129
+ sender_error = nil
130
+
131
+ # Start sender thread
132
+ sender_thread = Thread.new do
133
+ stream_input(stream, session_id: session_id)
134
+ rescue AbortError => e
135
+ sender_error = e
136
+ rescue => e
137
+ sender_error = e
138
+ # Don't re-raise here; let the main thread handle it
139
+ end
140
+
141
+ # Yield responses until we get a ResultMessage or error
142
+ begin
143
+ each_message do |message|
144
+ # Check if sender had an error
145
+ if sender_error
146
+ raise sender_error if sender_error.is_a?(AbortError)
147
+
148
+ raise Error, "Stream input error: #{sender_error.message}"
149
+ end
150
+
151
+ yield message
152
+ break if message.is_a?(ResultMessage)
153
+ end
154
+ ensure
155
+ # Wait for sender to finish
156
+ sender_thread.join(1)
157
+ end
158
+
159
+ # Check for sender errors after loop
160
+ raise sender_error if sender_error.is_a?(AbortError)
161
+
162
+ raise Error, "Stream input error: #{sender_error.message}" if sender_error
163
+ end
164
+ end
165
+ end
166
+ end