claude_swarm 0.3.2 → 1.0.0

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.
@@ -1,102 +1,84 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ClaudeSwarm
4
- class ClaudeCodeExecutor
5
- attr_reader :session_id, :last_response, :working_directory, :logger, :session_path
6
-
7
- def initialize(working_directory: Dir.pwd, model: nil, mcp_config: nil, vibe: false,
8
- instance_name: nil, instance_id: nil, calling_instance: nil, calling_instance_id: nil,
9
- claude_session_id: nil, additional_directories: [])
10
- @working_directory = working_directory
11
- @additional_directories = additional_directories
12
- @model = model
13
- @mcp_config = mcp_config
14
- @vibe = vibe
15
- @session_id = claude_session_id
16
- @last_response = nil
17
- @instance_name = instance_name
18
- @instance_id = instance_id
19
- @calling_instance = calling_instance
20
- @calling_instance_id = calling_instance_id
21
-
22
- # Setup logging
23
- setup_logging
24
- end
25
-
4
+ class ClaudeCodeExecutor < BaseExecutor
26
5
  def execute(prompt, options = {})
27
6
  # Log the request
28
7
  log_request(prompt)
29
8
 
30
- cmd_array = build_command_array(prompt, options)
9
+ # Build SDK options
10
+ sdk_options = build_sdk_options(prompt, options)
31
11
 
32
12
  # Variables to collect output
33
- stderr_output = []
13
+ all_messages = []
34
14
  result_response = nil
35
15
 
36
- # Execute command with unbundled environment to avoid bundler conflicts
37
- # This ensures claude runs in a clean environment without inheriting
38
- # Claude Swarm's BUNDLE_* environment variables
39
- Bundler.with_unbundled_env do
40
- # Execute command with streaming
41
- Open3.popen3(*cmd_array, chdir: @working_directory) do |stdin, stdout, stderr, wait_thread|
42
- stdin.close
43
-
44
- # Read stderr in a separate thread
45
- stderr_thread = Thread.new do
46
- stderr.each_line { |line| stderr_output << line }
47
- end
16
+ # Execute with streaming
17
+ begin
18
+ ClaudeSDK.query(prompt, options: sdk_options) do |message|
19
+ # Convert message to hash for logging
20
+ message_hash = message_to_hash(message)
21
+ all_messages << message_hash
48
22
 
49
- # Process stdout line by line
50
- stdout.each_line do |line|
51
- json_data = JSON.parse(line.strip)
52
-
53
- # Log each JSON event
54
- log_streaming_event(json_data)
23
+ # Log streaming event BEFORE we modify anything
24
+ log_streaming_event(message_hash)
55
25
 
26
+ # Process specific message types
27
+ case message
28
+ when ClaudeSDK::Messages::System
56
29
  # Capture session_id from system init
57
- if json_data["type"] == "system" && json_data["subtype"] == "init"
58
- @session_id = json_data["session_id"]
59
- write_instance_state
30
+ if message.subtype == "init" && message.data.is_a?(Hash)
31
+ # For init messages, session_id is in the data hash
32
+ session_id = message.data[:session_id] || message.data["session_id"]
33
+
34
+ if session_id
35
+ @session_id = session_id
36
+ write_instance_state
37
+ end
38
+ end
39
+ when ClaudeSDK::Messages::Assistant
40
+ # Assistant messages only contain content blocks
41
+ # No need to track for result extraction - result comes from Result message
42
+ when ClaudeSDK::Messages::Result
43
+ # Validate that we have actual result content
44
+ if message.result.nil? || (message.result.is_a?(String) && message.result.strip.empty?)
45
+ raise ExecutionError, "Claude SDK returned an empty result. The agent completed execution but provided no response content."
60
46
  end
61
47
 
62
- # Capture the final result
63
- result_response = json_data if json_data["type"] == "result"
64
- rescue JSON::ParserError => e
65
- @logger.warn("Failed to parse JSON line: #{line.strip} - #{e.message}")
66
- end
67
-
68
- # Wait for stderr thread to finish
69
- stderr_thread.join
70
-
71
- # Check exit status
72
- exit_status = wait_thread.value
73
- unless exit_status.success?
74
- error_msg = stderr_output.join
75
- @logger.error("Execution error for #{@instance_name}: #{error_msg}")
76
- raise ExecutionError, "Claude Code execution failed: #{error_msg}"
48
+ # Build result response in expected format
49
+ result_response = {
50
+ "type" => "result",
51
+ "subtype" => message.subtype || "success",
52
+ "cost_usd" => message.total_cost_usd,
53
+ "is_error" => message.is_error || false,
54
+ "duration_ms" => message.duration_ms,
55
+ "result" => message.result, # Result text is directly in message.result
56
+ "total_cost" => message.total_cost_usd,
57
+ "session_id" => message.session_id,
58
+ }
77
59
  end
78
60
  end
61
+ rescue StandardError => e
62
+ logger.error { "Execution error for #{@instance_name}: #{e.class} - #{e.message}" }
63
+ logger.error { "Backtrace: #{e.backtrace.join("\n")}" }
64
+ raise ExecutionError, "Claude Code execution failed: #{e.message}"
79
65
  end
80
66
 
81
67
  # Ensure we got a result
82
- raise ParseError, "No result found in stream output" unless result_response
68
+ raise ParseError, "No result found in SDK response" unless result_response
69
+
70
+ # Write session JSON log
71
+ all_messages.each do |msg|
72
+ append_to_session_json(msg)
73
+ end
83
74
 
84
75
  result_response
85
76
  rescue StandardError => e
86
- @logger.error("Unexpected error for #{@instance_name}: #{e.class} - #{e.message}")
87
- @logger.error("Backtrace: #{e.backtrace.join("\n")}")
77
+ logger.error { "Unexpected error for #{@instance_name}: #{e.class} - #{e.message}" }
78
+ logger.error { "Backtrace: #{e.backtrace.join("\n")}" }
88
79
  raise
89
80
  end
90
81
 
91
- def reset_session
92
- @session_id = nil
93
- @last_response = nil
94
- end
95
-
96
- def has_session?
97
- !@session_id.nil?
98
- end
99
-
100
82
  private
101
83
 
102
84
  def write_instance_state
@@ -114,64 +96,10 @@ module ClaudeSwarm
114
96
  updated_at: Time.now.iso8601,
115
97
  }
116
98
 
117
- File.write(state_file, JSON.pretty_generate(state_data))
118
- @logger.info("Wrote instance state for #{@instance_name} (#{@instance_id}) with session ID: #{@session_id}")
99
+ JsonHandler.write_file!(state_file, state_data)
100
+ logger.info { "Wrote instance state for #{@instance_name} (#{@instance_id}) with session ID: #{@session_id}" }
119
101
  rescue StandardError => e
120
- @logger.error("Failed to write instance state for #{@instance_name} (#{@instance_id}): #{e.message}")
121
- end
122
-
123
- def setup_logging
124
- # Use session path from environment (required)
125
- @session_path = SessionPath.from_env
126
- SessionPath.ensure_directory(@session_path)
127
-
128
- # Create logger with session.log filename
129
- log_filename = "session.log"
130
- log_path = File.join(@session_path, log_filename)
131
- @logger = Logger.new(log_path)
132
- @logger.level = Logger::INFO
133
-
134
- # Custom formatter for better readability
135
- @logger.formatter = proc do |severity, datetime, _progname, msg|
136
- "[#{datetime.strftime("%Y-%m-%d %H:%M:%S.%L")}] [#{severity}] #{msg}\n"
137
- end
138
-
139
- return unless @instance_name
140
-
141
- instance_info = @instance_name
142
- instance_info += " (#{@instance_id})" if @instance_id
143
- @logger.info("Started Claude Code executor for instance: #{instance_info}")
144
- end
145
-
146
- def log_request(prompt)
147
- caller_info = @calling_instance
148
- caller_info += " (#{@calling_instance_id})" if @calling_instance_id
149
- instance_info = @instance_name
150
- instance_info += " (#{@instance_id})" if @instance_id
151
- @logger.info("#{caller_info} -> #{instance_info}: \n---\n#{prompt}\n---")
152
-
153
- # Build event hash for JSON logging
154
- event = {
155
- type: "request",
156
- from_instance: @calling_instance,
157
- from_instance_id: @calling_instance_id,
158
- to_instance: @instance_name,
159
- to_instance_id: @instance_id,
160
- prompt: prompt,
161
- timestamp: Time.now.iso8601,
162
- }
163
-
164
- append_to_session_json(event)
165
- end
166
-
167
- def log_response(response)
168
- caller_info = @calling_instance
169
- caller_info += " (#{@calling_instance_id})" if @calling_instance_id
170
- instance_info = @instance_name
171
- instance_info += " (#{@instance_id})" if @instance_id
172
- @logger.info(
173
- "($#{response["total_cost"]} - #{response["duration_ms"]}ms) #{instance_info} -> #{caller_info}: \n---\n#{response["result"]}\n---",
174
- )
102
+ logger.error { "Failed to write instance state for #{@instance_name} (#{@instance_id}): #{e.message}" }
175
103
  end
176
104
 
177
105
  def log_streaming_event(event)
@@ -191,124 +119,231 @@ module ClaudeSwarm
191
119
  end
192
120
 
193
121
  def log_system_message(event)
194
- @logger.debug("SYSTEM: #{JSON.pretty_generate(event)}")
122
+ logger.debug { "SYSTEM: #{JsonHandler.pretty_generate!(event)}" }
195
123
  end
196
124
 
197
125
  def log_assistant_message(msg)
198
- return if msg["stop_reason"] == "end_turn" # that means it is not a thought but the final answer
199
-
126
+ # Assistant messages don't have stop_reason in SDK - they only have content
200
127
  content = msg["content"]
201
- @logger.debug("ASSISTANT: #{JSON.pretty_generate(content)}")
202
- tool_calls = content.select { |c| c["type"] == "tool_use" }
128
+ logger.debug { "ASSISTANT: #{JsonHandler.pretty_generate!(content)}" } if content
129
+
130
+ # Log tool calls
131
+ tool_calls = content&.select { |c| c["type"] == "tool_use" } || []
203
132
  tool_calls.each do |tool_call|
204
133
  arguments = tool_call["input"].to_json
205
134
  arguments = "#{arguments[0..300]} ...}" if arguments.length > 300
206
135
 
207
- instance_info = @instance_name
208
- instance_info += " (#{@instance_id})" if @instance_id
209
- @logger.info(
210
- "Tool call from #{instance_info} -> Tool: #{tool_call["name"]}, ID: #{tool_call["id"]}, Arguments: #{arguments}",
211
- )
136
+ logger.info do
137
+ "Tool call from #{instance_info} -> Tool: #{tool_call["name"]}, ID: #{tool_call["id"]}, Arguments: #{arguments}"
138
+ end
212
139
  end
213
140
 
214
- text = content.select { |c| c["type"] == "text" }
141
+ # Log thinking text
142
+ text = content&.select { |c| c["type"] == "text" } || []
215
143
  text.each do |t|
216
- instance_info = @instance_name
217
- instance_info += " (#{@instance_id})" if @instance_id
218
- @logger.info("#{instance_info} is thinking:\n---\n#{t["text"]}\n---")
144
+ logger.info { "#{instance_info} is thinking:\n---\n#{t["text"]}\n---" }
219
145
  end
220
146
  end
221
147
 
222
148
  def log_user_message(content)
223
- @logger.debug("USER: #{JSON.pretty_generate(content)}")
149
+ logger.debug { "USER: #{JsonHandler.pretty_generate!(content)}" }
224
150
  end
225
151
 
226
- def append_to_session_json(event)
227
- json_filename = "session.log.json"
228
- json_path = File.join(@session_path, json_filename)
229
-
230
- # Use file locking to ensure thread-safe writes
231
- File.open(json_path, File::WRONLY | File::APPEND | File::CREAT) do |file|
232
- file.flock(File::LOCK_EX)
233
-
234
- # Create entry with metadata
235
- entry = {
236
- instance: @instance_name,
237
- instance_id: @instance_id,
238
- calling_instance: @calling_instance,
239
- calling_instance_id: @calling_instance_id,
240
- timestamp: Time.now.iso8601,
241
- event: event,
242
- }
152
+ def build_sdk_options(prompt, options)
153
+ # Map CLI options to SDK options
154
+ sdk_options = ClaudeSDK::ClaudeCodeOptions.new
243
155
 
244
- # Write as single line JSON (JSONL format)
245
- file.puts(entry.to_json)
156
+ # Basic options
157
+ # Only set model if ANTHROPIC_MODEL env var is not set
158
+ sdk_options.model = @model if @model && !ENV["ANTHROPIC_MODEL"]
159
+ sdk_options.cwd = @working_directory
160
+ sdk_options.resume = @session_id if @session_id && !options[:new_session]
246
161
 
247
- file.flock(File::LOCK_UN)
248
- end
249
- rescue StandardError => e
250
- @logger.error("Failed to append to session JSON: #{e.message}")
251
- raise
252
- end
162
+ # Permission mode
163
+ if @vibe
164
+ sdk_options.permission_mode = ClaudeSDK::PermissionMode::BYPASS_PERMISSIONS
165
+ else
166
+ # Build allowed tools list including MCP connections
167
+ allowed_tools = options[:allowed_tools] ? Array(options[:allowed_tools]).dup : []
253
168
 
254
- def build_command_array(prompt, options)
255
- cmd_array = ["claude"]
169
+ # Add mcp__instance_name for each connection if we have instance info
170
+ options[:connections]&.each do |connection_name|
171
+ allowed_tools << "mcp__#{connection_name}"
172
+ end
256
173
 
257
- # Add model if specified
258
- cmd_array += ["--model", @model]
174
+ # Set allowed and disallowed tools
175
+ sdk_options.allowed_tools = allowed_tools if allowed_tools.any?
176
+ sdk_options.disallowed_tools = Array(options[:disallowed_tools]) if options[:disallowed_tools]
177
+ end
259
178
 
260
- cmd_array << "--verbose"
179
+ # System prompt
180
+ sdk_options.append_system_prompt = options[:system_prompt] if options[:system_prompt]
261
181
 
262
- # Add additional directories with --add-dir
263
- cmd_array << "--add-dir" if @additional_directories.any?
264
- @additional_directories.each do |additional_dir|
265
- cmd_array << additional_dir
182
+ # MCP configuration
183
+ if @mcp_config
184
+ sdk_options.mcp_servers = parse_mcp_config(@mcp_config)
266
185
  end
267
186
 
268
- # Add MCP config if specified
269
- cmd_array += ["--mcp-config", @mcp_config] if @mcp_config
187
+ # Handle additional directories by adding them to MCP servers
188
+ if @additional_directories.any?
189
+ setup_additional_directories_mcp(sdk_options)
190
+ end
270
191
 
271
- # Resume session if we have a session ID
272
- cmd_array += ["--resume", @session_id] if @session_id && !options[:new_session]
192
+ # Add settings file path if it exists
193
+ settings_file = File.join(@session_path, "#{@instance_name}_settings.json")
194
+ sdk_options.settings = settings_file if File.exist?(settings_file)
273
195
 
274
- # Always use JSON output format for structured responses
275
- cmd_array += ["--output-format", "stream-json"]
196
+ sdk_options
197
+ end
276
198
 
277
- # Add non-interactive mode with prompt
278
- cmd_array += ["--print", "-p", prompt]
199
+ def parse_mcp_config(config_path)
200
+ # Parse MCP JSON config file and convert to SDK format
201
+ config = JsonHandler.parse_file!(config_path)
202
+ mcp_servers = {}
203
+
204
+ config["mcpServers"]&.each do |name, server_config|
205
+ server_type = server_config["type"] || "stdio"
206
+
207
+ mcp_servers[name] = case server_type
208
+ when "stdio"
209
+ ClaudeSDK::McpServerConfig::StdioServer.new(
210
+ command: server_config["command"],
211
+ args: server_config["args"] || [],
212
+ env: server_config["env"] || {},
213
+ )
214
+ when "sse"
215
+ ClaudeSDK::McpServerConfig::SSEServer.new(
216
+ url: server_config["url"],
217
+ headers: server_config["headers"] || {},
218
+ )
219
+ when "http"
220
+ ClaudeSDK::McpServerConfig::HttpServer.new(
221
+ url: server_config["url"],
222
+ headers: server_config["headers"] || {},
223
+ )
224
+ else
225
+ logger.warn { "Unsupported MCP server type: #{server_type} for server: #{name}" }
226
+ nil
227
+ end
228
+ end
279
229
 
280
- # Add any custom system prompt
281
- cmd_array += ["--append-system-prompt", options[:system_prompt]] if options[:system_prompt]
230
+ mcp_servers.compact
231
+ rescue StandardError => e
232
+ logger.error { "Failed to parse MCP config: #{e.message}" }
233
+ {}
234
+ end
282
235
 
283
- # Add any allowed tools or vibe flag
284
- if @vibe
285
- cmd_array << "--dangerously-skip-permissions"
286
- else
287
- # Build allowed tools list including MCP connections
288
- allowed_tools = options[:allowed_tools] ? Array(options[:allowed_tools]).dup : []
236
+ def setup_additional_directories_mcp(sdk_options)
237
+ # Workaround for --add-dir: add file system MCP servers for additional directories
238
+ sdk_options.mcp_servers ||= {}
289
239
 
290
- # Add mcp__instance_name for each connection if we have instance info
291
- options[:connections]&.each do |connection_name|
292
- allowed_tools << "mcp__#{connection_name}"
293
- end
240
+ @additional_directories.each do |dir|
241
+ # This is a placeholder - the SDK doesn't directly support file system servers
242
+ # You would need to implement a proper MCP server that provides file access
243
+ logger.warn { "Additional directories not fully supported: #{dir}" }
244
+ end
245
+ end
246
+
247
+ def message_to_hash(message)
248
+ # Convert SDK message objects to hash format matching CLI JSON output
249
+ case message
250
+ when ClaudeSDK::Messages::System
251
+ # System messages have subtype and data attributes
252
+ # The data hash contains the actual information from the CLI
253
+ hash = {
254
+ "type" => "system",
255
+ "subtype" => message.subtype,
256
+ }
294
257
 
295
- # Add allowed tools if any
296
- if allowed_tools.any?
297
- tools_str = allowed_tools.join(",")
298
- cmd_array += ["--allowedTools", tools_str]
258
+ # Include the data hash if it exists - this is where CLI puts info like session_id, tools, etc.
259
+ if message.data.is_a?(Hash)
260
+ # For "init" subtype, extract session_id and tools from data
261
+ if message.subtype == "init"
262
+ hash["session_id"] = message.data[:session_id] || message.data["session_id"]
263
+ hash["tools"] = message.data[:tools] || message.data["tools"]
264
+ end
265
+ # You can add other relevant data fields as needed
299
266
  end
300
267
 
301
- # Add disallowed tools if any
302
- if options[:disallowed_tools]
303
- disallowed_tools = Array(options[:disallowed_tools]).join(",")
304
- cmd_array += ["--disallowedTools", disallowed_tools]
268
+ hash.compact
269
+ when ClaudeSDK::Messages::Assistant
270
+ # Assistant messages only have content attribute
271
+ {
272
+ "type" => "assistant",
273
+ "message" => {
274
+ "type" => "message",
275
+ "role" => "assistant",
276
+ "content" => content_blocks_to_hash(message.content),
277
+ },
278
+ "session_id" => @session_id,
279
+ }
280
+ when ClaudeSDK::Messages::User
281
+ # User messages only have content attribute (a string)
282
+ {
283
+ "type" => "user",
284
+ "message" => {
285
+ "type" => "message",
286
+ "role" => "user",
287
+ "content" => message.content,
288
+ },
289
+ "session_id" => @session_id,
290
+ }
291
+ when ClaudeSDK::Messages::Result
292
+ # Result messages have multiple attributes
293
+ {
294
+ "type" => "result",
295
+ "subtype" => message.subtype || "success",
296
+ "cost_usd" => message.total_cost_usd,
297
+ "is_error" => message.is_error || false,
298
+ "duration_ms" => message.duration_ms,
299
+ "duration_api_ms" => message.duration_api_ms,
300
+ "num_turns" => message.num_turns,
301
+ "result" => message.result, # Result text is in message.result, not from content
302
+ "total_cost" => message.total_cost_usd,
303
+ "total_cost_usd" => message.total_cost_usd,
304
+ "session_id" => message.session_id,
305
+ "usage" => message.usage,
306
+ }.compact
307
+ else
308
+ # Fallback for unknown message types
309
+ begin
310
+ message.to_h
311
+ rescue
312
+ { "type" => "unknown", "data" => message.to_s }
305
313
  end
306
314
  end
307
-
308
- cmd_array
309
315
  end
310
316
 
311
- class ExecutionError < StandardError; end
312
- class ParseError < StandardError; end
317
+ def content_blocks_to_hash(content)
318
+ return [] unless content
319
+
320
+ content.map do |block|
321
+ case block
322
+ when ClaudeSDK::ContentBlock::Text
323
+ { "type" => "text", "text" => block.text }
324
+ when ClaudeSDK::ContentBlock::ToolUse
325
+ {
326
+ "type" => "tool_use",
327
+ "id" => block.id,
328
+ "name" => block.name,
329
+ "input" => block.input,
330
+ }
331
+ when ClaudeSDK::ContentBlock::ToolResult
332
+ {
333
+ "type" => "tool_result",
334
+ "tool_use_id" => block.tool_use_id,
335
+ "content" => block.content,
336
+ "is_error" => block.is_error,
337
+ }
338
+ else
339
+ # Fallback
340
+ begin
341
+ block.to_h
342
+ rescue
343
+ { "type" => "unknown", "data" => block.to_s }
344
+ end
345
+ end
346
+ end
347
+ end
313
348
  end
314
349
  end
@@ -7,7 +7,7 @@ module ClaudeSwarm
7
7
  attr_accessor :executor, :instance_config, :logger, :session_path, :calling_instance, :calling_instance_id
8
8
  end
9
9
 
10
- def initialize(instance_config, calling_instance:, calling_instance_id: nil)
10
+ def initialize(instance_config, calling_instance:, calling_instance_id: nil, debug: false)
11
11
  @instance_config = instance_config
12
12
  @calling_instance = calling_instance
13
13
  @calling_instance_id = calling_instance_id
@@ -24,6 +24,7 @@ module ClaudeSwarm
24
24
  calling_instance_id: calling_instance_id,
25
25
  claude_session_id: instance_config[:claude_session_id],
26
26
  additional_directories: instance_config[:directories][1..] || [],
27
+ debug: debug,
27
28
  }
28
29
 
29
30
  @executor = if instance_config[:provider] == "openai"
@@ -37,7 +38,7 @@ module ClaudeSwarm
37
38
  reasoning_effort: instance_config[:reasoning_effort],
38
39
  )
39
40
  else
40
- # Default Claude behavior (existing code)
41
+ # Default Claude behavior - always use SDK
41
42
  ClaudeCodeExecutor.new(**common_params)
42
43
  end
43
44