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