claude_swarm 0.3.11 ā 1.0.1
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/CHANGELOG.md +48 -0
- data/CLAUDE.md +2 -0
- data/README.md +58 -0
- data/lib/claude_swarm/claude_code_executor.rb +10 -5
- data/lib/claude_swarm/cli.rb +11 -13
- data/lib/claude_swarm/commands/ps.rb +8 -8
- data/lib/claude_swarm/commands/show.rb +4 -5
- data/lib/claude_swarm/configuration.rb +2 -2
- data/lib/claude_swarm/json_handler.rb +91 -0
- data/lib/claude_swarm/mcp_generator.rb +4 -4
- data/lib/claude_swarm/openai/chat_completion.rb +6 -6
- data/lib/claude_swarm/openai/executor.rb +1 -1
- data/lib/claude_swarm/openai/responses.rb +8 -8
- data/lib/claude_swarm/orchestrator.rb +70 -46
- data/lib/claude_swarm/session_cost_calculator.rb +6 -6
- data/lib/claude_swarm/session_path.rb +2 -8
- data/lib/claude_swarm/settings_generator.rb +1 -1
- data/lib/claude_swarm/templates/generation_prompt.md.erb +3 -0
- data/lib/claude_swarm/tools/task_tool.rb +13 -1
- data/lib/claude_swarm/version.rb +1 -1
- data/lib/claude_swarm/worktree_manager.rb +2 -2
- data/lib/claude_swarm.rb +22 -2
- data/team_v2.yml +367 -0
- metadata +8 -6
@@ -6,7 +6,6 @@ module ClaudeSwarm
|
|
6
6
|
|
7
7
|
attr_reader :config, :session_path, :session_log_path
|
8
8
|
|
9
|
-
RUN_DIR = File.expand_path("~/.claude-swarm/run")
|
10
9
|
["INT", "TERM", "QUIT"].each do |signal|
|
11
10
|
Signal.trap(signal) do
|
12
11
|
puts "\nš Received #{signal} signal."
|
@@ -201,15 +200,29 @@ module ClaudeSwarm
|
|
201
200
|
main_pid_file = File.join(@session_path, "main_pid")
|
202
201
|
File.write(main_pid_file, Process.pid.to_s)
|
203
202
|
|
204
|
-
# Execute
|
205
|
-
|
206
|
-
|
207
|
-
|
208
|
-
|
209
|
-
|
210
|
-
|
211
|
-
|
203
|
+
# Execute before commands if specified
|
204
|
+
# If the main instance directory exists, run in it for backward compatibility
|
205
|
+
# If it doesn't exist, run in the parent directory so before commands can create it
|
206
|
+
before_commands = @config.before_commands
|
207
|
+
if before_commands.any? && !@restore_session_path
|
208
|
+
non_interactive_output do
|
209
|
+
puts "āļø Executing before commands..."
|
210
|
+
end
|
211
|
+
|
212
|
+
# Determine where to run before commands
|
213
|
+
if File.exist?(main_instance[:directory])
|
214
|
+
# Directory exists, run commands in it (backward compatibility)
|
215
|
+
before_commands_dir = main_instance[:directory]
|
216
|
+
else
|
217
|
+
# Directory doesn't exist, run in parent directory
|
218
|
+
# This allows before commands to create the directory
|
219
|
+
parent_dir = File.dirname(File.expand_path(main_instance[:directory]))
|
220
|
+
# Ensure parent directory exists (important for worktrees)
|
221
|
+
FileUtils.mkdir_p(parent_dir)
|
222
|
+
before_commands_dir = parent_dir
|
223
|
+
end
|
212
224
|
|
225
|
+
Dir.chdir(before_commands_dir) do
|
213
226
|
success = execute_before_commands?(before_commands)
|
214
227
|
unless success
|
215
228
|
non_interactive_output { print("ā Before commands failed. Aborting swarm launch.") }
|
@@ -218,26 +231,29 @@ module ClaudeSwarm
|
|
218
231
|
cleanup_worktrees
|
219
232
|
exit(1)
|
220
233
|
end
|
234
|
+
end
|
221
235
|
|
222
|
-
|
223
|
-
|
224
|
-
|
236
|
+
non_interactive_output do
|
237
|
+
puts "ā Before commands completed successfully"
|
238
|
+
end
|
225
239
|
|
226
|
-
|
227
|
-
|
228
|
-
|
229
|
-
|
230
|
-
|
231
|
-
end
|
232
|
-
rescue ClaudeSwarm::Error => e
|
233
|
-
non_interactive_output { print("ā Directory validation failed: #{e.message}") }
|
234
|
-
cleanup_processes
|
235
|
-
cleanup_run_symlink
|
236
|
-
cleanup_worktrees
|
237
|
-
exit(1)
|
240
|
+
# Validate directories after before commands have run
|
241
|
+
begin
|
242
|
+
@config.validate_directories
|
243
|
+
non_interactive_output do
|
244
|
+
puts "ā All directories validated successfully"
|
238
245
|
end
|
246
|
+
rescue ClaudeSwarm::Error => e
|
247
|
+
non_interactive_output { print("ā Directory validation failed: #{e.message}") }
|
248
|
+
cleanup_processes
|
249
|
+
cleanup_run_symlink
|
250
|
+
cleanup_worktrees
|
251
|
+
exit(1)
|
239
252
|
end
|
253
|
+
end
|
240
254
|
|
255
|
+
# Execute the main instance - this will cascade to other instances via MCP
|
256
|
+
Dir.chdir(main_instance[:directory]) do
|
241
257
|
# Execute main Claude instance with unbundled environment to avoid bundler conflicts
|
242
258
|
# This ensures the main instance runs in a clean environment without inheriting
|
243
259
|
# Claude Swarm's BUNDLE_* environment variables
|
@@ -263,9 +279,20 @@ module ClaudeSwarm
|
|
263
279
|
display_summary
|
264
280
|
|
265
281
|
# Execute after commands if specified
|
282
|
+
# Use the same logic as before commands for consistency
|
266
283
|
after_commands = @config.after_commands
|
267
284
|
if after_commands.any? && !@restore_session_path
|
268
|
-
|
285
|
+
# Determine where to run after commands (same logic as before commands)
|
286
|
+
if File.exist?(main_instance[:directory])
|
287
|
+
# Directory exists, run commands in it
|
288
|
+
after_commands_dir = main_instance[:directory]
|
289
|
+
else
|
290
|
+
# Directory doesn't exist (shouldn't happen after main instance runs, but be safe)
|
291
|
+
parent_dir = File.dirname(File.expand_path(main_instance[:directory]))
|
292
|
+
after_commands_dir = parent_dir
|
293
|
+
end
|
294
|
+
|
295
|
+
Dir.chdir(after_commands_dir) do
|
269
296
|
non_interactive_output do
|
270
297
|
print("āļø Executing after commands...")
|
271
298
|
end
|
@@ -311,7 +338,7 @@ module ClaudeSwarm
|
|
311
338
|
|
312
339
|
# Save session metadata
|
313
340
|
metadata_file = File.join(session_path, "session_metadata.json")
|
314
|
-
|
341
|
+
JsonHandler.write_file!(metadata_file, build_session_metadata)
|
315
342
|
end
|
316
343
|
|
317
344
|
def build_session_metadata
|
@@ -367,11 +394,11 @@ module ClaudeSwarm
|
|
367
394
|
metadata_file = File.join(@session_path, "session_metadata.json")
|
368
395
|
return unless File.exist?(metadata_file)
|
369
396
|
|
370
|
-
metadata =
|
397
|
+
metadata = JsonHandler.parse_file!(metadata_file)
|
371
398
|
metadata["end_time"] = end_time.utc.iso8601
|
372
399
|
metadata["duration_seconds"] = (end_time - @start_time).to_i
|
373
400
|
|
374
|
-
|
401
|
+
JsonHandler.write_file!(metadata_file, metadata)
|
375
402
|
rescue StandardError => e
|
376
403
|
non_interactive_output { print("ā ļø Error updating session metadata: #{e.message}") }
|
377
404
|
end
|
@@ -409,11 +436,12 @@ module ClaudeSwarm
|
|
409
436
|
def create_run_symlink
|
410
437
|
return unless @session_path
|
411
438
|
|
412
|
-
|
439
|
+
run_dir = ClaudeSwarm.joined_run_dir
|
440
|
+
FileUtils.mkdir_p(run_dir)
|
413
441
|
|
414
442
|
# Session ID is the last part of the session path
|
415
443
|
session_id = File.basename(@session_path)
|
416
|
-
symlink_path =
|
444
|
+
symlink_path = ClaudeSwarm.joined_run_dir(session_id)
|
417
445
|
|
418
446
|
# Remove stale symlink if exists
|
419
447
|
File.unlink(symlink_path) if File.symlink?(symlink_path)
|
@@ -429,7 +457,7 @@ module ClaudeSwarm
|
|
429
457
|
return unless @session_path
|
430
458
|
|
431
459
|
session_id = File.basename(@session_path)
|
432
|
-
symlink_path =
|
460
|
+
symlink_path = ClaudeSwarm.joined_run_dir(session_id)
|
433
461
|
File.unlink(symlink_path) if File.symlink?(symlink_path)
|
434
462
|
rescue StandardError
|
435
463
|
# Ignore errors during cleanup
|
@@ -484,7 +512,7 @@ module ClaudeSwarm
|
|
484
512
|
|
485
513
|
# Find the state file for the main instance
|
486
514
|
state_files.each do |state_file|
|
487
|
-
state_data =
|
515
|
+
state_data = JsonHandler.parse_file!(state_file)
|
488
516
|
next unless state_data["instance_name"] == main_instance_name
|
489
517
|
|
490
518
|
claude_session_id = state_data["claude_session_id"]
|
@@ -569,7 +597,7 @@ module ClaudeSwarm
|
|
569
597
|
metadata_file = File.join(session_path, "session_metadata.json")
|
570
598
|
return unless File.exist?(metadata_file)
|
571
599
|
|
572
|
-
metadata =
|
600
|
+
metadata = JsonHandler.parse_file!(metadata_file)
|
573
601
|
worktree_data = metadata["worktree"]
|
574
602
|
return unless worktree_data && worktree_data["enabled"]
|
575
603
|
|
@@ -601,13 +629,11 @@ module ClaudeSwarm
|
|
601
629
|
|
602
630
|
# Read and process the merged output
|
603
631
|
stdout_and_stderr.each_line do |line|
|
604
|
-
|
605
|
-
|
606
|
-
|
607
|
-
|
608
|
-
|
609
|
-
rescue JSON::ParserError
|
610
|
-
logger.info { line.chomp }
|
632
|
+
logger.info do
|
633
|
+
chomped_line = line.chomp
|
634
|
+
json_data = JsonHandler.parse(chomped_line)
|
635
|
+
json_data == chomped_line ? chomped_line : JsonHandler.pretty_generate!(json_data)
|
636
|
+
end
|
611
637
|
end
|
612
638
|
|
613
639
|
wait_thr.value
|
@@ -636,11 +662,11 @@ module ClaudeSwarm
|
|
636
662
|
line = file.gets
|
637
663
|
if line
|
638
664
|
begin
|
639
|
-
# Parse JSONL entry
|
640
|
-
transcript_entry =
|
665
|
+
# Parse JSONL entry, silently skip unparseable lines
|
666
|
+
transcript_entry = JsonHandler.parse(line)
|
641
667
|
|
642
|
-
# Skip
|
643
|
-
next if transcript_entry["type"] == "summary"
|
668
|
+
# Skip if parsing failed or if it's a summary entry
|
669
|
+
next if transcript_entry == line || transcript_entry["type"] == "summary"
|
644
670
|
|
645
671
|
# Convert to session.log.json format
|
646
672
|
session_entry = convert_transcript_to_session_format(transcript_entry)
|
@@ -654,8 +680,6 @@ module ClaudeSwarm
|
|
654
680
|
log_file.puts(session_entry.to_json)
|
655
681
|
end
|
656
682
|
end
|
657
|
-
rescue JSON::ParserError
|
658
|
-
# Silently skip unparseable lines
|
659
683
|
rescue StandardError
|
660
684
|
# Silently handle other errors to keep thread running
|
661
685
|
end
|
@@ -86,7 +86,9 @@ module ClaudeSwarm
|
|
86
86
|
main_instance_cost = 0.0
|
87
87
|
|
88
88
|
File.foreach(session_log_path) do |line|
|
89
|
-
data =
|
89
|
+
data = JsonHandler.parse(line)
|
90
|
+
next if data == line # Skip unparseable lines
|
91
|
+
|
90
92
|
instance_name = data["instance"]
|
91
93
|
instance_id = data["instance_id"]
|
92
94
|
|
@@ -108,8 +110,6 @@ module ClaudeSwarm
|
|
108
110
|
instance_costs[instance_name] += cost
|
109
111
|
end
|
110
112
|
end
|
111
|
-
rescue JSON::ParserError
|
112
|
-
next
|
113
113
|
end
|
114
114
|
|
115
115
|
# Calculate total: sum of all instance costs + main instance token costs
|
@@ -137,7 +137,9 @@ module ClaudeSwarm
|
|
137
137
|
return instances unless File.exist?(session_log_path)
|
138
138
|
|
139
139
|
File.foreach(session_log_path) do |line|
|
140
|
-
data =
|
140
|
+
data = JsonHandler.parse(line)
|
141
|
+
next if data == line # Skip unparseable lines
|
142
|
+
|
141
143
|
instance_name = data["instance"]
|
142
144
|
instance_id = data["instance_id"]
|
143
145
|
calling_instance = data["calling_instance"]
|
@@ -191,8 +193,6 @@ module ClaudeSwarm
|
|
191
193
|
instances[instance_name][:has_cost_data] = true
|
192
194
|
end
|
193
195
|
end
|
194
|
-
rescue JSON::ParserError
|
195
|
-
next
|
196
196
|
end
|
197
197
|
|
198
198
|
# Set main instance costs (replace, don't add)
|
@@ -2,13 +2,7 @@
|
|
2
2
|
|
3
3
|
module ClaudeSwarm
|
4
4
|
module SessionPath
|
5
|
-
SESSIONS_DIR = "sessions"
|
6
|
-
|
7
5
|
class << self
|
8
|
-
def swarm_home
|
9
|
-
ENV["CLAUDE_SWARM_HOME"] || File.expand_path("~/.claude-swarm")
|
10
|
-
end
|
11
|
-
|
12
6
|
# Convert a directory path to a safe folder name using + as separator
|
13
7
|
def project_folder_name(working_dir = Dir.pwd)
|
14
8
|
# Don't expand path if it's already expanded (avoids double expansion on Windows)
|
@@ -27,7 +21,7 @@ module ClaudeSwarm
|
|
27
21
|
# Generate a full session path for a given directory and session ID
|
28
22
|
def generate(working_dir: Dir.pwd, session_id: SecureRandom.uuid)
|
29
23
|
project_name = project_folder_name(working_dir)
|
30
|
-
|
24
|
+
ClaudeSwarm.joined_sessions_dir(project_name, session_id)
|
31
25
|
end
|
32
26
|
|
33
27
|
# Ensure the session directory exists
|
@@ -35,7 +29,7 @@ module ClaudeSwarm
|
|
35
29
|
FileUtils.mkdir_p(session_path)
|
36
30
|
|
37
31
|
# Add .gitignore to swarm home if it doesn't exist
|
38
|
-
gitignore_path =
|
32
|
+
gitignore_path = ClaudeSwarm.joined_home_dir(".gitignore")
|
39
33
|
File.write(gitignore_path, "*\n") unless File.exist?(gitignore_path)
|
40
34
|
end
|
41
35
|
|
@@ -225,3 +225,6 @@ The more precisely you explain what you want, the better Claude's response will
|
|
225
225
|
* **Provide instructions as sequential steps:** Use numbered lists or bullet points to better ensure that Claude carries out the task the exact way you want it to.
|
226
226
|
|
227
227
|
</prompt_best_practices>
|
228
|
+
|
229
|
+
|
230
|
+
IMPORTANT: Do not generate swarms with circular dependencies. For example, instance A connections to instance B, and instance B connections to instance A.
|
@@ -43,8 +43,20 @@ module ClaudeSwarm
|
|
43
43
|
|
44
44
|
response = executor.execute(final_prompt, options)
|
45
45
|
|
46
|
+
# Validate the response has a result
|
47
|
+
unless response.is_a?(Hash) && response.key?("result")
|
48
|
+
raise "Invalid response from executor: missing 'result' field. Response structure: #{response.keys.join(", ")}"
|
49
|
+
end
|
50
|
+
|
51
|
+
result = response["result"]
|
52
|
+
|
53
|
+
# Validate the result is not empty
|
54
|
+
if result.nil? || (result.is_a?(String) && result.strip.empty?)
|
55
|
+
raise "Agent #{instance_config[:name]} returned an empty response. The task was executed but no content was provided."
|
56
|
+
end
|
57
|
+
|
46
58
|
# Return just the result text as expected by MCP
|
47
|
-
|
59
|
+
result
|
48
60
|
end
|
49
61
|
end
|
50
62
|
end
|
data/lib/claude_swarm/version.rb
CHANGED
@@ -158,7 +158,7 @@ module ClaudeSwarm
|
|
158
158
|
# Remove session-specific worktree directory if it exists and is empty
|
159
159
|
return unless @session_id
|
160
160
|
|
161
|
-
session_worktree_dir =
|
161
|
+
session_worktree_dir = ClaudeSwarm.joined_worktrees_dir(@session_id)
|
162
162
|
return unless File.exist?(session_worktree_dir)
|
163
163
|
|
164
164
|
# Try to remove the directory tree
|
@@ -214,7 +214,7 @@ module ClaudeSwarm
|
|
214
214
|
unique_repo_name = "#{repo_name}-#{path_hash}"
|
215
215
|
|
216
216
|
# Build external path: ~/.claude-swarm/worktrees/[session_id]/[repo_name-hash]/[worktree_name]
|
217
|
-
base_dir =
|
217
|
+
base_dir = ClaudeSwarm.joined_worktrees_dir
|
218
218
|
|
219
219
|
# Validate base directory is accessible
|
220
220
|
begin
|
data/lib/claude_swarm.rb
CHANGED
@@ -22,7 +22,7 @@ require "yaml"
|
|
22
22
|
|
23
23
|
# External dependencies
|
24
24
|
require "claude_sdk"
|
25
|
-
require "
|
25
|
+
require "fast_mcp"
|
26
26
|
require "mcp_client"
|
27
27
|
require "thor"
|
28
28
|
|
@@ -41,7 +41,27 @@ module ClaudeSwarm
|
|
41
41
|
|
42
42
|
class << self
|
43
43
|
def root_dir
|
44
|
-
ENV.fetch("CLAUDE_SWARM_ROOT_DIR"
|
44
|
+
ENV.fetch("CLAUDE_SWARM_ROOT_DIR") { Dir.pwd }
|
45
|
+
end
|
46
|
+
|
47
|
+
def home_dir
|
48
|
+
ENV.fetch("CLAUDE_SWARM_HOME") { File.expand_path("~/.claude-swarm") }
|
49
|
+
end
|
50
|
+
|
51
|
+
def joined_home_dir(*strings)
|
52
|
+
File.join(home_dir, *strings)
|
53
|
+
end
|
54
|
+
|
55
|
+
def joined_run_dir(*strings)
|
56
|
+
joined_home_dir("run", *strings)
|
57
|
+
end
|
58
|
+
|
59
|
+
def joined_sessions_dir(*strings)
|
60
|
+
joined_home_dir("sessions", *strings)
|
61
|
+
end
|
62
|
+
|
63
|
+
def joined_worktrees_dir(*strings)
|
64
|
+
joined_home_dir("worktrees", *strings)
|
45
65
|
end
|
46
66
|
end
|
47
67
|
end
|