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,168 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ClaudeAgent
4
+ class ControlProtocol
5
+ # Low-level protocol primitives: sending/receiving control messages,
6
+ # writing to transport, request ID generation, and initialization.
7
+ module Primitives
8
+ private
9
+
10
+ # Send a control request and wait for response
11
+ # @param subtype [String] Request subtype
12
+ # @param kwargs [Hash] Additional request data
13
+ # @param timeout [Integer] Timeout in seconds
14
+ # @return [Hash] Response data
15
+ # @raise [AbortError] If abort signal is triggered
16
+ def send_control_request(subtype:, timeout: DEFAULT_TIMEOUT, **kwargs)
17
+ # Check abort signal before sending
18
+ @abort_signal&.check!
19
+
20
+ request_id = generate_request_id
21
+ logger.debug("protocol") { "Sending control request: #{subtype} (#{request_id})" }
22
+
23
+ request = {
24
+ type: "control_request",
25
+ request_id: request_id,
26
+ request: { subtype: subtype, **kwargs }
27
+ }
28
+
29
+ @mutex.synchronize do
30
+ @pending_requests[request_id] = true
31
+ end
32
+
33
+ write_message(request)
34
+
35
+ # Wait for response
36
+ response = nil
37
+ @mutex.synchronize do
38
+ deadline = Time.now + timeout
39
+ until @pending_results.key?(request_id)
40
+ # Check abort signal during wait (outside mutex for thread safety)
41
+ if @abort_signal&.aborted?
42
+ @pending_requests.delete(request_id)
43
+ raise AbortError, @abort_signal.reason
44
+ end
45
+
46
+ remaining = deadline - Time.now
47
+ if remaining <= 0
48
+ @pending_requests.delete(request_id)
49
+ raise TimeoutError.new("Control request timed out", request_id: request_id, timeout_seconds: timeout)
50
+ end
51
+ @condition.wait(@mutex, [ remaining, 0.1 ].min) # Wake up periodically to check abort
52
+ end
53
+ response = @pending_results.delete(request_id)
54
+ @pending_requests.delete(request_id)
55
+ end
56
+
57
+ if response["subtype"] == "error"
58
+ logger.error("protocol") { "Control request failed: #{subtype} - #{response["error"]}" }
59
+ raise Error, response["error"] || "Unknown error"
60
+ end
61
+
62
+ response["response"] || response
63
+ end
64
+
65
+ # Send a control response
66
+ # @param request_id [String] Request ID to respond to
67
+ # @param data [Hash] Response data
68
+ def send_control_response(request_id, data)
69
+ response = {
70
+ type: "control_response",
71
+ response: {
72
+ subtype: data[:error] ? "error" : "success",
73
+ request_id: request_id
74
+ }
75
+ }
76
+
77
+ if data[:error]
78
+ response[:response][:error] = data[:error]
79
+ else
80
+ response[:response][:response] = data
81
+ end
82
+
83
+ write_message(response)
84
+ end
85
+
86
+ # Handle control response from CLI
87
+ # @param raw [Hash] Raw control response
88
+ def handle_control_response(raw)
89
+ response = raw["response"] || {}
90
+ request_id = response["request_id"]
91
+
92
+ @mutex.synchronize do
93
+ if @pending_requests.key?(request_id)
94
+ @pending_results[request_id] = response
95
+ @condition.broadcast
96
+ end
97
+ end
98
+ end
99
+
100
+ # Write a message to the transport
101
+ # @param message [Hash] Message to write
102
+ def write_message(message)
103
+ json = JSON.generate(message)
104
+ @transport.write(json)
105
+ end
106
+
107
+ # Generate a unique request ID
108
+ # @return [String]
109
+ def generate_request_id
110
+ @mutex.synchronize do
111
+ @request_counter += 1
112
+ "#{REQUEST_ID_PREFIX}_#{@request_counter}_#{SecureRandom.hex(4)}"
113
+ end
114
+ end
115
+
116
+ # Send initialization request
117
+ # @return [Hash] Server info
118
+ def send_initialize
119
+ hooks_config = build_hooks_config
120
+
121
+ request = { subtype: "initialize" }
122
+ request[:hooks] = hooks_config if hooks_config
123
+ request[:promptSuggestions] = true if options.prompt_suggestions
124
+ request[:sdkMcpServers] = sdk_mcp_server_names if options.has_sdk_mcp_servers?
125
+ request[:toolConfig] = options.tool_config if options.tool_config
126
+ request[:agentProgressSummaries] = true if options.agent_progress_summaries
127
+
128
+ send_control_request(**request)
129
+ end
130
+
131
+ # Build hooks configuration for initialization
132
+ # @return [Hash, nil]
133
+ def build_hooks_config
134
+ return nil unless options.has_hooks?
135
+
136
+ config = {}
137
+
138
+ options.hooks.each do |event, matchers|
139
+ config[event] = matchers.map.with_index do |matcher, idx|
140
+ callback_ids = matcher.callbacks.map.with_index do |callback, cidx|
141
+ callback_id = "hook_#{event}_#{idx}_#{cidx}"
142
+ @hook_callbacks[callback_id] = callback
143
+ callback_id
144
+ end
145
+
146
+ entry = {
147
+ matcher: matcher.matcher,
148
+ hookCallbackIds: callback_ids
149
+ }
150
+ entry[:timeout] = matcher.timeout if matcher.timeout
151
+ entry
152
+ end
153
+ end
154
+
155
+ config
156
+ end
157
+
158
+ # Extract SDK MCP server names from options
159
+ # @return [Array<String>]
160
+ def sdk_mcp_server_names
161
+ options.mcp_servers
162
+ .select { |_, v| v.is_a?(Hash) && v[:type] == "sdk" }
163
+ .keys
164
+ .map(&:to_s)
165
+ end
166
+ end
167
+ end
168
+ end
@@ -0,0 +1,231 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ClaudeAgent
4
+ class ControlProtocol
5
+ # Handles incoming control requests from the CLI: permission checks,
6
+ # hook callbacks, MCP message routing, and elicitation.
7
+ module RequestHandling
8
+ private
9
+
10
+ # Handle incoming control request from CLI
11
+ # @param raw [Hash] Raw control request
12
+ def handle_control_request(raw)
13
+ request = raw["request"] || {}
14
+ request_id = raw["request_id"]
15
+ subtype = request["subtype"]
16
+
17
+ response = case subtype
18
+ when "can_use_tool"
19
+ handle_can_use_tool(request)
20
+ when "hook_callback"
21
+ handle_hook_callback(request)
22
+ when "mcp_message"
23
+ handle_mcp_message(request)
24
+ when "elicitation"
25
+ handle_elicitation(request)
26
+ else
27
+ { error: "Unknown control request subtype: #{subtype}" }
28
+ end
29
+
30
+ send_control_response(request_id, response)
31
+ rescue => e
32
+ send_control_response(request_id, { error: e.message })
33
+ end
34
+
35
+ # Handle can_use_tool permission request
36
+ #
37
+ # Supports three modes:
38
+ # 1. Synchronous callback — can_use_tool is set, returns result directly
39
+ # 2. Queue-based — permission_queue is set, enqueues and waits for resolution
40
+ # 3. Default allow — neither is set
41
+ #
42
+ # In hybrid mode (callback + queue), the callback can call
43
+ # context.request.defer! to enqueue the request instead of
44
+ # returning a synchronous answer.
45
+ #
46
+ # @param request [Hash] Request data
47
+ # @return [Hash] Response
48
+ def handle_can_use_tool(request)
49
+ tool_name = request["tool_name"]
50
+ input = (request["input"] || {}).deep_symbolize_keys
51
+
52
+ # Build PermissionRequest for queue/hybrid modes
53
+ perm_request = PermissionRequest.new(
54
+ tool_name: tool_name,
55
+ input: input,
56
+ context: nil, # set below after ToolPermissionContext is built
57
+ request_id: request["tool_use_id"] || SecureRandom.hex(8)
58
+ )
59
+
60
+ context = ToolPermissionContext.new(
61
+ permission_suggestions: request["permission_suggestions"],
62
+ blocked_path: request["blocked_path"],
63
+ decision_reason: request["decision_reason"],
64
+ tool_use_id: request["tool_use_id"],
65
+ agent_id: request["agent_id"],
66
+ description: request["description"],
67
+ signal: @abort_signal,
68
+ request: perm_request
69
+ )
70
+
71
+ # Back-fill context on the request (circular, but both are needed)
72
+ perm_request.instance_variable_set(:@context, context)
73
+
74
+ # Mode 1: Synchronous callback
75
+ if options.can_use_tool
76
+ result = options.can_use_tool.call(tool_name, input, context)
77
+
78
+ # Check if the callback deferred to the queue
79
+ if perm_request.deferred?
80
+ return enqueue_and_wait(perm_request, tool_name, input)
81
+ end
82
+
83
+ return normalize_permission_result(result, tool_name, input)
84
+ end
85
+
86
+ # Mode 2: Queue-based
87
+ if @permission_queue
88
+ return enqueue_and_wait(perm_request, tool_name, input)
89
+ end
90
+
91
+ # Mode 3: Default allow
92
+ logger.info("protocol") { "Permission decision for #{tool_name}: allow (no callback)" }
93
+ { behavior: "allow" }
94
+ end
95
+
96
+ # Enqueue a permission request and block until resolved
97
+ # @param perm_request [PermissionRequest] The request to enqueue
98
+ # @param tool_name [String] Tool name (for logging)
99
+ # @param input [Hash] Original tool input
100
+ # @return [Hash] Normalized response
101
+ def enqueue_and_wait(perm_request, tool_name, input)
102
+ logger.info("protocol") { "Permission request queued for #{tool_name}" }
103
+ @permission_queue.push(perm_request)
104
+
105
+ result = perm_request.wait(timeout: DEFAULT_TIMEOUT)
106
+ normalize_permission_result(result, tool_name, input)
107
+ end
108
+
109
+ # Normalize a permission result for the CLI response
110
+ # @param result [PermissionResultAllow, PermissionResultDeny, Hash] The result
111
+ # @param tool_name [String] Tool name (for logging)
112
+ # @param input [Hash] Original tool input
113
+ # @return [Hash] Normalized response
114
+ def normalize_permission_result(result, tool_name, input)
115
+ normalized = result.to_h
116
+ logger.info("protocol") { "Permission decision for #{tool_name}: #{normalized[:behavior]}" }
117
+
118
+ if normalized[:behavior] == "allow" && !normalized.key?(:updatedInput)
119
+ normalized[:updatedInput] = input
120
+ end
121
+
122
+ normalized
123
+ end
124
+
125
+ # Handle hook callback request
126
+ # @param request [Hash] Request data
127
+ # @return [Hash] Response
128
+ def handle_hook_callback(request)
129
+ callback_id = request["callback_id"]
130
+ input = (request["input"] || {}).deep_symbolize_keys
131
+ tool_use_id = request["tool_use_id"]
132
+
133
+ callback = @hook_callbacks[callback_id]
134
+ unless callback
135
+ logger.debug("protocol") { "Hook callback not found: #{callback_id}" }
136
+ return {}
137
+ end
138
+ logger.debug("protocol") { "Hook callback: #{callback_id}" }
139
+
140
+ context = { tool_use_id: tool_use_id }
141
+ result = callback.call(input, context)
142
+
143
+ # Normalize result - convert Ruby field names to CLI field names
144
+ normalize_hook_response(result || {})
145
+ end
146
+
147
+ # Handle MCP message routing
148
+ # @param request [Hash] Request data
149
+ # @return [Hash] Response
150
+ def handle_mcp_message(request)
151
+ server_name = request["server_name"]
152
+ message = request["message"]
153
+ logger.debug("protocol") { "MCP message for #{server_name}: #{message["method"]}" }
154
+
155
+ # Find SDK MCP server
156
+ server_config = options.mcp_servers[server_name]
157
+ return { error: "Unknown MCP server: #{server_name}" } unless server_config
158
+ return { error: "Not an SDK MCP server" } unless server_config[:type] == "sdk"
159
+
160
+ server_instance = server_config[:instance]
161
+ return { error: "No server instance" } unless server_instance
162
+
163
+ # Route message to server
164
+ mcp_response = server_instance.handle_message(message)
165
+ { mcp_response: mcp_response }
166
+ end
167
+
168
+ # Handle elicitation request from CLI (TypeScript SDK v0.2.63 parity)
169
+ # @param request [Hash] Request data
170
+ # @return [Hash] Response
171
+ def handle_elicitation(request)
172
+ elicitation_request = {
173
+ server_name: request["mcp_server_name"],
174
+ message: request["message"],
175
+ mode: request["mode"],
176
+ url: request["url"],
177
+ elicitation_id: request["elicitation_id"],
178
+ requested_schema: request["requested_schema"]
179
+ }
180
+
181
+ if options.on_elicitation
182
+ result = options.on_elicitation.call(elicitation_request, signal: @abort_signal)
183
+ return normalize_elicitation_result(result)
184
+ end
185
+
186
+ # Default: decline
187
+ { action: "decline" }
188
+ end
189
+
190
+ # Normalize an elicitation result for the CLI response
191
+ # @param result [Hash, nil] The result from the callback
192
+ # @return [Hash] Normalized response
193
+ def normalize_elicitation_result(result)
194
+ return { action: "decline" } unless result
195
+
196
+ result = result.to_h if result.respond_to?(:to_h) && !result.is_a?(Hash)
197
+ {
198
+ action: result[:action] || result["action"] || "decline",
199
+ content: result[:content] || result["content"]
200
+ }.compact
201
+ end
202
+
203
+ # Normalize hook response for CLI
204
+ # @param result [Hash] Raw result from callback
205
+ # @return [Hash] Normalized response
206
+ def normalize_hook_response(result)
207
+ result = result.to_h
208
+
209
+ response = HOOK_RESPONSE_KEYS.each_with_object({}) do |(ruby_key, json_key), acc|
210
+ acc[json_key] = result[ruby_key] if result.key?(ruby_key)
211
+ end
212
+
213
+ if result[:hook_specific_output]
214
+ response["hookSpecificOutput"] = normalize_hook_specific_output(result[:hook_specific_output])
215
+ end
216
+
217
+ response
218
+ end
219
+
220
+ # Normalize hookSpecificOutput nested fields to camelCase
221
+ # @param hso [Hash] Hook-specific output
222
+ # @return [Hash] Normalized output
223
+ def normalize_hook_specific_output(hso)
224
+ hso.each_with_object({}) do |(key, value), normalized|
225
+ camel_key = key.to_s.camelize(:lower)
226
+ normalized[camel_key] = value
227
+ end
228
+ end
229
+ end
230
+ end
231
+ end