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.
- checksums.yaml +4 -4
- data/.rubocop.yml +9 -66
- data/.rubocop_todo.yml +11 -0
- data/CHANGELOG.md +93 -0
- data/CLAUDE.md +61 -0
- data/README.md +172 -15
- data/Rakefile +1 -1
- data/examples/mixed-provider-swarm.yml +23 -0
- data/lib/claude_swarm/claude_code_executor.rb +7 -12
- data/lib/claude_swarm/claude_mcp_server.rb +26 -12
- data/lib/claude_swarm/cli.rb +293 -165
- data/lib/claude_swarm/commands/ps.rb +22 -24
- data/lib/claude_swarm/commands/show.rb +45 -63
- data/lib/claude_swarm/configuration.rb +137 -8
- data/lib/claude_swarm/mcp_generator.rb +39 -14
- data/lib/claude_swarm/openai/chat_completion.rb +264 -0
- data/lib/claude_swarm/openai/executor.rb +301 -0
- data/lib/claude_swarm/openai/responses.rb +338 -0
- data/lib/claude_swarm/orchestrator.rb +205 -39
- data/lib/claude_swarm/process_tracker.rb +7 -7
- data/lib/claude_swarm/session_cost_calculator.rb +93 -0
- data/lib/claude_swarm/session_path.rb +3 -5
- data/lib/claude_swarm/system_utils.rb +1 -3
- data/lib/claude_swarm/tools/reset_session_tool.rb +24 -0
- data/lib/claude_swarm/tools/session_info_tool.rb +24 -0
- data/lib/claude_swarm/tools/task_tool.rb +43 -0
- data/lib/claude_swarm/version.rb +1 -1
- data/lib/claude_swarm/worktree_manager.rb +13 -20
- data/lib/claude_swarm.rb +23 -10
- data/single.yml +482 -6
- metadata +50 -16
- data/claude-swarm.yml +0 -64
- data/lib/claude_swarm/reset_session_tool.rb +0 -22
- data/lib/claude_swarm/session_info_tool.rb +0 -22
- data/lib/claude_swarm/task_tool.rb +0 -39
- /data/{example → examples}/claude-swarm.yml +0 -0
- /data/{example → examples}/microservices-team.yml +0 -0
- /data/{example → examples}/session-restoration-demo.yml +0 -0
- /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
|
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
|
-
|
66
|
-
|
63
|
+
class << self
|
64
|
+
def cleanup_session(session_path)
|
65
|
+
return unless Dir.exist?(File.join(session_path, PIDS_DIR))
|
67
66
|
|
68
|
-
|
69
|
-
|
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
|
30
|
-
def generate(working_dir: Dir.pwd,
|
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,
|
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
|
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
|
data/lib/claude_swarm/version.rb
CHANGED
@@ -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
|
-
|
21
|
-
|
22
|
-
|
23
|
-
|
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
|
-
|
105
|
-
|
106
|
-
|
107
|
-
|
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
|
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 =
|
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
|
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
|
-
|
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
|
-
#
|
4
|
-
require "
|
5
|
-
require "
|
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 "
|
7
|
+
require "fileutils"
|
8
|
+
require "io/console"
|
9
|
+
require "json"
|
10
|
+
require "logger"
|
11
11
|
require "open3"
|
12
|
-
require "
|
12
|
+
require "pathname"
|
13
13
|
require "pty"
|
14
|
-
require "
|
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
|
|