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.
- 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 +52 -0
- data/CLAUDE.md +2 -0
- data/README.md +47 -1
- data/Rakefile +17 -0
- data/SPEC.md +314 -133
- 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 -861
- 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 +27 -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 -827
- 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 +146 -13
- 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
|