claude_swarm 0.1.20 → 0.2.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.
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 +93 -0
  5. data/CLAUDE.md +61 -0
  6. data/README.md +172 -15
  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 +137 -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 +13 -20
  29. data/lib/claude_swarm.rb +23 -10
  30. data/single.yml +482 -6
  31. metadata +50 -16
  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.0"
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,10 +10,10 @@ 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
26
19
  end
@@ -101,10 +94,10 @@ module ClaudeSwarm
101
94
 
102
95
  # Return the equivalent path in the worktree
103
96
  result = if relative_path == "."
104
- worktree_path
105
- else
106
- File.join(worktree_path, relative_path)
107
- end
97
+ worktree_path
98
+ else
99
+ File.join(worktree_path, relative_path)
100
+ end
108
101
 
109
102
  puts "Debug [map_to_worktree_path]: Result: #{result}" if ENV["CLAUDE_SWARM_DEBUG"]
110
103
 
@@ -177,7 +170,7 @@ module ClaudeSwarm
177
170
  enabled: true,
178
171
  shared_name: @shared_worktree_name,
179
172
  created_paths: @created_worktrees.dup,
180
- instance_configs: @instance_worktree_configs.dup
173
+ instance_configs: @instance_worktree_configs.dup,
181
174
  }
182
175
  end
183
176
 
@@ -419,17 +412,17 @@ module ClaudeSwarm
419
412
  def find_original_repo_for_worktree(worktree_path)
420
413
  # Get the git directory for this worktree
421
414
  _, git_dir_status = Open3.capture2e("git", "-C", worktree_path, "rev-parse", "--git-dir")
422
- return nil unless git_dir_status.success?
415
+ return unless git_dir_status.success?
423
416
 
424
417
  # Read the gitdir file to find the main repository
425
418
  # Worktree .git files contain: gitdir: /path/to/main/repo/.git/worktrees/worktree-name
426
419
  if File.file?(File.join(worktree_path, ".git"))
427
420
  gitdir_content = File.read(File.join(worktree_path, ".git")).strip
428
421
  if gitdir_content =~ /^gitdir: (.+)$/
429
- git_path = $1
422
+ git_path = ::Regexp.last_match(1)
430
423
  # Extract the main repo path from the worktree git path
431
424
  # Format: /path/to/repo/.git/worktrees/worktree-name
432
- return $1 if git_path =~ %r{^(.+)/\.git/worktrees/[^/]+$}
425
+ return ::Regexp.last_match(1) if git_path =~ %r{^(.+)/\.git/worktrees/[^/]+$}
433
426
  end
434
427
  end
435
428
 
@@ -438,7 +431,7 @@ module ClaudeSwarm
438
431
 
439
432
  def find_base_branch(repo_path)
440
433
  # Try to find the base branch - check for main, master, or the default branch
441
- %w[main master].each do |branch|
434
+ ["main", "master"].each do |branch|
442
435
  _, status = Open3.capture2e("git", "-C", repo_path, "rev-parse", "--verify", "refs/heads/#{branch}")
443
436
  return branch if status.success?
444
437
  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