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.
- checksums.yaml +4 -4
- data/.claude/rules/testing.md +51 -10
- data/.claude/settings.json +1 -0
- data/ARCHITECTURE.md +237 -0
- data/CHANGELOG.md +45 -0
- data/CLAUDE.md +2 -0
- data/README.md +46 -1
- data/Rakefile +17 -0
- data/SPEC.md +214 -125
- data/lib/claude_agent/client/commands.rb +225 -0
- data/lib/claude_agent/client.rb +4 -204
- data/lib/claude_agent/content_blocks/generic_block.rb +39 -0
- data/lib/claude_agent/content_blocks/image_content_block.rb +54 -0
- data/lib/claude_agent/content_blocks/server_tool_result_block.rb +22 -0
- data/lib/claude_agent/content_blocks/server_tool_use_block.rb +48 -0
- data/lib/claude_agent/content_blocks/text_block.rb +19 -0
- data/lib/claude_agent/content_blocks/thinking_block.rb +19 -0
- data/lib/claude_agent/content_blocks/tool_result_block.rb +25 -0
- data/lib/claude_agent/content_blocks/tool_use_block.rb +134 -0
- data/lib/claude_agent/content_blocks.rb +8 -335
- data/lib/claude_agent/control_protocol/commands.rb +304 -0
- data/lib/claude_agent/control_protocol/lifecycle.rb +113 -0
- data/lib/claude_agent/control_protocol/messaging.rb +166 -0
- data/lib/claude_agent/control_protocol/primitives.rb +168 -0
- data/lib/claude_agent/control_protocol/request_handling.rb +231 -0
- data/lib/claude_agent/control_protocol.rb +27 -882
- data/lib/claude_agent/event_handler.rb +1 -0
- data/lib/claude_agent/get_session_info.rb +86 -0
- data/lib/claude_agent/hooks.rb +23 -2
- data/lib/claude_agent/list_sessions.rb +22 -13
- data/lib/claude_agent/message_parser.rb +26 -4
- data/lib/claude_agent/messages/conversation.rb +138 -0
- data/lib/claude_agent/messages/generic.rb +39 -0
- data/lib/claude_agent/messages/hook_lifecycle.rb +158 -0
- data/lib/claude_agent/messages/result.rb +80 -0
- data/lib/claude_agent/messages/streaming.rb +84 -0
- data/lib/claude_agent/messages/system.rb +67 -0
- data/lib/claude_agent/messages/task_lifecycle.rb +240 -0
- data/lib/claude_agent/messages/tool_lifecycle.rb +95 -0
- data/lib/claude_agent/messages.rb +11 -829
- data/lib/claude_agent/options/serializer.rb +194 -0
- data/lib/claude_agent/options.rb +11 -176
- data/lib/claude_agent/sandbox_settings.rb +3 -0
- data/lib/claude_agent/session.rb +0 -204
- data/lib/claude_agent/session_mutations.rb +148 -0
- data/lib/claude_agent/types/mcp.rb +30 -0
- data/lib/claude_agent/types/models.rb +146 -0
- data/lib/claude_agent/types/operations.rb +38 -0
- data/lib/claude_agent/types/sessions.rb +50 -0
- data/lib/claude_agent/types/tools.rb +32 -0
- data/lib/claude_agent/types.rb +6 -264
- data/lib/claude_agent/v2_session.rb +207 -0
- data/lib/claude_agent/version.rb +1 -1
- data/lib/claude_agent.rb +37 -3
- data/sig/claude_agent.rbs +144 -13
- 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
|