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.
- checksums.yaml +4 -4
- data/.rubocop.yml +9 -66
- data/.rubocop_todo.yml +11 -0
- data/CHANGELOG.md +106 -0
- data/CLAUDE.md +61 -0
- data/README.md +174 -16
- 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 +161 -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 +39 -22
- data/lib/claude_swarm.rb +23 -10
- data/single.yml +481 -6
- metadata +54 -14
- 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,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
|
-
|
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
|
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
|
-
|
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
|
-
|
105
|
-
|
106
|
-
|
107
|
-
|
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:
|
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
|
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 =
|
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
|
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
|
-
|
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
|
-
#
|
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
|
|