claude_swarm 0.1.20 → 0.2.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.
Files changed (39) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +9 -66
  3. data/.rubocop_todo.yml +11 -0
  4. data/CHANGELOG.md +106 -0
  5. data/CLAUDE.md +61 -0
  6. data/README.md +174 -16
  7. data/Rakefile +1 -1
  8. data/examples/mixed-provider-swarm.yml +23 -0
  9. data/lib/claude_swarm/claude_code_executor.rb +7 -12
  10. data/lib/claude_swarm/claude_mcp_server.rb +26 -12
  11. data/lib/claude_swarm/cli.rb +293 -165
  12. data/lib/claude_swarm/commands/ps.rb +22 -24
  13. data/lib/claude_swarm/commands/show.rb +45 -63
  14. data/lib/claude_swarm/configuration.rb +161 -8
  15. data/lib/claude_swarm/mcp_generator.rb +39 -14
  16. data/lib/claude_swarm/openai/chat_completion.rb +264 -0
  17. data/lib/claude_swarm/openai/executor.rb +301 -0
  18. data/lib/claude_swarm/openai/responses.rb +338 -0
  19. data/lib/claude_swarm/orchestrator.rb +205 -39
  20. data/lib/claude_swarm/process_tracker.rb +7 -7
  21. data/lib/claude_swarm/session_cost_calculator.rb +93 -0
  22. data/lib/claude_swarm/session_path.rb +3 -5
  23. data/lib/claude_swarm/system_utils.rb +1 -3
  24. data/lib/claude_swarm/tools/reset_session_tool.rb +24 -0
  25. data/lib/claude_swarm/tools/session_info_tool.rb +24 -0
  26. data/lib/claude_swarm/tools/task_tool.rb +43 -0
  27. data/lib/claude_swarm/version.rb +1 -1
  28. data/lib/claude_swarm/worktree_manager.rb +39 -22
  29. data/lib/claude_swarm.rb +23 -10
  30. data/single.yml +481 -6
  31. metadata +54 -14
  32. data/claude-swarm.yml +0 -64
  33. data/lib/claude_swarm/reset_session_tool.rb +0 -22
  34. data/lib/claude_swarm/session_info_tool.rb +0 -22
  35. data/lib/claude_swarm/task_tool.rb +0 -39
  36. /data/{example → examples}/claude-swarm.yml +0 -0
  37. /data/{example → examples}/microservices-team.yml +0 -0
  38. /data/{example → examples}/session-restoration-demo.yml +0 -0
  39. /data/{example → examples}/test-generation.yml +0 -0
@@ -1,7 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "fileutils"
4
-
5
3
  module ClaudeSwarm
6
4
  class ProcessTracker
7
5
  PIDS_DIR = "pids"
@@ -39,7 +37,7 @@ module ClaudeSwarm
39
37
  puts "✓ Terminated MCP server: #{name} (PID: #{pid})"
40
38
 
41
39
  # Give it a moment to terminate gracefully
42
- sleep 0.1
40
+ sleep(0.1)
43
41
 
44
42
  # Force kill if still running
45
43
  begin
@@ -62,11 +60,13 @@ module ClaudeSwarm
62
60
  FileUtils.rm_rf(@pids_dir)
63
61
  end
64
62
 
65
- def self.cleanup_session(session_path)
66
- return unless Dir.exist?(File.join(session_path, PIDS_DIR))
63
+ class << self
64
+ def cleanup_session(session_path)
65
+ return unless Dir.exist?(File.join(session_path, PIDS_DIR))
67
66
 
68
- tracker = new(session_path)
69
- tracker.cleanup_all
67
+ tracker = new(session_path)
68
+ tracker.cleanup_all
69
+ end
70
70
  end
71
71
 
72
72
  private
@@ -0,0 +1,93 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ClaudeSwarm
4
+ module SessionCostCalculator
5
+ extend self
6
+
7
+ # Calculate total cost from session log file
8
+ # Returns a hash with:
9
+ # - total_cost: Total cost in USD
10
+ # - instances_with_cost: Set of instance names that have cost data
11
+ def calculate_total_cost(session_log_path)
12
+ return { total_cost: 0.0, instances_with_cost: Set.new } unless File.exist?(session_log_path)
13
+
14
+ total_cost = 0.0
15
+ instances_with_cost = Set.new
16
+
17
+ File.foreach(session_log_path) do |line|
18
+ data = JSON.parse(line)
19
+ if data.dig("event", "type") == "result" && (cost = data.dig("event", "total_cost_usd"))
20
+ total_cost += cost
21
+ instances_with_cost << data["instance"]
22
+ end
23
+ rescue JSON::ParserError
24
+ next
25
+ end
26
+
27
+ {
28
+ total_cost: total_cost,
29
+ instances_with_cost: instances_with_cost,
30
+ }
31
+ end
32
+
33
+ # Calculate simple total cost (for backward compatibility)
34
+ def calculate_simple_total(session_log_path)
35
+ calculate_total_cost(session_log_path)[:total_cost]
36
+ end
37
+
38
+ # Parse instance hierarchy with costs from session log
39
+ # Returns a hash of instances with their cost data and relationships
40
+ def parse_instance_hierarchy(session_log_path)
41
+ instances = {}
42
+
43
+ return instances unless File.exist?(session_log_path)
44
+
45
+ File.foreach(session_log_path) do |line|
46
+ data = JSON.parse(line)
47
+ instance_name = data["instance"]
48
+ instance_id = data["instance_id"]
49
+ calling_instance = data["calling_instance"]
50
+
51
+ # Initialize instance data
52
+ instances[instance_name] ||= {
53
+ name: instance_name,
54
+ id: instance_id,
55
+ cost: 0.0,
56
+ calls: 0,
57
+ called_by: Set.new,
58
+ calls_to: Set.new,
59
+ has_cost_data: false,
60
+ }
61
+
62
+ # Track relationships
63
+ if calling_instance && calling_instance != instance_name
64
+ instances[instance_name][:called_by] << calling_instance
65
+
66
+ instances[calling_instance] ||= {
67
+ name: calling_instance,
68
+ id: data["calling_instance_id"],
69
+ cost: 0.0,
70
+ calls: 0,
71
+ called_by: Set.new,
72
+ calls_to: Set.new,
73
+ has_cost_data: false,
74
+ }
75
+ instances[calling_instance][:calls_to] << instance_name
76
+ end
77
+
78
+ # Track costs and calls
79
+ if data.dig("event", "type") == "result"
80
+ instances[instance_name][:calls] += 1
81
+ if (cost = data.dig("event", "total_cost_usd"))
82
+ instances[instance_name][:cost] += cost
83
+ instances[instance_name][:has_cost_data] = true
84
+ end
85
+ end
86
+ rescue JSON::ParserError
87
+ next
88
+ end
89
+
90
+ instances
91
+ end
92
+ end
93
+ end
@@ -1,7 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "fileutils"
4
-
5
3
  module ClaudeSwarm
6
4
  module SessionPath
7
5
  SESSIONS_DIR = "sessions"
@@ -26,10 +24,10 @@ module ClaudeSwarm
26
24
  path.gsub(%r{[/\\]}, "+")
27
25
  end
28
26
 
29
- # Generate a full session path for a given directory and timestamp
30
- def generate(working_dir: Dir.pwd, timestamp: Time.now.strftime("%Y%m%d_%H%M%S"))
27
+ # Generate a full session path for a given directory and session ID
28
+ def generate(working_dir: Dir.pwd, session_id: SecureRandom.uuid)
31
29
  project_name = project_folder_name(working_dir)
32
- File.join(swarm_home, SESSIONS_DIR, project_name, timestamp)
30
+ File.join(swarm_home, SESSIONS_DIR, project_name, session_id)
33
31
  end
34
32
 
35
33
  # Ensure the session directory exists
@@ -1,7 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "English"
4
-
5
3
  module ClaudeSwarm
6
4
  module SystemUtils
7
5
  def system!(*args)
@@ -9,7 +7,7 @@ module ClaudeSwarm
9
7
  unless success
10
8
  exit_status = $CHILD_STATUS&.exitstatus || 1
11
9
  command_str = args.size == 1 ? args.first : args.join(" ")
12
- warn "❌ Command failed with exit status: #{exit_status}"
10
+ warn("❌ Command failed with exit status: #{exit_status}")
13
11
  raise Error, "Command failed with exit status #{exit_status}: #{command_str}"
14
12
  end
15
13
  success
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ClaudeSwarm
4
+ module Tools
5
+ class ResetSessionTool < FastMcp::Tool
6
+ tool_name "reset_session"
7
+ description "Reset the Claude session for this agent, starting fresh on the next task"
8
+
9
+ arguments do
10
+ # No arguments needed
11
+ end
12
+
13
+ def call
14
+ executor = ClaudeMcpServer.executor
15
+ executor.reset_session
16
+
17
+ {
18
+ success: true,
19
+ message: "Session has been reset",
20
+ }
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ClaudeSwarm
4
+ module Tools
5
+ class SessionInfoTool < FastMcp::Tool
6
+ tool_name "session_info"
7
+ description "Get information about the current Claude session for this agent"
8
+
9
+ arguments do
10
+ # No arguments needed
11
+ end
12
+
13
+ def call
14
+ executor = ClaudeMcpServer.executor
15
+
16
+ {
17
+ has_session: executor.has_session?,
18
+ session_id: executor.session_id,
19
+ working_directory: executor.working_directory,
20
+ }
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ClaudeSwarm
4
+ module Tools
5
+ class TaskTool < FastMcp::Tool
6
+ tool_name "task"
7
+ description "Execute a task using Claude Code. There is no description parameter."
8
+ annotations(read_only_hint: true, open_world_hint: false, destructive_hint: false)
9
+
10
+ arguments do
11
+ required(:prompt).filled(:string).description("The task or question for the agent")
12
+ optional(:new_session).filled(:bool).description("Start a new session (default: false)")
13
+ optional(:system_prompt).filled(:string).description("Override the system prompt for this request")
14
+ optional(:description).filled(:string).description("A description for the request")
15
+ end
16
+
17
+ def call(prompt:, new_session: false, system_prompt: nil, description: nil)
18
+ executor = ClaudeMcpServer.executor
19
+ instance_config = ClaudeMcpServer.instance_config
20
+
21
+ options = {
22
+ new_session: new_session,
23
+ system_prompt: system_prompt || instance_config[:prompt],
24
+ description: description,
25
+ }
26
+
27
+ # Add allowed tools from instance config
28
+ options[:allowed_tools] = instance_config[:allowed_tools] if instance_config[:allowed_tools]&.any?
29
+
30
+ # Add disallowed tools from instance config
31
+ options[:disallowed_tools] = instance_config[:disallowed_tools] if instance_config[:disallowed_tools]&.any?
32
+
33
+ # Add connections from instance config
34
+ options[:connections] = instance_config[:connections] if instance_config[:connections]&.any?
35
+
36
+ response = executor.execute(prompt, options)
37
+
38
+ # Return just the result text as expected by MCP
39
+ response["result"]
40
+ end
41
+ end
42
+ end
43
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ClaudeSwarm
4
- VERSION = "0.1.20"
4
+ VERSION = "0.2.1"
5
5
  end
@@ -1,12 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "open3"
4
- require "fileutils"
5
- require "json"
6
- require "pathname"
7
- require "securerandom"
8
- require "digest"
9
-
10
3
  module ClaudeSwarm
11
4
  class WorktreeManager
12
5
  include SystemUtils
@@ -17,12 +10,14 @@ module ClaudeSwarm
17
10
  @session_id = session_id
18
11
  # Generate a name based on session ID if no option given, empty string, or default "worktree" from Thor
19
12
  @shared_worktree_name = if cli_worktree_option.nil? || cli_worktree_option.empty? || cli_worktree_option == "worktree"
20
- generate_worktree_name
21
- else
22
- cli_worktree_option
23
- end
13
+ generate_worktree_name
14
+ else
15
+ cli_worktree_option
16
+ end
24
17
  @created_worktrees = {} # Maps "repo_root:worktree_name" to worktree_path
25
18
  @instance_worktree_configs = {} # Stores per-instance worktree settings
19
+ @instance_directories = {} # Stores original resolved directories for each instance
20
+ @instance_worktree_paths = {} # Stores worktree paths for each instance
26
21
  end
27
22
 
28
23
  def setup_worktrees(instances)
@@ -47,12 +42,23 @@ module ClaudeSwarm
47
42
  puts "Debug [WorktreeManager]: Worktree config: #{worktree_config.inspect}"
48
43
  end
49
44
 
50
- next if worktree_config[:skip]
45
+ # Store original directories (resolved)
46
+ original_dirs = instance[:directories] || [instance[:directory]]
47
+ resolved_dirs = original_dirs.map { |dir| dir ? File.expand_path(dir) : nil }.compact
48
+ @instance_directories[instance[:name]] = resolved_dirs
49
+
50
+ if worktree_config[:skip]
51
+ # No worktree, paths remain the same
52
+ @instance_worktree_paths[instance[:name]] = resolved_dirs
53
+ next
54
+ end
51
55
 
52
56
  worktree_name = worktree_config[:name]
53
- original_dirs = instance[:directories] || [instance[:directory]]
54
57
  mapped_dirs = original_dirs.map { |dir| map_to_worktree_path(dir, worktree_name) }
55
58
 
59
+ # Store the worktree paths
60
+ @instance_worktree_paths[instance[:name]] = mapped_dirs
61
+
56
62
  if ENV["CLAUDE_SWARM_DEBUG"]
57
63
  puts "Debug [WorktreeManager]: Original dirs: #{original_dirs.inspect}"
58
64
  puts "Debug [WorktreeManager]: Mapped dirs: #{mapped_dirs.inspect}"
@@ -101,10 +107,10 @@ module ClaudeSwarm
101
107
 
102
108
  # Return the equivalent path in the worktree
103
109
  result = if relative_path == "."
104
- worktree_path
105
- else
106
- File.join(worktree_path, relative_path)
107
- end
110
+ worktree_path
111
+ else
112
+ File.join(worktree_path, relative_path)
113
+ end
108
114
 
109
115
  puts "Debug [map_to_worktree_path]: Result: #{result}" if ENV["CLAUDE_SWARM_DEBUG"]
110
116
 
@@ -173,11 +179,22 @@ module ClaudeSwarm
173
179
  end
174
180
 
175
181
  def session_metadata
182
+ # Build instance details with resolved paths and worktree mappings
183
+ instance_details = {}
184
+
185
+ @instance_worktree_configs.each do |instance_name, worktree_config|
186
+ instance_details[instance_name] = {
187
+ worktree_config: worktree_config,
188
+ directories: @instance_directories[instance_name] || {},
189
+ worktree_paths: @instance_worktree_paths[instance_name] || {},
190
+ }
191
+ end
192
+
176
193
  {
177
194
  enabled: true,
178
195
  shared_name: @shared_worktree_name,
179
196
  created_paths: @created_worktrees.dup,
180
- instance_configs: @instance_worktree_configs.dup
197
+ instance_configs: instance_details,
181
198
  }
182
199
  end
183
200
 
@@ -419,17 +436,17 @@ module ClaudeSwarm
419
436
  def find_original_repo_for_worktree(worktree_path)
420
437
  # Get the git directory for this worktree
421
438
  _, git_dir_status = Open3.capture2e("git", "-C", worktree_path, "rev-parse", "--git-dir")
422
- return nil unless git_dir_status.success?
439
+ return unless git_dir_status.success?
423
440
 
424
441
  # Read the gitdir file to find the main repository
425
442
  # Worktree .git files contain: gitdir: /path/to/main/repo/.git/worktrees/worktree-name
426
443
  if File.file?(File.join(worktree_path, ".git"))
427
444
  gitdir_content = File.read(File.join(worktree_path, ".git")).strip
428
445
  if gitdir_content =~ /^gitdir: (.+)$/
429
- git_path = $1
446
+ git_path = ::Regexp.last_match(1)
430
447
  # Extract the main repo path from the worktree git path
431
448
  # Format: /path/to/repo/.git/worktrees/worktree-name
432
- return $1 if git_path =~ %r{^(.+)/\.git/worktrees/[^/]+$}
449
+ return ::Regexp.last_match(1) if git_path =~ %r{^(.+)/\.git/worktrees/[^/]+$}
433
450
  end
434
451
  end
435
452
 
@@ -438,7 +455,7 @@ module ClaudeSwarm
438
455
 
439
456
  def find_base_branch(repo_path)
440
457
  # Try to find the base branch - check for main, master, or the default branch
441
- %w[main master].each do |branch|
458
+ ["main", "master"].each do |branch|
442
459
  _, status = Open3.capture2e("git", "-C", repo_path, "rev-parse", "--verify", "refs/heads/#{branch}")
443
460
  return branch if status.success?
444
461
  end
data/lib/claude_swarm.rb CHANGED
@@ -1,24 +1,37 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- # External dependencies
4
- require "time"
5
- require "thor"
6
- require "yaml"
7
- require "json"
8
- require "fileutils"
3
+ # Standard library dependencies
4
+ require "digest"
5
+ require "English"
9
6
  require "erb"
10
- require "tmpdir"
7
+ require "fileutils"
8
+ require "io/console"
9
+ require "json"
10
+ require "logger"
11
11
  require "open3"
12
- require "timeout"
12
+ require "pathname"
13
13
  require "pty"
14
- require "io/console"
14
+ require "securerandom"
15
+ require "set"
16
+ require "shellwords"
17
+ require "time"
18
+ require "timeout"
19
+ require "tmpdir"
20
+ require "yaml"
21
+
22
+ # External dependencies
23
+ require "fast_mcp_annotations"
24
+ require "mcp_client"
25
+ require "openai"
26
+ require "thor"
15
27
 
16
28
  # Zeitwerk setup
17
29
  require "zeitwerk"
18
30
  loader = Zeitwerk::Loader.for_gem
19
31
  loader.ignore("#{__dir__}/claude_swarm/templates")
20
32
  loader.inflector.inflect(
21
- "cli" => "CLI"
33
+ "cli" => "CLI",
34
+ "openai" => "OpenAI",
22
35
  )
23
36
  loader.setup
24
37