claude_agent 0.7.12 → 0.7.14

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 (59) 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 +53 -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 -206
  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 +116 -0
  23. data/lib/claude_agent/control_protocol/messaging.rb +163 -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 +50 -882
  27. data/lib/claude_agent/conversation.rb +8 -1
  28. data/lib/claude_agent/event_handler.rb +1 -0
  29. data/lib/claude_agent/get_session_info.rb +86 -0
  30. data/lib/claude_agent/hooks.rb +23 -2
  31. data/lib/claude_agent/list_sessions.rb +22 -13
  32. data/lib/claude_agent/message_parser.rb +26 -4
  33. data/lib/claude_agent/messages/conversation.rb +138 -0
  34. data/lib/claude_agent/messages/generic.rb +39 -0
  35. data/lib/claude_agent/messages/hook_lifecycle.rb +158 -0
  36. data/lib/claude_agent/messages/result.rb +80 -0
  37. data/lib/claude_agent/messages/streaming.rb +84 -0
  38. data/lib/claude_agent/messages/system.rb +67 -0
  39. data/lib/claude_agent/messages/task_lifecycle.rb +240 -0
  40. data/lib/claude_agent/messages/tool_lifecycle.rb +95 -0
  41. data/lib/claude_agent/messages.rb +11 -829
  42. data/lib/claude_agent/options/serializer.rb +194 -0
  43. data/lib/claude_agent/options.rb +11 -176
  44. data/lib/claude_agent/query.rb +0 -2
  45. data/lib/claude_agent/sandbox_settings.rb +3 -0
  46. data/lib/claude_agent/session.rb +0 -204
  47. data/lib/claude_agent/session_mutations.rb +148 -0
  48. data/lib/claude_agent/transport/subprocess.rb +2 -2
  49. data/lib/claude_agent/types/mcp.rb +30 -0
  50. data/lib/claude_agent/types/models.rb +146 -0
  51. data/lib/claude_agent/types/operations.rb +38 -0
  52. data/lib/claude_agent/types/sessions.rb +50 -0
  53. data/lib/claude_agent/types/tools.rb +32 -0
  54. data/lib/claude_agent/types.rb +6 -264
  55. data/lib/claude_agent/v2_session.rb +207 -0
  56. data/lib/claude_agent/version.rb +1 -1
  57. data/lib/claude_agent.rb +37 -3
  58. data/sig/claude_agent.rbs +144 -13
  59. metadata +33 -1
@@ -3,6 +3,12 @@
3
3
  require "json"
4
4
  require "securerandom"
5
5
 
6
+ require_relative "control_protocol/primitives"
7
+ require_relative "control_protocol/lifecycle"
8
+ require_relative "control_protocol/messaging"
9
+ require_relative "control_protocol/commands"
10
+ require_relative "control_protocol/request_handling"
11
+
6
12
  module ClaudeAgent
7
13
  # Handles the control protocol for bidirectional communication with Claude Code CLI
8
14
  #
@@ -19,10 +25,53 @@ module ClaudeAgent
19
25
  # protocol.start
20
26
  # protocol.each_message { |msg| process(msg) }
21
27
  #
28
+ # State ownership across modules:
29
+ #
30
+ # Owned by Lifecycle:
31
+ # @running, @reader_thread
32
+ #
33
+ # Owned by RequestHandling:
34
+ # @hook_callbacks
35
+ #
36
+ # Owned by Primitives:
37
+ # write_message, read helpers (stateless)
38
+ #
39
+ # Owned by Commands:
40
+ # interrupt, rewind (stateless, uses @transport)
41
+ #
42
+ # Shared (initialized here, used by multiple modules):
43
+ # @transport, @options, @parser, @server_info
44
+ # @request_counter, @pending_requests, @pending_results (Primitives + RequestHandling)
45
+ # @mutex, @condition (Primitives + Lifecycle + RequestHandling)
46
+ # @message_queue (Lifecycle + Messaging)
47
+ # @abort_signal (Lifecycle + Messaging)
48
+ # @permission_queue (RequestHandling, set externally by Client)
49
+ #
22
50
  class ControlProtocol
23
51
  DEFAULT_TIMEOUT = 60
24
52
  REQUEST_ID_PREFIX = "req"
25
53
 
54
+ # Mapping of Ruby keys to CLI keys for hook responses
55
+ # Handles special cases where Ruby uses trailing underscore for reserved words
56
+ HOOK_RESPONSE_KEYS = {
57
+ continue_: "continue",
58
+ continue: "continue",
59
+ async_: "async",
60
+ async: "async",
61
+ async_timeout: "asyncTimeout",
62
+ suppress_output: "suppressOutput",
63
+ stop_reason: "stopReason",
64
+ decision: "decision",
65
+ system_message: "systemMessage",
66
+ reason: "reason"
67
+ }.freeze
68
+
69
+ include Primitives
70
+ include Lifecycle
71
+ include Messaging
72
+ include Commands
73
+ include RequestHandling
74
+
26
75
  attr_reader :transport, :options, :server_info
27
76
  attr_accessor :permission_queue
28
77
 
@@ -51,504 +100,7 @@ module ClaudeAgent
51
100
 
52
101
  # Abort signal from options
53
102
  @abort_signal = options&.abort_signal
54
- end
55
-
56
- # Start the control protocol (initialize connection)
57
- # @param streaming [Boolean] Whether to use streaming mode
58
- # @param prompt [String, nil] Initial prompt for non-streaming mode
59
- # @return [Hash, nil] Server info from initialization
60
- def start(streaming: true, prompt: nil)
61
- logger.info("protocol") { "Starting control protocol (streaming=#{streaming})" }
62
- @transport.connect(streaming: streaming, prompt: prompt)
63
- @running = true
64
-
65
- # Start background reader thread
66
- @reader_thread = Thread.new { reader_loop }
67
- logger.debug("protocol") { "Reader thread started" }
68
-
69
- # Always send initialize in streaming mode (Python/TypeScript SDK parity)
70
- if streaming
71
- @server_info = send_initialize
72
- logger.info("protocol") { "Initialize complete" }
73
- end
74
-
75
- @server_info
76
- end
77
-
78
- # Stop the control protocol
79
- # @return [void]
80
- def stop
81
- logger.info("protocol") { "Stopping control protocol" }
82
- @running = false
83
- @transport.end_input
84
- @reader_thread&.join(5)
85
- @transport.close
86
- end
87
-
88
- # Abort all pending operations (TypeScript SDK parity)
89
- #
90
- # This method:
91
- # 1. Stops the reader loop
92
- # 2. Fails all pending requests with AbortError
93
- # 3. Terminates the transport
94
- #
95
- # @return [void]
96
- def abort!
97
- @running = false
98
-
99
- # Drain permission queue so reader thread unblocks
100
- @permission_queue&.drain!(reason: "Operation aborted")
101
-
102
- # Send cancel requests for pending operations (protocol courtesy)
103
- @mutex.synchronize do
104
- @pending_requests.each_key do |request_id|
105
- begin
106
- write_message({ type: "control_cancel_request", request_id: request_id })
107
- rescue
108
- # Ignore transport errors during abort — fire-and-forget
109
- end
110
- end
111
- end
112
-
113
- # Fail all pending requests
114
- @mutex.synchronize do
115
- @pending_requests.each_key do |request_id|
116
- @pending_results[request_id] = {
117
- "subtype" => "error",
118
- "error" => "Operation aborted"
119
- }
120
- end
121
- @condition.broadcast
122
- end
123
-
124
- # Terminate the transport
125
- @transport.terminate if @transport.respond_to?(:terminate)
126
- end
127
-
128
- # Send a user message
129
- # @param content [String, Array] Message content
130
- # @param session_id [String] Session ID
131
- # @param uuid [String, nil] Message UUID for file checkpointing
132
- # @return [void]
133
- def send_user_message(content, session_id: "default", uuid: nil)
134
- message = {
135
- type: "user",
136
- message: { role: "user", content: content },
137
- session_id: session_id
138
- }
139
- message[:uuid] = uuid if uuid
140
- write_message(message)
141
- end
142
-
143
- # Iterate over incoming messages (SDK messages only, not control)
144
- # @yield [Message] Parsed message objects
145
- # @return [Enumerator] If no block given
146
- # @raise [AbortError] If abort signal is triggered
147
- def each_message
148
- return enum_for(:each_message) unless block_given?
149
-
150
- while @running || !@message_queue.empty?
151
- # Check abort signal
152
- @abort_signal&.check!
153
-
154
- begin
155
- raw = @message_queue.pop(true)
156
- message = @parser.parse(raw)
157
- yield message
158
- rescue ThreadError
159
- # Queue empty, wait a bit
160
- sleep 0.01
161
- rescue AbortError
162
- # Re-raise abort errors
163
- raise
164
- rescue => e
165
- logger.warn("protocol") { "Message parse error: #{e.message}" }
166
- end
167
- end
168
- end
169
-
170
- # Receive messages until a ResultMessage is received
171
- # @yield [Message] Parsed message objects
172
- # @return [Enumerator] If no block given
173
- def receive_response
174
- return enum_for(:receive_response) unless block_given?
175
-
176
- each_message do |message|
177
- yield message
178
- break if message.is_a?(ResultMessage)
179
- end
180
- end
181
-
182
- # Stream user input from an enumerable (TypeScript SDK parity)
183
- #
184
- # Sends each message from the input stream to Claude. Messages can be:
185
- # - String: Sent as user message content
186
- # - Hash: Must have :content key, optionally :session_id and :uuid
187
- # - UserMessage: Sent directly
188
- #
189
- # @param stream [Enumerable] Input stream of messages
190
- # @param session_id [String] Default session ID for messages
191
- # @return [void]
192
- # @raise [AbortError] If abort signal is triggered
193
- #
194
- # @example With strings
195
- # protocol.stream_input(["Hello", "How are you?"])
196
- #
197
- # @example With hashes
198
- # protocol.stream_input([
199
- # { content: "Hello", uuid: "msg-1" },
200
- # { content: "Follow up", session_id: "custom" }
201
- # ])
202
- #
203
- def stream_input(stream, session_id: "default")
204
- stream.each do |message|
205
- # Check abort signal before each message
206
- @abort_signal&.check!
207
-
208
- case message
209
- when String
210
- send_user_message(message, session_id: session_id)
211
- when Hash
212
- content = message[:content] || message["content"]
213
- msg_session = message[:session_id] || message["session_id"] || session_id
214
- uuid = message[:uuid] || message["uuid"]
215
- send_user_message(content, session_id: msg_session, uuid: uuid)
216
- when UserMessage, UserMessageReplay
217
- send_user_message(message.content, session_id: message.session_id || session_id, uuid: message.uuid)
218
- else
219
- raise ArgumentError, "Unknown message type in stream: #{message.class}"
220
- end
221
- end
222
- end
223
-
224
- # Stream user input and receive responses (TypeScript SDK parity)
225
- #
226
- # Sends messages from the input stream in a background thread while
227
- # yielding responses in the foreground. This enables concurrent input/output.
228
- #
229
- # @param stream [Enumerable] Input stream of messages
230
- # @param session_id [String] Default session ID for messages
231
- # @yield [Message] Received messages
232
- # @return [Enumerator] If no block given
233
- # @raise [AbortError] If abort signal is triggered
234
- #
235
- # @example
236
- # messages = ["Hello", "Tell me more"]
237
- # protocol.stream_conversation(messages) do |msg|
238
- # case msg
239
- # when ClaudeAgent::AssistantMessage
240
- # puts msg.text
241
- # when ClaudeAgent::ResultMessage
242
- # puts "Done!"
243
- # end
244
- # end
245
- #
246
- def stream_conversation(stream, session_id: "default", &block)
247
- return enum_for(:stream_conversation, stream, session_id: session_id) unless block_given?
248
-
249
- # Track errors from the sender thread
250
- sender_error = nil
251
-
252
- # Start sender thread
253
- sender_thread = Thread.new do
254
- stream_input(stream, session_id: session_id)
255
- rescue AbortError => e
256
- sender_error = e
257
- rescue => e
258
- sender_error = e
259
- # Don't re-raise here; let the main thread handle it
260
- end
261
-
262
- # Yield responses until we get a ResultMessage or error
263
- begin
264
- each_message do |message|
265
- # Check if sender had an error
266
- if sender_error
267
- raise sender_error if sender_error.is_a?(AbortError)
268
-
269
- raise Error, "Stream input error: #{sender_error.message}"
270
- end
271
-
272
- yield message
273
- break if message.is_a?(ResultMessage)
274
- end
275
- ensure
276
- # Wait for sender to finish
277
- sender_thread.join(1)
278
- end
279
-
280
- # Check for sender errors after loop
281
- raise sender_error if sender_error.is_a?(AbortError)
282
-
283
- raise Error, "Stream input error: #{sender_error.message}" if sender_error
284
- end
285
-
286
- # Send an interrupt request
287
- # @return [void]
288
- def interrupt
289
- send_control_request(subtype: "interrupt")
290
- end
291
-
292
- # Change the permission mode
293
- # @param mode [String] New permission mode
294
- # @return [Hash] Response
295
- def set_permission_mode(mode)
296
- send_control_request(subtype: "set_permission_mode", mode: mode)
297
- end
298
-
299
- # Change the model
300
- # @param model [String, nil] New model name
301
- # @return [Hash] Response
302
- def set_model(model)
303
- send_control_request(subtype: "set_model", model: model)
304
- end
305
-
306
- # Rewind files to a previous state
307
- # @param user_message_id [String] UUID of user message to rewind to
308
- # @param dry_run [Boolean] If true, preview changes without modifying files
309
- # @return [RewindFilesResult] Result with rewind information
310
- def rewind_files(user_message_id, dry_run: false)
311
- request = { user_message_id: user_message_id }
312
- request[:dry_run] = dry_run if dry_run
313
-
314
- response = send_control_request(subtype: "rewind_files", **request)
315
-
316
- RewindFilesResult.new(
317
- can_rewind: response["canRewind"] || response["can_rewind"] || false,
318
- error: response["error"],
319
- files_changed: response["filesChanged"] || response["files_changed"],
320
- insertions: response["insertions"],
321
- deletions: response["deletions"]
322
- )
323
- end
324
-
325
- # Set maximum thinking tokens (TypeScript SDK parity)
326
- # @param tokens [Integer, nil] Max thinking tokens (nil to reset)
327
- # @return [Hash] Response
328
- def set_max_thinking_tokens(tokens)
329
- send_control_request(subtype: "set_max_thinking_tokens", max_thinking_tokens: tokens)
330
- end
331
-
332
- # Get available slash commands (TypeScript SDK parity)
333
- # @return [Array<SlashCommand>]
334
- def supported_commands
335
- response = send_control_request(subtype: "supported_commands")
336
- (response["commands"] || []).map do |cmd|
337
- SlashCommand.new(
338
- name: cmd["name"],
339
- description: cmd["description"],
340
- argument_hint: cmd["argumentHint"]
341
- )
342
- end
343
- end
344
-
345
- # Get full initialization result (TypeScript SDK parity)
346
- #
347
- # Sends the supported_commands request and maps the full response including
348
- # commands, output style, available output styles, models, and account info.
349
- #
350
- # @return [InitializationResult]
351
- def initialization_result
352
- response = send_control_request(subtype: "supported_commands")
353
-
354
- commands = (response["commands"] || []).map do |cmd|
355
- SlashCommand.new(
356
- name: cmd["name"],
357
- description: cmd["description"],
358
- argument_hint: cmd["argumentHint"]
359
- )
360
- end
361
-
362
- models = (response["models"] || []).map do |model|
363
- ModelInfo.new(
364
- value: model["value"],
365
- display_name: model["displayName"],
366
- description: model["description"],
367
- supports_effort: model["supportsEffort"],
368
- supported_effort_levels: model["supportedEffortLevels"],
369
- supports_adaptive_thinking: model["supportsAdaptiveThinking"]
370
- )
371
- end
372
-
373
- account_data = response["account"]
374
- account = if account_data
375
- AccountInfo.new(
376
- email: account_data["email"],
377
- organization: account_data["organization"],
378
- subscription_type: account_data["subscriptionType"],
379
- token_source: account_data["tokenSource"],
380
- api_key_source: account_data["apiKeySource"]
381
- )
382
- end
383
-
384
- InitializationResult.new(
385
- commands: commands,
386
- output_style: response["output_style"],
387
- available_output_styles: response["available_output_styles"] || [],
388
- models: models,
389
- account: account
390
- )
391
- end
392
-
393
- # Get available models (TypeScript SDK parity)
394
- # @return [Array<ModelInfo>]
395
- def supported_models
396
- response = send_control_request(subtype: "supported_models")
397
- (response["models"] || []).map do |model|
398
- ModelInfo.new(
399
- value: model["value"],
400
- display_name: model["displayName"],
401
- description: model["description"],
402
- supports_effort: model["supportsEffort"],
403
- supported_effort_levels: model["supportedEffortLevels"],
404
- supports_adaptive_thinking: model["supportsAdaptiveThinking"]
405
- )
406
- end
407
- end
408
-
409
- # Get MCP server status (TypeScript SDK parity)
410
- # @return [Array<McpServerStatus>]
411
- def mcp_server_status
412
- response = send_control_request(subtype: "mcp_server_status")
413
- (response["servers"] || []).map do |server|
414
- McpServerStatus.new(
415
- name: server["name"],
416
- status: server["status"],
417
- server_info: server["serverInfo"],
418
- error: server["error"],
419
- config: server["config"],
420
- scope: server["scope"],
421
- tools: server["tools"]
422
- )
423
- end
424
- end
425
-
426
- # Get account information (TypeScript SDK parity)
427
- # @return [AccountInfo]
428
- def account_info
429
- response = send_control_request(subtype: "account_info")
430
- AccountInfo.new(
431
- email: response["email"],
432
- organization: response["organization"],
433
- subscription_type: response["subscriptionType"],
434
- token_source: response["tokenSource"],
435
- api_key_source: response["apiKeySource"]
436
- )
437
- end
438
-
439
- # Reconnect to an MCP server (TypeScript SDK parity)
440
- #
441
- # Attempts to reconnect to a disconnected or errored MCP server.
442
- #
443
- # @param server_name [String] Name of the MCP server to reconnect
444
- # @return [Hash] Response from the CLI
445
- #
446
- # @example
447
- # protocol.mcp_reconnect("my-server")
448
- #
449
- def mcp_reconnect(server_name)
450
- send_control_request(subtype: "mcp_reconnect", serverName: server_name)
451
- end
452
-
453
- # Enable or disable an MCP server (TypeScript SDK parity)
454
- #
455
- # Toggles an MCP server on or off without removing its configuration.
456
- #
457
- # @param server_name [String] Name of the MCP server to toggle
458
- # @param enabled [Boolean] Whether to enable (true) or disable (false) the server
459
- # @return [Hash] Response from the CLI
460
- #
461
- # @example Enable a server
462
- # protocol.mcp_toggle("my-server", enabled: true)
463
- #
464
- # @example Disable a server
465
- # protocol.mcp_toggle("my-server", enabled: false)
466
- #
467
- def mcp_toggle(server_name, enabled:)
468
- send_control_request(subtype: "mcp_toggle", serverName: server_name, enabled: enabled)
469
- end
470
-
471
- # Initiate OAuth authentication for an MCP server (TypeScript SDK v0.2.52 parity)
472
- #
473
- # @param server_name [String] Name of the MCP server to authenticate
474
- # @return [Hash] Response from the CLI
475
- #
476
- # @example
477
- # protocol.mcp_authenticate("my-remote-server")
478
- #
479
- def mcp_authenticate(server_name)
480
- send_control_request(subtype: "mcp_authenticate", serverName: server_name)
481
- end
482
-
483
- # Clear stored auth credentials for an MCP server (TypeScript SDK v0.2.52 parity)
484
- #
485
- # @param server_name [String] Name of the MCP server to clear auth for
486
- # @return [Hash] Response from the CLI
487
- #
488
- # @example
489
- # protocol.mcp_clear_auth("my-remote-server")
490
- #
491
- def mcp_clear_auth(server_name)
492
- send_control_request(subtype: "mcp_clear_auth", serverName: server_name)
493
- end
494
-
495
- # Stop a running background task (TypeScript SDK parity)
496
- #
497
- # Sends a stop signal to a running task. A task_notification message
498
- # with status 'stopped' will be emitted when the task stops.
499
- #
500
- # @param task_id [String] The task ID from task_notification events
501
- # @return [void]
502
- #
503
- # @example
504
- # protocol.stop_task("task-123")
505
- #
506
- def stop_task(task_id)
507
- send_control_request(subtype: "stop_task", task_id: task_id)
508
- end
509
-
510
- # Apply flag settings (TypeScript SDK v0.2.50 parity)
511
- #
512
- # Merges the provided settings into the flag settings layer.
513
- #
514
- # @param settings [Hash] Settings to merge into the flag layer
515
- # @return [Hash] Response from the CLI
516
- #
517
- # @example
518
- # protocol.apply_flag_settings({ "model" => "claude-sonnet-4-5-20250514" })
519
- #
520
- def apply_flag_settings(settings)
521
- send_control_request(subtype: "apply_flag_settings", settings: settings)
522
- end
523
-
524
- # Dynamically set MCP servers for this session (TypeScript SDK parity)
525
- #
526
- # This replaces the current set of dynamically-added MCP servers.
527
- # Servers that are removed will be disconnected, and new servers will be connected.
528
- #
529
- # @param servers [Hash] Map of server name to configuration
530
- # @return [McpSetServersResult] Result with added, removed, and errors
531
- #
532
- # @example
533
- # result = protocol.set_mcp_servers({
534
- # "my-server" => { type: "stdio", command: "node", args: ["server.js"] }
535
- # })
536
- # puts "Added: #{result.added}"
537
- # puts "Removed: #{result.removed}"
538
- #
539
- def set_mcp_servers(servers)
540
- # Convert servers hash to format expected by CLI
541
- servers_config = servers.reject do |_, config|
542
- config.is_a?(Hash) && (config[:type] == "sdk" || config["type"] == "sdk")
543
- end
544
-
545
- response = send_control_request(subtype: "mcp_set_servers", servers: servers_config)
546
-
547
- McpSetServersResult.new(
548
- added: response["added"] || [],
549
- removed: response["removed"] || [],
550
- errors: response["errors"] || {}
551
- )
103
+ @abort_signal&.on_abort { @message_queue.push(:done) }
552
104
  end
553
105
 
554
106
  private
@@ -556,389 +108,5 @@ module ClaudeAgent
556
108
  def logger
557
109
  @options.effective_logger
558
110
  end
559
-
560
- # Background thread that reads messages and routes them
561
- def reader_loop
562
- @transport.read_messages do |raw|
563
- # Check abort signal on each iteration
564
- if @abort_signal&.aborted?
565
- @running = false
566
- break
567
- end
568
-
569
- break unless @running
570
-
571
- if raw["type"] == "control_request"
572
- logger.debug("protocol") { "Control request received: #{raw.dig("request", "subtype")}" }
573
- handle_control_request(raw)
574
- elsif raw["type"] == "control_response"
575
- logger.debug("protocol") { "Control response received: #{raw.dig("response", "request_id")}" }
576
- handle_control_response(raw)
577
- else
578
- # SDK message - queue for consumer
579
- logger.debug("protocol") { "Queued message: #{raw["type"]}" }
580
- @message_queue.push(raw)
581
- end
582
- end
583
- rescue IOError, Errno::EPIPE
584
- logger.debug("protocol") { "Reader thread exiting: transport closed" }
585
- @running = false
586
- rescue AbortError
587
- logger.debug("protocol") { "Reader thread exiting: abort signal" }
588
- @running = false
589
- end
590
-
591
- # Send initialization request
592
- # @return [Hash] Server info
593
- def send_initialize
594
- hooks_config = build_hooks_config
595
-
596
- request = { subtype: "initialize" }
597
- request[:hooks] = hooks_config if hooks_config
598
- request[:promptSuggestions] = true if options.prompt_suggestions
599
- request[:sdkMcpServers] = sdk_mcp_server_names if options.has_sdk_mcp_servers?
600
-
601
- send_control_request(**request)
602
- end
603
-
604
- # Extract SDK MCP server names from options
605
- # @return [Array<String>]
606
- def sdk_mcp_server_names
607
- options.mcp_servers
608
- .select { |_, v| v.is_a?(Hash) && v[:type] == "sdk" }
609
- .keys
610
- .map(&:to_s)
611
- end
612
-
613
- # Build hooks configuration for initialization
614
- # @return [Hash, nil]
615
- def build_hooks_config
616
- return nil unless options.has_hooks?
617
-
618
- config = {}
619
-
620
- options.hooks.each do |event, matchers|
621
- config[event] = matchers.map.with_index do |matcher, idx|
622
- callback_ids = matcher.callbacks.map.with_index do |callback, cidx|
623
- callback_id = "hook_#{event}_#{idx}_#{cidx}"
624
- @hook_callbacks[callback_id] = callback
625
- callback_id
626
- end
627
-
628
- entry = {
629
- matcher: matcher.matcher,
630
- hookCallbackIds: callback_ids
631
- }
632
- entry[:timeout] = matcher.timeout if matcher.timeout
633
- entry
634
- end
635
- end
636
-
637
- config
638
- end
639
-
640
- # Handle incoming control request from CLI
641
- # @param raw [Hash] Raw control request
642
- def handle_control_request(raw)
643
- request = raw["request"] || {}
644
- request_id = raw["request_id"]
645
- subtype = request["subtype"]
646
-
647
- response = case subtype
648
- when "can_use_tool"
649
- handle_can_use_tool(request)
650
- when "hook_callback"
651
- handle_hook_callback(request)
652
- when "mcp_message"
653
- handle_mcp_message(request)
654
- else
655
- { error: "Unknown control request subtype: #{subtype}" }
656
- end
657
-
658
- send_control_response(request_id, response)
659
- rescue => e
660
- send_control_response(request_id, { error: e.message })
661
- end
662
-
663
- # Handle can_use_tool permission request
664
- #
665
- # Supports three modes:
666
- # 1. Synchronous callback — can_use_tool is set, returns result directly
667
- # 2. Queue-based — permission_queue is set, enqueues and waits for resolution
668
- # 3. Default allow — neither is set
669
- #
670
- # In hybrid mode (callback + queue), the callback can call
671
- # context.request.defer! to enqueue the request instead of
672
- # returning a synchronous answer.
673
- #
674
- # @param request [Hash] Request data
675
- # @return [Hash] Response
676
- def handle_can_use_tool(request)
677
- tool_name = request["tool_name"]
678
- input = (request["input"] || {}).deep_symbolize_keys
679
-
680
- # Build PermissionRequest for queue/hybrid modes
681
- perm_request = PermissionRequest.new(
682
- tool_name: tool_name,
683
- input: input,
684
- context: nil, # set below after ToolPermissionContext is built
685
- request_id: request["tool_use_id"] || SecureRandom.hex(8)
686
- )
687
-
688
- context = ToolPermissionContext.new(
689
- permission_suggestions: request["permission_suggestions"],
690
- blocked_path: request["blocked_path"],
691
- decision_reason: request["decision_reason"],
692
- tool_use_id: request["tool_use_id"],
693
- agent_id: request["agent_id"],
694
- description: request["description"],
695
- signal: @abort_signal,
696
- request: perm_request
697
- )
698
-
699
- # Back-fill context on the request (circular, but both are needed)
700
- perm_request.instance_variable_set(:@context, context)
701
-
702
- # Mode 1: Synchronous callback
703
- if options.can_use_tool
704
- result = options.can_use_tool.call(tool_name, input, context)
705
-
706
- # Check if the callback deferred to the queue
707
- if perm_request.deferred?
708
- return enqueue_and_wait(perm_request, tool_name, input)
709
- end
710
-
711
- return normalize_permission_result(result, tool_name, input)
712
- end
713
-
714
- # Mode 2: Queue-based
715
- if @permission_queue
716
- return enqueue_and_wait(perm_request, tool_name, input)
717
- end
718
-
719
- # Mode 3: Default allow
720
- logger.info("protocol") { "Permission decision for #{tool_name}: allow (no callback)" }
721
- { behavior: "allow" }
722
- end
723
-
724
- # Enqueue a permission request and block until resolved
725
- # @param perm_request [PermissionRequest] The request to enqueue
726
- # @param tool_name [String] Tool name (for logging)
727
- # @param input [Hash] Original tool input
728
- # @return [Hash] Normalized response
729
- def enqueue_and_wait(perm_request, tool_name, input)
730
- logger.info("protocol") { "Permission request queued for #{tool_name}" }
731
- @permission_queue.push(perm_request)
732
-
733
- result = perm_request.wait(timeout: DEFAULT_TIMEOUT)
734
- normalize_permission_result(result, tool_name, input)
735
- end
736
-
737
- # Normalize a permission result for the CLI response
738
- # @param result [PermissionResultAllow, PermissionResultDeny, Hash] The result
739
- # @param tool_name [String] Tool name (for logging)
740
- # @param input [Hash] Original tool input
741
- # @return [Hash] Normalized response
742
- def normalize_permission_result(result, tool_name, input)
743
- normalized = result.to_h
744
- logger.info("protocol") { "Permission decision for #{tool_name}: #{normalized[:behavior]}" }
745
-
746
- if normalized[:behavior] == "allow" && !normalized.key?(:updatedInput)
747
- normalized[:updatedInput] = input
748
- end
749
-
750
- normalized
751
- end
752
-
753
- # Handle hook callback request
754
- # @param request [Hash] Request data
755
- # @return [Hash] Response
756
- def handle_hook_callback(request)
757
- callback_id = request["callback_id"]
758
- input = (request["input"] || {}).deep_symbolize_keys
759
- tool_use_id = request["tool_use_id"]
760
-
761
- callback = @hook_callbacks[callback_id]
762
- unless callback
763
- logger.debug("protocol") { "Hook callback not found: #{callback_id}" }
764
- return {}
765
- end
766
- logger.debug("protocol") { "Hook callback: #{callback_id}" }
767
-
768
- context = { tool_use_id: tool_use_id }
769
- result = callback.call(input, context)
770
-
771
- # Normalize result - convert Ruby field names to CLI field names
772
- normalize_hook_response(result || {})
773
- end
774
-
775
- # Handle MCP message routing
776
- # @param request [Hash] Request data
777
- # @return [Hash] Response
778
- def handle_mcp_message(request)
779
- server_name = request["server_name"]
780
- message = request["message"]
781
- logger.debug("protocol") { "MCP message for #{server_name}: #{message["method"]}" }
782
-
783
- # Find SDK MCP server
784
- server_config = options.mcp_servers[server_name]
785
- return { error: "Unknown MCP server: #{server_name}" } unless server_config
786
- return { error: "Not an SDK MCP server" } unless server_config[:type] == "sdk"
787
-
788
- server_instance = server_config[:instance]
789
- return { error: "No server instance" } unless server_instance
790
-
791
- # Route message to server
792
- mcp_response = server_instance.handle_message(message)
793
- { mcp_response: mcp_response }
794
- end
795
-
796
- # Mapping of Ruby keys to CLI keys for hook responses
797
- # Handles special cases where Ruby uses trailing underscore for reserved words
798
- HOOK_RESPONSE_KEYS = {
799
- continue_: "continue",
800
- continue: "continue",
801
- async_: "async",
802
- async: "async",
803
- async_timeout: "asyncTimeout",
804
- suppress_output: "suppressOutput",
805
- stop_reason: "stopReason",
806
- decision: "decision",
807
- system_message: "systemMessage",
808
- reason: "reason"
809
- }.freeze
810
-
811
- # Normalize hook response for CLI
812
- # @param result [Hash] Raw result from callback
813
- # @return [Hash] Normalized response
814
- def normalize_hook_response(result)
815
- result = result.to_h
816
-
817
- response = HOOK_RESPONSE_KEYS.each_with_object({}) do |(ruby_key, json_key), acc|
818
- acc[json_key] = result[ruby_key] if result.key?(ruby_key)
819
- end
820
-
821
- if result[:hook_specific_output]
822
- response["hookSpecificOutput"] = normalize_hook_specific_output(result[:hook_specific_output])
823
- end
824
-
825
- response
826
- end
827
-
828
- # Normalize hookSpecificOutput nested fields to camelCase
829
- # @param hso [Hash] Hook-specific output
830
- # @return [Hash] Normalized output
831
- def normalize_hook_specific_output(hso)
832
- hso.each_with_object({}) do |(key, value), normalized|
833
- camel_key = key.to_s.camelize(:lower)
834
- normalized[camel_key] = value
835
- end
836
- end
837
-
838
- # Handle control response from CLI
839
- # @param raw [Hash] Raw control response
840
- def handle_control_response(raw)
841
- response = raw["response"] || {}
842
- request_id = response["request_id"]
843
-
844
- @mutex.synchronize do
845
- if @pending_requests.key?(request_id)
846
- @pending_results[request_id] = response
847
- @condition.broadcast
848
- end
849
- end
850
- end
851
-
852
- # Send a control request and wait for response
853
- # @param subtype [String] Request subtype
854
- # @param kwargs [Hash] Additional request data
855
- # @param timeout [Integer] Timeout in seconds
856
- # @return [Hash] Response data
857
- # @raise [AbortError] If abort signal is triggered
858
- def send_control_request(subtype:, timeout: DEFAULT_TIMEOUT, **kwargs)
859
- # Check abort signal before sending
860
- @abort_signal&.check!
861
-
862
- request_id = generate_request_id
863
- logger.debug("protocol") { "Sending control request: #{subtype} (#{request_id})" }
864
-
865
- request = {
866
- type: "control_request",
867
- request_id: request_id,
868
- request: { subtype: subtype, **kwargs }
869
- }
870
-
871
- @mutex.synchronize do
872
- @pending_requests[request_id] = true
873
- end
874
-
875
- write_message(request)
876
-
877
- # Wait for response
878
- response = nil
879
- @mutex.synchronize do
880
- deadline = Time.now + timeout
881
- until @pending_results.key?(request_id)
882
- # Check abort signal during wait (outside mutex for thread safety)
883
- if @abort_signal&.aborted?
884
- @pending_requests.delete(request_id)
885
- raise AbortError, @abort_signal.reason
886
- end
887
-
888
- remaining = deadline - Time.now
889
- if remaining <= 0
890
- @pending_requests.delete(request_id)
891
- raise TimeoutError.new("Control request timed out", request_id: request_id, timeout_seconds: timeout)
892
- end
893
- @condition.wait(@mutex, [ remaining, 0.1 ].min) # Wake up periodically to check abort
894
- end
895
- response = @pending_results.delete(request_id)
896
- @pending_requests.delete(request_id)
897
- end
898
-
899
- if response["subtype"] == "error"
900
- logger.error("protocol") { "Control request failed: #{subtype} - #{response["error"]}" }
901
- raise Error, response["error"] || "Unknown error"
902
- end
903
-
904
- response["response"] || response
905
- end
906
-
907
- # Send a control response
908
- # @param request_id [String] Request ID to respond to
909
- # @param data [Hash] Response data
910
- def send_control_response(request_id, data)
911
- response = {
912
- type: "control_response",
913
- response: {
914
- subtype: data[:error] ? "error" : "success",
915
- request_id: request_id
916
- }
917
- }
918
-
919
- if data[:error]
920
- response[:response][:error] = data[:error]
921
- else
922
- response[:response][:response] = data
923
- end
924
-
925
- write_message(response)
926
- end
927
-
928
- # Write a message to the transport
929
- # @param message [Hash] Message to write
930
- def write_message(message)
931
- json = JSON.generate(message)
932
- @transport.write(json)
933
- end
934
-
935
- # Generate a unique request ID
936
- # @return [String]
937
- def generate_request_id
938
- @mutex.synchronize do
939
- @request_counter += 1
940
- "#{REQUEST_ID_PREFIX}_#{@request_counter}_#{SecureRandom.hex(4)}"
941
- end
942
- end
943
111
  end
944
112
  end