claude_agent 0.7.12 → 0.7.14
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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 +53 -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 -206
- 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 +116 -0
- data/lib/claude_agent/control_protocol/messaging.rb +163 -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 +50 -882
- data/lib/claude_agent/conversation.rb +8 -1
- 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/query.rb +0 -2
- 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/transport/subprocess.rb +2 -2
- 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,304 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ClaudeAgent
|
|
4
|
+
class ControlProtocol
|
|
5
|
+
# Public control commands: interrupt, model/permission changes,
|
|
6
|
+
# MCP management, task control, and query methods.
|
|
7
|
+
module Commands
|
|
8
|
+
# Send an interrupt request
|
|
9
|
+
# @return [void]
|
|
10
|
+
def interrupt
|
|
11
|
+
send_control_request(subtype: "interrupt")
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# Change the permission mode
|
|
15
|
+
# @param mode [String] New permission mode
|
|
16
|
+
# @return [Hash] Response
|
|
17
|
+
def set_permission_mode(mode)
|
|
18
|
+
send_control_request(subtype: "set_permission_mode", mode: mode)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Change the model
|
|
22
|
+
# @param model [String, nil] New model name
|
|
23
|
+
# @return [Hash] Response
|
|
24
|
+
def set_model(model)
|
|
25
|
+
send_control_request(subtype: "set_model", model: model)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Rewind files to a previous state
|
|
29
|
+
# @param user_message_id [String] UUID of user message to rewind to
|
|
30
|
+
# @param dry_run [Boolean] If true, preview changes without modifying files
|
|
31
|
+
# @return [RewindFilesResult] Result with rewind information
|
|
32
|
+
def rewind_files(user_message_id, dry_run: false)
|
|
33
|
+
request = { user_message_id: user_message_id }
|
|
34
|
+
request[:dry_run] = dry_run if dry_run
|
|
35
|
+
|
|
36
|
+
response = send_control_request(subtype: "rewind_files", **request)
|
|
37
|
+
|
|
38
|
+
RewindFilesResult.new(
|
|
39
|
+
can_rewind: response["canRewind"] || response["can_rewind"] || false,
|
|
40
|
+
error: response["error"],
|
|
41
|
+
files_changed: response["filesChanged"] || response["files_changed"],
|
|
42
|
+
insertions: response["insertions"],
|
|
43
|
+
deletions: response["deletions"]
|
|
44
|
+
)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Set maximum thinking tokens (TypeScript SDK parity)
|
|
48
|
+
# @param tokens [Integer, nil] Max thinking tokens (nil to reset)
|
|
49
|
+
# @return [Hash] Response
|
|
50
|
+
def set_max_thinking_tokens(tokens)
|
|
51
|
+
send_control_request(subtype: "set_max_thinking_tokens", max_thinking_tokens: tokens)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Get available slash commands (TypeScript SDK parity)
|
|
55
|
+
# @return [Array<SlashCommand>]
|
|
56
|
+
def supported_commands
|
|
57
|
+
response = send_control_request(subtype: "supported_commands")
|
|
58
|
+
(response["commands"] || []).map do |cmd|
|
|
59
|
+
SlashCommand.new(
|
|
60
|
+
name: cmd["name"],
|
|
61
|
+
description: cmd["description"],
|
|
62
|
+
argument_hint: cmd["argumentHint"]
|
|
63
|
+
)
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Get full initialization result (TypeScript SDK parity)
|
|
68
|
+
#
|
|
69
|
+
# Sends the supported_commands request and maps the full response including
|
|
70
|
+
# commands, output style, available output styles, models, and account info.
|
|
71
|
+
#
|
|
72
|
+
# @return [InitializationResult]
|
|
73
|
+
def initialization_result
|
|
74
|
+
response = send_control_request(subtype: "supported_commands")
|
|
75
|
+
|
|
76
|
+
commands = (response["commands"] || []).map do |cmd|
|
|
77
|
+
SlashCommand.new(
|
|
78
|
+
name: cmd["name"],
|
|
79
|
+
description: cmd["description"],
|
|
80
|
+
argument_hint: cmd["argumentHint"]
|
|
81
|
+
)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
models = (response["models"] || []).map do |model|
|
|
85
|
+
ModelInfo.new(
|
|
86
|
+
value: model["value"],
|
|
87
|
+
display_name: model["displayName"],
|
|
88
|
+
description: model["description"],
|
|
89
|
+
supports_effort: model["supportsEffort"],
|
|
90
|
+
supported_effort_levels: model["supportedEffortLevels"],
|
|
91
|
+
supports_adaptive_thinking: model["supportsAdaptiveThinking"],
|
|
92
|
+
supports_fast_mode: model["supportsFastMode"],
|
|
93
|
+
supports_auto_mode: model["supportsAutoMode"]
|
|
94
|
+
)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
account_data = response["account"]
|
|
98
|
+
account = if account_data
|
|
99
|
+
AccountInfo.new(
|
|
100
|
+
email: account_data["email"],
|
|
101
|
+
organization: account_data["organization"],
|
|
102
|
+
subscription_type: account_data["subscriptionType"],
|
|
103
|
+
token_source: account_data["tokenSource"],
|
|
104
|
+
api_key_source: account_data["apiKeySource"]
|
|
105
|
+
)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
agents = (response["agents"] || []).map do |agent|
|
|
109
|
+
AgentInfo.new(
|
|
110
|
+
name: agent["name"],
|
|
111
|
+
description: agent["description"],
|
|
112
|
+
model: agent["model"]
|
|
113
|
+
)
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
InitializationResult.new(
|
|
117
|
+
commands: commands,
|
|
118
|
+
output_style: response["output_style"],
|
|
119
|
+
available_output_styles: response["available_output_styles"] || [],
|
|
120
|
+
models: models,
|
|
121
|
+
account: account,
|
|
122
|
+
agents: agents,
|
|
123
|
+
fast_mode_state: response["fast_mode_state"]
|
|
124
|
+
)
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# Get available models (TypeScript SDK parity)
|
|
128
|
+
# @return [Array<ModelInfo>]
|
|
129
|
+
def supported_models
|
|
130
|
+
response = send_control_request(subtype: "supported_models")
|
|
131
|
+
(response["models"] || []).map do |model|
|
|
132
|
+
ModelInfo.new(
|
|
133
|
+
value: model["value"],
|
|
134
|
+
display_name: model["displayName"],
|
|
135
|
+
description: model["description"],
|
|
136
|
+
supports_effort: model["supportsEffort"],
|
|
137
|
+
supported_effort_levels: model["supportedEffortLevels"],
|
|
138
|
+
supports_adaptive_thinking: model["supportsAdaptiveThinking"],
|
|
139
|
+
supports_fast_mode: model["supportsFastMode"],
|
|
140
|
+
supports_auto_mode: model["supportsAutoMode"]
|
|
141
|
+
)
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# Get available agents (TypeScript SDK v0.2.63 parity)
|
|
146
|
+
# @return [Array<AgentInfo>]
|
|
147
|
+
def supported_agents
|
|
148
|
+
response = send_control_request(subtype: "supported_agents")
|
|
149
|
+
(response["agents"] || []).map do |agent|
|
|
150
|
+
AgentInfo.new(
|
|
151
|
+
name: agent["name"],
|
|
152
|
+
description: agent["description"],
|
|
153
|
+
model: agent["model"]
|
|
154
|
+
)
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# Get MCP server status (TypeScript SDK parity)
|
|
159
|
+
# @return [Array<McpServerStatus>]
|
|
160
|
+
def mcp_server_status
|
|
161
|
+
response = send_control_request(subtype: "mcp_server_status")
|
|
162
|
+
(response["servers"] || []).map do |server|
|
|
163
|
+
McpServerStatus.new(
|
|
164
|
+
name: server["name"],
|
|
165
|
+
status: server["status"],
|
|
166
|
+
server_info: server["serverInfo"],
|
|
167
|
+
error: server["error"],
|
|
168
|
+
config: server["config"],
|
|
169
|
+
scope: server["scope"],
|
|
170
|
+
tools: server["tools"]
|
|
171
|
+
)
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
# Get account information (TypeScript SDK parity)
|
|
176
|
+
# @return [AccountInfo]
|
|
177
|
+
def account_info
|
|
178
|
+
response = send_control_request(subtype: "account_info")
|
|
179
|
+
AccountInfo.new(
|
|
180
|
+
email: response["email"],
|
|
181
|
+
organization: response["organization"],
|
|
182
|
+
subscription_type: response["subscriptionType"],
|
|
183
|
+
token_source: response["tokenSource"],
|
|
184
|
+
api_key_source: response["apiKeySource"]
|
|
185
|
+
)
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
# Reconnect to an MCP server (TypeScript SDK parity)
|
|
189
|
+
#
|
|
190
|
+
# Attempts to reconnect to a disconnected or errored MCP server.
|
|
191
|
+
#
|
|
192
|
+
# @param server_name [String] Name of the MCP server to reconnect
|
|
193
|
+
# @return [Hash] Response from the CLI
|
|
194
|
+
#
|
|
195
|
+
# @example
|
|
196
|
+
# protocol.mcp_reconnect("my-server")
|
|
197
|
+
#
|
|
198
|
+
def mcp_reconnect(server_name)
|
|
199
|
+
send_control_request(subtype: "mcp_reconnect", serverName: server_name)
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
# Enable or disable an MCP server (TypeScript SDK parity)
|
|
203
|
+
#
|
|
204
|
+
# Toggles an MCP server on or off without removing its configuration.
|
|
205
|
+
#
|
|
206
|
+
# @param server_name [String] Name of the MCP server to toggle
|
|
207
|
+
# @param enabled [Boolean] Whether to enable (true) or disable (false) the server
|
|
208
|
+
# @return [Hash] Response from the CLI
|
|
209
|
+
#
|
|
210
|
+
# @example Enable a server
|
|
211
|
+
# protocol.mcp_toggle("my-server", enabled: true)
|
|
212
|
+
#
|
|
213
|
+
# @example Disable a server
|
|
214
|
+
# protocol.mcp_toggle("my-server", enabled: false)
|
|
215
|
+
#
|
|
216
|
+
def mcp_toggle(server_name, enabled:)
|
|
217
|
+
send_control_request(subtype: "mcp_toggle", serverName: server_name, enabled: enabled)
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
# Initiate OAuth authentication for an MCP server (TypeScript SDK v0.2.52 parity)
|
|
221
|
+
#
|
|
222
|
+
# @param server_name [String] Name of the MCP server to authenticate
|
|
223
|
+
# @return [Hash] Response from the CLI
|
|
224
|
+
#
|
|
225
|
+
# @example
|
|
226
|
+
# protocol.mcp_authenticate("my-remote-server")
|
|
227
|
+
#
|
|
228
|
+
def mcp_authenticate(server_name)
|
|
229
|
+
send_control_request(subtype: "mcp_authenticate", serverName: server_name)
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
# Clear stored auth credentials for an MCP server (TypeScript SDK v0.2.52 parity)
|
|
233
|
+
#
|
|
234
|
+
# @param server_name [String] Name of the MCP server to clear auth for
|
|
235
|
+
# @return [Hash] Response from the CLI
|
|
236
|
+
#
|
|
237
|
+
# @example
|
|
238
|
+
# protocol.mcp_clear_auth("my-remote-server")
|
|
239
|
+
#
|
|
240
|
+
def mcp_clear_auth(server_name)
|
|
241
|
+
send_control_request(subtype: "mcp_clear_auth", serverName: server_name)
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
# Stop a running background task (TypeScript SDK parity)
|
|
245
|
+
#
|
|
246
|
+
# Sends a stop signal to a running task. A task_notification message
|
|
247
|
+
# with status 'stopped' will be emitted when the task stops.
|
|
248
|
+
#
|
|
249
|
+
# @param task_id [String] The task ID from task_notification events
|
|
250
|
+
# @return [void]
|
|
251
|
+
#
|
|
252
|
+
# @example
|
|
253
|
+
# protocol.stop_task("task-123")
|
|
254
|
+
#
|
|
255
|
+
def stop_task(task_id)
|
|
256
|
+
send_control_request(subtype: "stop_task", task_id: task_id)
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
# Apply flag settings (TypeScript SDK v0.2.50 parity)
|
|
260
|
+
#
|
|
261
|
+
# Merges the provided settings into the flag settings layer.
|
|
262
|
+
#
|
|
263
|
+
# @param settings [Hash] Settings to merge into the flag layer
|
|
264
|
+
# @return [Hash] Response from the CLI
|
|
265
|
+
#
|
|
266
|
+
# @example
|
|
267
|
+
# protocol.apply_flag_settings({ "model" => "claude-sonnet-4-5-20250514" })
|
|
268
|
+
#
|
|
269
|
+
def apply_flag_settings(settings)
|
|
270
|
+
send_control_request(subtype: "apply_flag_settings", settings: settings)
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
# Dynamically set MCP servers for this session (TypeScript SDK parity)
|
|
274
|
+
#
|
|
275
|
+
# This replaces the current set of dynamically-added MCP servers.
|
|
276
|
+
# Servers that are removed will be disconnected, and new servers will be connected.
|
|
277
|
+
#
|
|
278
|
+
# @param servers [Hash] Map of server name to configuration
|
|
279
|
+
# @return [McpSetServersResult] Result with added, removed, and errors
|
|
280
|
+
#
|
|
281
|
+
# @example
|
|
282
|
+
# result = protocol.set_mcp_servers({
|
|
283
|
+
# "my-server" => { type: "stdio", command: "node", args: ["server.js"] }
|
|
284
|
+
# })
|
|
285
|
+
# puts "Added: #{result.added}"
|
|
286
|
+
# puts "Removed: #{result.removed}"
|
|
287
|
+
#
|
|
288
|
+
def set_mcp_servers(servers)
|
|
289
|
+
# Convert servers hash to format expected by CLI
|
|
290
|
+
servers_config = servers.reject do |_, config|
|
|
291
|
+
config.is_a?(Hash) && (config[:type] == "sdk" || config["type"] == "sdk")
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
response = send_control_request(subtype: "mcp_set_servers", servers: servers_config)
|
|
295
|
+
|
|
296
|
+
McpSetServersResult.new(
|
|
297
|
+
added: response["added"] || [],
|
|
298
|
+
removed: response["removed"] || [],
|
|
299
|
+
errors: response["errors"] || {}
|
|
300
|
+
)
|
|
301
|
+
end
|
|
302
|
+
end
|
|
303
|
+
end
|
|
304
|
+
end
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ClaudeAgent
|
|
4
|
+
class ControlProtocol
|
|
5
|
+
# Connection lifecycle: start, stop, abort, and the background reader loop.
|
|
6
|
+
module Lifecycle
|
|
7
|
+
# Start the control protocol (initialize connection)
|
|
8
|
+
# @param streaming [Boolean] Whether to use streaming mode
|
|
9
|
+
# @param prompt [String, nil] Initial prompt for non-streaming mode
|
|
10
|
+
# @return [Hash, nil] Server info from initialization
|
|
11
|
+
def start(streaming: true, prompt: nil)
|
|
12
|
+
logger.info("protocol") { "Starting control protocol (streaming=#{streaming})" }
|
|
13
|
+
@transport.connect(streaming: streaming, prompt: prompt)
|
|
14
|
+
@running = true
|
|
15
|
+
|
|
16
|
+
# Start background reader thread
|
|
17
|
+
@reader_thread = Thread.new { reader_loop }
|
|
18
|
+
logger.debug("protocol") { "Reader thread started" }
|
|
19
|
+
|
|
20
|
+
# Always send initialize in streaming mode (Python/TypeScript SDK parity)
|
|
21
|
+
if streaming
|
|
22
|
+
@server_info = send_initialize
|
|
23
|
+
logger.info("protocol") { "Initialize complete" }
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
@server_info
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Stop the control protocol
|
|
30
|
+
# @return [void]
|
|
31
|
+
def stop
|
|
32
|
+
logger.info("protocol") { "Stopping control protocol" }
|
|
33
|
+
@running = false
|
|
34
|
+
@transport.end_input
|
|
35
|
+
@reader_thread&.join(5)
|
|
36
|
+
@transport.close
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Abort all pending operations (TypeScript SDK parity)
|
|
40
|
+
#
|
|
41
|
+
# This method:
|
|
42
|
+
# 1. Stops the reader loop
|
|
43
|
+
# 2. Fails all pending requests with AbortError
|
|
44
|
+
# 3. Terminates the transport
|
|
45
|
+
#
|
|
46
|
+
# @return [void]
|
|
47
|
+
def abort!
|
|
48
|
+
@running = false
|
|
49
|
+
|
|
50
|
+
# Drain permission queue so reader thread unblocks
|
|
51
|
+
@permission_queue&.drain!(reason: "Operation aborted")
|
|
52
|
+
|
|
53
|
+
# Send cancel requests for pending operations (protocol courtesy)
|
|
54
|
+
@mutex.synchronize do
|
|
55
|
+
@pending_requests.each_key do |request_id|
|
|
56
|
+
begin
|
|
57
|
+
write_message({ type: "control_cancel_request", request_id: request_id })
|
|
58
|
+
rescue
|
|
59
|
+
# Ignore transport errors during abort — fire-and-forget
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Fail all pending requests
|
|
65
|
+
@mutex.synchronize do
|
|
66
|
+
@pending_requests.each_key do |request_id|
|
|
67
|
+
@pending_results[request_id] = {
|
|
68
|
+
"subtype" => "error",
|
|
69
|
+
"error" => "Operation aborted"
|
|
70
|
+
}
|
|
71
|
+
end
|
|
72
|
+
@condition.broadcast
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Unblock the consumer and terminate the transport
|
|
76
|
+
@message_queue.push(:done)
|
|
77
|
+
@transport.terminate if @transport.respond_to?(:terminate)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
private
|
|
81
|
+
|
|
82
|
+
# Background thread that reads messages and routes them
|
|
83
|
+
def reader_loop
|
|
84
|
+
@transport.read_messages do |raw|
|
|
85
|
+
# Check abort signal on each iteration
|
|
86
|
+
if @abort_signal&.aborted?
|
|
87
|
+
@running = false
|
|
88
|
+
break
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
break unless @running
|
|
92
|
+
|
|
93
|
+
if raw["type"] == "control_request"
|
|
94
|
+
logger.debug("protocol") { "Control request received: #{raw.dig("request", "subtype")}" }
|
|
95
|
+
handle_control_request(raw)
|
|
96
|
+
elsif raw["type"] == "control_response"
|
|
97
|
+
logger.debug("protocol") { "Control response received: #{raw.dig("response", "request_id")}" }
|
|
98
|
+
handle_control_response(raw)
|
|
99
|
+
else
|
|
100
|
+
# SDK message - queue for consumer
|
|
101
|
+
logger.debug("protocol") { "Queued message: #{raw["type"]}" }
|
|
102
|
+
@message_queue.push(raw)
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
rescue IOError, Errno::EPIPE
|
|
106
|
+
logger.debug("protocol") { "Reader thread exiting: transport closed" }
|
|
107
|
+
@running = false
|
|
108
|
+
rescue AbortError
|
|
109
|
+
logger.debug("protocol") { "Reader thread exiting: abort signal" }
|
|
110
|
+
@running = false
|
|
111
|
+
ensure
|
|
112
|
+
@message_queue.push(:done)
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ClaudeAgent
|
|
4
|
+
class ControlProtocol
|
|
5
|
+
# User message sending, iteration, and streaming conversation support.
|
|
6
|
+
module Messaging
|
|
7
|
+
# Send a user message
|
|
8
|
+
# @param content [String, Array] Message content
|
|
9
|
+
# @param session_id [String] Session ID
|
|
10
|
+
# @param uuid [String, nil] Message UUID for file checkpointing
|
|
11
|
+
# @return [void]
|
|
12
|
+
def send_user_message(content, session_id: "default", uuid: nil)
|
|
13
|
+
message = {
|
|
14
|
+
type: "user",
|
|
15
|
+
message: { role: "user", content: content },
|
|
16
|
+
session_id: session_id
|
|
17
|
+
}
|
|
18
|
+
message[:uuid] = uuid if uuid
|
|
19
|
+
write_message(message)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Iterate over incoming messages (SDK messages only, not control)
|
|
23
|
+
# @yield [Message] Parsed message objects
|
|
24
|
+
# @return [Enumerator] If no block given
|
|
25
|
+
# @raise [AbortError] If abort signal is triggered
|
|
26
|
+
def each_message
|
|
27
|
+
return enum_for(:each_message) unless block_given?
|
|
28
|
+
|
|
29
|
+
loop do
|
|
30
|
+
@abort_signal&.check!
|
|
31
|
+
|
|
32
|
+
raw = @message_queue.pop # blocks until data available
|
|
33
|
+
break if raw == :done # sentinel from reader_loop
|
|
34
|
+
|
|
35
|
+
begin
|
|
36
|
+
message = @parser.parse(raw)
|
|
37
|
+
yield message
|
|
38
|
+
rescue AbortError
|
|
39
|
+
raise
|
|
40
|
+
rescue => e
|
|
41
|
+
logger.warn("protocol") { "Message parse error: #{e.message}" }
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Receive messages until a ResultMessage is received
|
|
47
|
+
# @yield [Message] Parsed message objects
|
|
48
|
+
# @return [Enumerator] If no block given
|
|
49
|
+
def receive_response
|
|
50
|
+
return enum_for(:receive_response) unless block_given?
|
|
51
|
+
|
|
52
|
+
each_message do |message|
|
|
53
|
+
yield message
|
|
54
|
+
break if message.is_a?(ResultMessage)
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Stream user input from an enumerable (TypeScript SDK parity)
|
|
59
|
+
#
|
|
60
|
+
# Sends each message from the input stream to Claude. Messages can be:
|
|
61
|
+
# - String: Sent as user message content
|
|
62
|
+
# - Hash: Must have :content key, optionally :session_id and :uuid
|
|
63
|
+
# - UserMessage: Sent directly
|
|
64
|
+
#
|
|
65
|
+
# @param stream [Enumerable] Input stream of messages
|
|
66
|
+
# @param session_id [String] Default session ID for messages
|
|
67
|
+
# @return [void]
|
|
68
|
+
# @raise [AbortError] If abort signal is triggered
|
|
69
|
+
#
|
|
70
|
+
# @example With strings
|
|
71
|
+
# protocol.stream_input(["Hello", "How are you?"])
|
|
72
|
+
#
|
|
73
|
+
# @example With hashes
|
|
74
|
+
# protocol.stream_input([
|
|
75
|
+
# { content: "Hello", uuid: "msg-1" },
|
|
76
|
+
# { content: "Follow up", session_id: "custom" }
|
|
77
|
+
# ])
|
|
78
|
+
#
|
|
79
|
+
def stream_input(stream, session_id: "default")
|
|
80
|
+
stream.each do |message|
|
|
81
|
+
# Check abort signal before each message
|
|
82
|
+
@abort_signal&.check!
|
|
83
|
+
|
|
84
|
+
case message
|
|
85
|
+
when String
|
|
86
|
+
send_user_message(message, session_id: session_id)
|
|
87
|
+
when Hash
|
|
88
|
+
content = message[:content] || message["content"]
|
|
89
|
+
msg_session = message[:session_id] || message["session_id"] || session_id
|
|
90
|
+
uuid = message[:uuid] || message["uuid"]
|
|
91
|
+
send_user_message(content, session_id: msg_session, uuid: uuid)
|
|
92
|
+
when UserMessage, UserMessageReplay
|
|
93
|
+
send_user_message(message.content, session_id: message.session_id || session_id, uuid: message.uuid)
|
|
94
|
+
else
|
|
95
|
+
raise ArgumentError, "Unknown message type in stream: #{message.class}"
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Stream user input and receive responses (TypeScript SDK parity)
|
|
101
|
+
#
|
|
102
|
+
# Sends messages from the input stream in a background thread while
|
|
103
|
+
# yielding responses in the foreground. This enables concurrent input/output.
|
|
104
|
+
#
|
|
105
|
+
# @param stream [Enumerable] Input stream of messages
|
|
106
|
+
# @param session_id [String] Default session ID for messages
|
|
107
|
+
# @yield [Message] Received messages
|
|
108
|
+
# @return [Enumerator] If no block given
|
|
109
|
+
# @raise [AbortError] If abort signal is triggered
|
|
110
|
+
#
|
|
111
|
+
# @example
|
|
112
|
+
# messages = ["Hello", "Tell me more"]
|
|
113
|
+
# protocol.stream_conversation(messages) do |msg|
|
|
114
|
+
# case msg
|
|
115
|
+
# when ClaudeAgent::AssistantMessage
|
|
116
|
+
# puts msg.text
|
|
117
|
+
# when ClaudeAgent::ResultMessage
|
|
118
|
+
# puts "Done!"
|
|
119
|
+
# end
|
|
120
|
+
# end
|
|
121
|
+
#
|
|
122
|
+
def stream_conversation(stream, session_id: "default", &block)
|
|
123
|
+
return enum_for(:stream_conversation, stream, session_id: session_id) unless block_given?
|
|
124
|
+
|
|
125
|
+
# Track errors from the sender thread
|
|
126
|
+
sender_error = nil
|
|
127
|
+
|
|
128
|
+
# Start sender thread
|
|
129
|
+
sender_thread = Thread.new do
|
|
130
|
+
stream_input(stream, session_id: session_id)
|
|
131
|
+
rescue AbortError => e
|
|
132
|
+
sender_error = e
|
|
133
|
+
rescue => e
|
|
134
|
+
sender_error = e
|
|
135
|
+
# Don't re-raise here; let the main thread handle it
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# Yield responses until we get a ResultMessage or error
|
|
139
|
+
begin
|
|
140
|
+
each_message do |message|
|
|
141
|
+
# Check if sender had an error
|
|
142
|
+
if sender_error
|
|
143
|
+
raise sender_error if sender_error.is_a?(AbortError)
|
|
144
|
+
|
|
145
|
+
raise Error, "Stream input error: #{sender_error.message}"
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
yield message
|
|
149
|
+
break if message.is_a?(ResultMessage)
|
|
150
|
+
end
|
|
151
|
+
ensure
|
|
152
|
+
# Wait for sender to finish
|
|
153
|
+
sender_thread.join(1)
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# Check for sender errors after loop
|
|
157
|
+
raise sender_error if sender_error.is_a?(AbortError)
|
|
158
|
+
|
|
159
|
+
raise Error, "Stream input error: #{sender_error.message}" if sender_error
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
end
|