claude_swarm 0.1.17 ā 0.1.19
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 +50 -0
- data/CLAUDE.md +48 -0
- data/README.md +143 -2
- data/claude-swarm.yml +54 -16
- data/examples/with-before-commands.yml +30 -0
- data/lib/claude_swarm/cli.rb +131 -3
- data/lib/claude_swarm/commands/ps.rb +53 -1
- data/lib/claude_swarm/configuration.rb +14 -1
- data/lib/claude_swarm/orchestrator.rb +165 -2
- data/lib/claude_swarm/version.rb +1 -1
- data/lib/claude_swarm/worktree_manager.rb +353 -0
- data/llms.txt +2 -2
- data/single.yml +92 -0
- metadata +4 -1
@@ -86,6 +86,11 @@ module ClaudeSwarm
|
|
86
86
|
swarm_name = config.dig("swarm", "name") || "Unknown"
|
87
87
|
main_instance = config.dig("swarm", "main")
|
88
88
|
|
89
|
+
# Get base directory from session metadata or start_directory file
|
90
|
+
base_dir = Dir.pwd
|
91
|
+
start_dir_file = File.join(session_dir, "start_directory")
|
92
|
+
base_dir = File.read(start_dir_file).strip if File.exist?(start_dir_file)
|
93
|
+
|
89
94
|
# Get all directories - handle both string and array formats
|
90
95
|
dir_config = config.dig("swarm", "instances", main_instance, "directory")
|
91
96
|
directories = if dir_config.is_a?(Array)
|
@@ -93,7 +98,16 @@ module ClaudeSwarm
|
|
93
98
|
else
|
94
99
|
[dir_config || "."]
|
95
100
|
end
|
96
|
-
|
101
|
+
|
102
|
+
# Expand paths relative to the base directory
|
103
|
+
expanded_directories = directories.map do |dir|
|
104
|
+
File.expand_path(dir, base_dir)
|
105
|
+
end
|
106
|
+
|
107
|
+
# Check for worktree information in session metadata
|
108
|
+
expanded_directories = apply_worktree_paths(expanded_directories, session_dir)
|
109
|
+
|
110
|
+
directories_str = expanded_directories.join(", ")
|
97
111
|
|
98
112
|
# Calculate total cost from JSON log
|
99
113
|
total_cost = calculate_total_cost(session_dir)
|
@@ -143,6 +157,44 @@ module ClaudeSwarm
|
|
143
157
|
def truncate(str, length)
|
144
158
|
str.length > length ? "#{str[0...length - 2]}.." : str
|
145
159
|
end
|
160
|
+
|
161
|
+
def apply_worktree_paths(directories, session_dir)
|
162
|
+
session_metadata_file = File.join(session_dir, "session_metadata.json")
|
163
|
+
return directories unless File.exist?(session_metadata_file)
|
164
|
+
|
165
|
+
metadata = JSON.parse(File.read(session_metadata_file))
|
166
|
+
worktree_info = metadata["worktree"]
|
167
|
+
return directories unless worktree_info && worktree_info["enabled"]
|
168
|
+
|
169
|
+
# Get the created worktree paths
|
170
|
+
created_paths = worktree_info["created_paths"] || {}
|
171
|
+
|
172
|
+
# For each directory, find the appropriate worktree path
|
173
|
+
directories.map do |dir|
|
174
|
+
# Find if this directory has a worktree created
|
175
|
+
repo_root = find_git_root(dir)
|
176
|
+
next dir unless repo_root
|
177
|
+
|
178
|
+
# Look for a worktree with this repo root
|
179
|
+
worktree_key = created_paths.keys.find { |key| key.start_with?("#{repo_root}:") }
|
180
|
+
worktree_key ? created_paths[worktree_key] : dir
|
181
|
+
end
|
182
|
+
end
|
183
|
+
|
184
|
+
def worktree_path_for(dir, worktree_name)
|
185
|
+
git_root = find_git_root(dir)
|
186
|
+
git_root ? File.join(git_root, ".worktrees", worktree_name) : dir
|
187
|
+
end
|
188
|
+
|
189
|
+
def find_git_root(dir)
|
190
|
+
current = File.expand_path(dir)
|
191
|
+
while current != "/"
|
192
|
+
return current if File.exist?(File.join(current, ".git"))
|
193
|
+
|
194
|
+
current = File.dirname(current)
|
195
|
+
end
|
196
|
+
nil
|
197
|
+
end
|
146
198
|
end
|
147
199
|
end
|
148
200
|
end
|
@@ -26,6 +26,10 @@ module ClaudeSwarm
|
|
26
26
|
instances[instance_name][:connections] || []
|
27
27
|
end
|
28
28
|
|
29
|
+
def before_commands
|
30
|
+
@swarm["before"] || []
|
31
|
+
end
|
32
|
+
|
29
33
|
private
|
30
34
|
|
31
35
|
def load_and_validate
|
@@ -101,7 +105,8 @@ module ClaudeSwarm
|
|
101
105
|
mcps: parse_mcps(config["mcps"] || []),
|
102
106
|
prompt: config["prompt"],
|
103
107
|
description: config["description"],
|
104
|
-
vibe: config["vibe"] || false
|
108
|
+
vibe: config["vibe"] || false,
|
109
|
+
worktree: parse_worktree_value(config["worktree"])
|
105
110
|
}
|
106
111
|
end
|
107
112
|
|
@@ -188,5 +193,13 @@ module ClaudeSwarm
|
|
188
193
|
def expand_path(path)
|
189
194
|
Pathname.new(path).expand_path(@base_dir).to_s
|
190
195
|
end
|
196
|
+
|
197
|
+
def parse_worktree_value(value)
|
198
|
+
return nil if value.nil? # Omitted means follow CLI behavior
|
199
|
+
return value if value.is_a?(TrueClass) || value.is_a?(FalseClass)
|
200
|
+
return value.to_s if value.is_a?(String) && !value.empty?
|
201
|
+
|
202
|
+
raise Error, "Invalid worktree value: #{value.inspect}. Must be true, false, or a non-empty string"
|
203
|
+
end
|
191
204
|
end
|
192
205
|
end
|
@@ -1,17 +1,19 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
|
+
require "English"
|
3
4
|
require "shellwords"
|
4
5
|
require "json"
|
5
6
|
require "fileutils"
|
6
7
|
require_relative "session_path"
|
7
8
|
require_relative "process_tracker"
|
9
|
+
require_relative "worktree_manager"
|
8
10
|
|
9
11
|
module ClaudeSwarm
|
10
12
|
class Orchestrator
|
11
13
|
RUN_DIR = File.expand_path("~/.claude-swarm/run")
|
12
14
|
|
13
15
|
def initialize(configuration, mcp_generator, vibe: false, prompt: nil, stream_logs: false, debug: false,
|
14
|
-
restore_session_path: nil)
|
16
|
+
restore_session_path: nil, worktree: nil)
|
15
17
|
@config = configuration
|
16
18
|
@generator = mcp_generator
|
17
19
|
@vibe = vibe
|
@@ -20,6 +22,15 @@ module ClaudeSwarm
|
|
20
22
|
@debug = debug
|
21
23
|
@restore_session_path = restore_session_path
|
22
24
|
@session_path = nil
|
25
|
+
# Store worktree option for later use
|
26
|
+
@worktree_option = worktree
|
27
|
+
@needs_worktree_manager = worktree.is_a?(String) || worktree == "" ||
|
28
|
+
configuration.instances.values.any? { |inst| !inst[:worktree].nil? }
|
29
|
+
# Store modified instances after worktree setup
|
30
|
+
@modified_instances = nil
|
31
|
+
|
32
|
+
# Set environment variable for prompt mode to suppress output
|
33
|
+
ENV["CLAUDE_SWARM_PROMPT"] = "1" if @prompt
|
23
34
|
end
|
24
35
|
|
25
36
|
def start
|
@@ -50,6 +61,9 @@ module ClaudeSwarm
|
|
50
61
|
# Set up signal handlers to clean up child processes
|
51
62
|
setup_signal_handlers
|
52
63
|
|
64
|
+
# Check if the original session used worktrees
|
65
|
+
restore_worktrees_if_needed(session_path)
|
66
|
+
|
53
67
|
# Regenerate MCP configurations with session IDs for restoration
|
54
68
|
@generator.generate_all
|
55
69
|
unless @prompt
|
@@ -68,6 +82,9 @@ module ClaudeSwarm
|
|
68
82
|
SessionPath.ensure_directory(session_path)
|
69
83
|
@session_path = session_path
|
70
84
|
|
85
|
+
# Extract session ID from path (the timestamp part)
|
86
|
+
@session_id = File.basename(session_path)
|
87
|
+
|
71
88
|
ENV["CLAUDE_SWARM_SESSION_PATH"] = session_path
|
72
89
|
ENV["CLAUDE_SWARM_START_DIR"] = Dir.pwd
|
73
90
|
|
@@ -85,6 +102,24 @@ module ClaudeSwarm
|
|
85
102
|
# Set up signal handlers to clean up child processes
|
86
103
|
setup_signal_handlers
|
87
104
|
|
105
|
+
# Create WorktreeManager if needed with session ID
|
106
|
+
if @needs_worktree_manager
|
107
|
+
cli_option = @worktree_option.is_a?(String) && !@worktree_option.empty? ? @worktree_option : nil
|
108
|
+
@worktree_manager = WorktreeManager.new(cli_option, session_id: @session_id)
|
109
|
+
puts "š³ Setting up Git worktrees..." unless @prompt
|
110
|
+
|
111
|
+
# Get all instances for worktree setup
|
112
|
+
# Note: instances.values already includes the main instance
|
113
|
+
all_instances = @config.instances.values
|
114
|
+
|
115
|
+
@worktree_manager.setup_worktrees(all_instances)
|
116
|
+
|
117
|
+
unless @prompt
|
118
|
+
puts "ā Worktrees created with branch: #{@worktree_manager.worktree_name}"
|
119
|
+
puts
|
120
|
+
end
|
121
|
+
end
|
122
|
+
|
88
123
|
# Generate all MCP configuration files
|
89
124
|
@generator.generate_all
|
90
125
|
unless @prompt
|
@@ -96,7 +131,30 @@ module ClaudeSwarm
|
|
96
131
|
save_swarm_config_path(session_path)
|
97
132
|
end
|
98
133
|
|
99
|
-
#
|
134
|
+
# Execute before commands if specified
|
135
|
+
before_commands = @config.before_commands
|
136
|
+
if before_commands.any? && !@restore_session_path
|
137
|
+
unless @prompt
|
138
|
+
puts "āļø Executing before commands..."
|
139
|
+
puts
|
140
|
+
end
|
141
|
+
|
142
|
+
success = execute_before_commands(before_commands)
|
143
|
+
unless success
|
144
|
+
puts "ā Before commands failed. Aborting swarm launch." unless @prompt
|
145
|
+
cleanup_processes
|
146
|
+
cleanup_run_symlink
|
147
|
+
cleanup_worktrees
|
148
|
+
exit 1
|
149
|
+
end
|
150
|
+
|
151
|
+
unless @prompt
|
152
|
+
puts "ā Before commands completed successfully"
|
153
|
+
puts
|
154
|
+
end
|
155
|
+
end
|
156
|
+
|
157
|
+
# Launch the main instance (fetch after worktree setup to get modified paths)
|
100
158
|
main_instance = @config.main_instance_config
|
101
159
|
unless @prompt
|
102
160
|
puts "š Launching main instance: #{@config.main_instance}"
|
@@ -138,10 +196,66 @@ module ClaudeSwarm
|
|
138
196
|
# Clean up child processes and run symlink
|
139
197
|
cleanup_processes
|
140
198
|
cleanup_run_symlink
|
199
|
+
cleanup_worktrees
|
141
200
|
end
|
142
201
|
|
143
202
|
private
|
144
203
|
|
204
|
+
def execute_before_commands(commands)
|
205
|
+
log_file = File.join(@session_path, "session.log") if @session_path
|
206
|
+
|
207
|
+
commands.each_with_index do |command, index|
|
208
|
+
# Log the command execution to session log
|
209
|
+
if @session_path
|
210
|
+
File.open(log_file, "a") do |f|
|
211
|
+
f.puts "[#{Time.now.strftime("%Y-%m-%d %H:%M:%S")}] Executing before command #{index + 1}/#{commands.size}: #{command}"
|
212
|
+
end
|
213
|
+
end
|
214
|
+
|
215
|
+
# Execute the command and capture output
|
216
|
+
begin
|
217
|
+
puts "Debug: Executing command #{index + 1}/#{commands.size}: #{command}" if @debug && !@prompt
|
218
|
+
|
219
|
+
# Use system with output capture
|
220
|
+
output = `#{command} 2>&1`
|
221
|
+
success = $CHILD_STATUS.success?
|
222
|
+
|
223
|
+
# Log the output
|
224
|
+
if @session_path
|
225
|
+
File.open(log_file, "a") do |f|
|
226
|
+
f.puts "Command output:"
|
227
|
+
f.puts output
|
228
|
+
f.puts "Exit status: #{$CHILD_STATUS.exitstatus}"
|
229
|
+
f.puts "-" * 80
|
230
|
+
end
|
231
|
+
end
|
232
|
+
|
233
|
+
# Show output if in debug mode or if command failed
|
234
|
+
if (@debug || !success) && !@prompt
|
235
|
+
puts "Command #{index + 1} output:"
|
236
|
+
puts output
|
237
|
+
puts "Exit status: #{$CHILD_STATUS.exitstatus}"
|
238
|
+
end
|
239
|
+
|
240
|
+
unless success
|
241
|
+
puts "ā Before command #{index + 1} failed: #{command}" unless @prompt
|
242
|
+
return false
|
243
|
+
end
|
244
|
+
rescue StandardError => e
|
245
|
+
puts "Error executing before command #{index + 1}: #{e.message}" unless @prompt
|
246
|
+
if @session_path
|
247
|
+
File.open(log_file, "a") do |f|
|
248
|
+
f.puts "Error: #{e.message}"
|
249
|
+
f.puts "-" * 80
|
250
|
+
end
|
251
|
+
end
|
252
|
+
return false
|
253
|
+
end
|
254
|
+
end
|
255
|
+
|
256
|
+
true
|
257
|
+
end
|
258
|
+
|
145
259
|
def save_swarm_config_path(session_path)
|
146
260
|
# Copy the YAML config file to the session directory
|
147
261
|
config_copy_path = File.join(session_path, "config.yml")
|
@@ -150,6 +264,20 @@ module ClaudeSwarm
|
|
150
264
|
# Save the original working directory
|
151
265
|
start_dir_file = File.join(session_path, "start_directory")
|
152
266
|
File.write(start_dir_file, Dir.pwd)
|
267
|
+
|
268
|
+
# Save session metadata
|
269
|
+
metadata = {
|
270
|
+
"start_directory" => Dir.pwd,
|
271
|
+
"timestamp" => Time.now.utc.iso8601,
|
272
|
+
"swarm_name" => @config.swarm_name,
|
273
|
+
"claude_swarm_version" => VERSION
|
274
|
+
}
|
275
|
+
|
276
|
+
# Add worktree info if applicable
|
277
|
+
metadata["worktree"] = @worktree_manager.session_metadata if @worktree_manager
|
278
|
+
|
279
|
+
metadata_file = File.join(session_path, "session_metadata.json")
|
280
|
+
File.write(metadata_file, JSON.pretty_generate(metadata))
|
153
281
|
end
|
154
282
|
|
155
283
|
def setup_signal_handlers
|
@@ -158,6 +286,7 @@ module ClaudeSwarm
|
|
158
286
|
puts "\nš Received #{signal} signal, cleaning up..."
|
159
287
|
cleanup_processes
|
160
288
|
cleanup_run_symlink
|
289
|
+
cleanup_worktrees
|
161
290
|
exit
|
162
291
|
end
|
163
292
|
end
|
@@ -170,6 +299,14 @@ module ClaudeSwarm
|
|
170
299
|
puts "ā ļø Error during cleanup: #{e.message}"
|
171
300
|
end
|
172
301
|
|
302
|
+
def cleanup_worktrees
|
303
|
+
return unless @worktree_manager
|
304
|
+
|
305
|
+
@worktree_manager.cleanup_worktrees
|
306
|
+
rescue StandardError => e
|
307
|
+
puts "ā ļø Error during worktree cleanup: #{e.message}"
|
308
|
+
end
|
309
|
+
|
173
310
|
def create_run_symlink
|
174
311
|
return unless @session_path
|
175
312
|
|
@@ -304,5 +441,31 @@ module ClaudeSwarm
|
|
304
441
|
parts << "#{instance[:prompt]}\n\nNow just say 'I am ready to start'"
|
305
442
|
end
|
306
443
|
end
|
444
|
+
|
445
|
+
def restore_worktrees_if_needed(session_path)
|
446
|
+
metadata_file = File.join(session_path, "session_metadata.json")
|
447
|
+
return unless File.exist?(metadata_file)
|
448
|
+
|
449
|
+
metadata = JSON.parse(File.read(metadata_file))
|
450
|
+
worktree_data = metadata["worktree"]
|
451
|
+
return unless worktree_data && worktree_data["enabled"]
|
452
|
+
|
453
|
+
unless @prompt
|
454
|
+
puts "š³ Restoring Git worktrees..."
|
455
|
+
puts
|
456
|
+
end
|
457
|
+
|
458
|
+
# Restore worktrees using the saved configuration
|
459
|
+
@worktree_manager = WorktreeManager.new(worktree_data["shared_name"])
|
460
|
+
|
461
|
+
# Get all instances and restore their worktree paths
|
462
|
+
all_instances = @config.instances.values
|
463
|
+
@worktree_manager.setup_worktrees(all_instances)
|
464
|
+
|
465
|
+
return if @prompt
|
466
|
+
|
467
|
+
puts "ā Worktrees restored with branch: #{@worktree_manager.worktree_name}"
|
468
|
+
puts
|
469
|
+
end
|
307
470
|
end
|
308
471
|
end
|
data/lib/claude_swarm/version.rb
CHANGED