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.
@@ -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
- directories_str = directories.join(", ")
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
- # Launch the main instance
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ClaudeSwarm
4
- VERSION = "0.1.17"
4
+ VERSION = "0.1.19"
5
5
  end