claude_swarm 0.1.19 → 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 (43) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +9 -63
  3. data/.rubocop_todo.yml +11 -0
  4. data/CHANGELOG.md +110 -0
  5. data/CLAUDE.md +64 -2
  6. data/README.md +190 -28
  7. data/Rakefile +1 -1
  8. data/examples/mixed-provider-swarm.yml +23 -0
  9. data/examples/monitoring-demo.yml +4 -4
  10. data/lib/claude_swarm/claude_code_executor.rb +7 -13
  11. data/lib/claude_swarm/claude_mcp_server.rb +26 -17
  12. data/lib/claude_swarm/cli.rb +384 -265
  13. data/lib/claude_swarm/commands/ps.rb +22 -24
  14. data/lib/claude_swarm/commands/show.rb +45 -63
  15. data/lib/claude_swarm/configuration.rb +137 -8
  16. data/lib/claude_swarm/mcp_generator.rb +39 -15
  17. data/lib/claude_swarm/openai/chat_completion.rb +264 -0
  18. data/lib/claude_swarm/openai/executor.rb +301 -0
  19. data/lib/claude_swarm/openai/responses.rb +338 -0
  20. data/lib/claude_swarm/orchestrator.rb +221 -45
  21. data/lib/claude_swarm/process_tracker.rb +7 -7
  22. data/lib/claude_swarm/session_cost_calculator.rb +93 -0
  23. data/lib/claude_swarm/session_path.rb +3 -5
  24. data/lib/claude_swarm/system_utils.rb +16 -0
  25. data/lib/claude_swarm/templates/generation_prompt.md.erb +230 -0
  26. data/lib/claude_swarm/tools/reset_session_tool.rb +24 -0
  27. data/lib/claude_swarm/tools/session_info_tool.rb +24 -0
  28. data/lib/claude_swarm/tools/task_tool.rb +43 -0
  29. data/lib/claude_swarm/version.rb +1 -1
  30. data/lib/claude_swarm/worktree_manager.rb +145 -48
  31. data/lib/claude_swarm.rb +34 -12
  32. data/llms.txt +2 -2
  33. data/single.yml +482 -6
  34. data/team.yml +344 -0
  35. metadata +65 -14
  36. data/claude-swarm.yml +0 -64
  37. data/lib/claude_swarm/reset_session_tool.rb +0 -22
  38. data/lib/claude_swarm/session_info_tool.rb +0 -22
  39. data/lib/claude_swarm/task_tool.rb +0 -39
  40. /data/{example → examples}/claude-swarm.yml +0 -0
  41. /data/{example → examples}/microservices-team.yml +0 -0
  42. /data/{example → examples}/session-restoration-demo.yml +0 -0
  43. /data/{example → examples}/test-generation.yml +0 -0
@@ -1,19 +1,12 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "English"
4
- require "shellwords"
5
- require "json"
6
- require "fileutils"
7
- require_relative "session_path"
8
- require_relative "process_tracker"
9
- require_relative "worktree_manager"
10
-
11
3
  module ClaudeSwarm
12
4
  class Orchestrator
5
+ include SystemUtils
13
6
  RUN_DIR = File.expand_path("~/.claude-swarm/run")
14
7
 
15
8
  def initialize(configuration, mcp_generator, vibe: false, prompt: nil, stream_logs: false, debug: false,
16
- restore_session_path: nil, worktree: nil)
9
+ restore_session_path: nil, worktree: nil, session_id: nil)
17
10
  @config = configuration
18
11
  @generator = mcp_generator
19
12
  @vibe = vibe
@@ -22,18 +15,24 @@ module ClaudeSwarm
22
15
  @debug = debug
23
16
  @restore_session_path = restore_session_path
24
17
  @session_path = nil
18
+ @provided_session_id = session_id
25
19
  # Store worktree option for later use
26
20
  @worktree_option = worktree
27
21
  @needs_worktree_manager = worktree.is_a?(String) || worktree == "" ||
28
- configuration.instances.values.any? { |inst| !inst[:worktree].nil? }
22
+ configuration.instances.values.any? { |inst| !inst[:worktree].nil? }
29
23
  # Store modified instances after worktree setup
30
24
  @modified_instances = nil
25
+ # Track start time for runtime calculation
26
+ @start_time = nil
31
27
 
32
28
  # Set environment variable for prompt mode to suppress output
33
29
  ENV["CLAUDE_SWARM_PROMPT"] = "1" if @prompt
34
30
  end
35
31
 
36
32
  def start
33
+ # Track start time
34
+ @start_time = Time.now
35
+
37
36
  if @restore_session_path
38
37
  unless @prompt
39
38
  puts "🔄 Restoring Claude Swarm: #{@config.swarm_name}"
@@ -78,7 +77,11 @@ module ClaudeSwarm
78
77
  end
79
78
 
80
79
  # Generate and set session path for all instances
81
- session_path = SessionPath.generate(working_dir: Dir.pwd)
80
+ session_path = if @provided_session_id
81
+ SessionPath.generate(working_dir: Dir.pwd, session_id: @provided_session_id)
82
+ else
83
+ SessionPath.generate(working_dir: Dir.pwd)
84
+ end
82
85
  SessionPath.ensure_directory(session_path)
83
86
  @session_path = session_path
84
87
 
@@ -131,29 +134,6 @@ module ClaudeSwarm
131
134
  save_swarm_config_path(session_path)
132
135
  end
133
136
 
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
137
  # Launch the main instance (fetch after worktree setup to get modified paths)
158
138
  main_instance = @config.main_instance_config
159
139
  unless @prompt
@@ -174,7 +154,7 @@ module ClaudeSwarm
174
154
 
175
155
  command = build_main_command(main_instance)
176
156
  if @debug && !@prompt
177
- puts "Running: #{command}"
157
+ puts "🏃 Running: #{format_command_for_display(command)}"
178
158
  puts
179
159
  end
180
160
 
@@ -182,9 +162,36 @@ module ClaudeSwarm
182
162
  log_thread = nil
183
163
  log_thread = start_log_streaming if @prompt && @stream_logs
184
164
 
165
+ # Write the current process PID (orchestrator) to a file for easy access
166
+ main_pid_file = File.join(@session_path, "main_pid")
167
+ File.write(main_pid_file, Process.pid.to_s)
168
+
185
169
  # Execute the main instance - this will cascade to other instances via MCP
186
170
  Dir.chdir(main_instance[:directory]) do
187
- system(*command)
171
+ # Execute before commands if specified
172
+ before_commands = @config.before_commands
173
+ if before_commands.any? && !@restore_session_path
174
+ unless @prompt
175
+ puts "⚙️ Executing before commands..."
176
+ puts
177
+ end
178
+
179
+ success = execute_before_commands?(before_commands)
180
+ unless success
181
+ puts "❌ Before commands failed. Aborting swarm launch." unless @prompt
182
+ cleanup_processes
183
+ cleanup_run_symlink
184
+ cleanup_worktrees
185
+ exit(1)
186
+ end
187
+
188
+ unless @prompt
189
+ puts "✓ Before commands completed successfully"
190
+ puts
191
+ end
192
+ end
193
+
194
+ system!(*command)
188
195
  end
189
196
 
190
197
  # Clean up log streaming thread
@@ -193,6 +200,27 @@ module ClaudeSwarm
193
200
  log_thread.join
194
201
  end
195
202
 
203
+ # Display runtime and cost summary
204
+ display_summary
205
+
206
+ # Execute after commands if specified
207
+ after_commands = @config.after_commands
208
+ if after_commands.any? && !@restore_session_path
209
+ Dir.chdir(main_instance[:directory]) do
210
+ unless @prompt
211
+ puts
212
+ puts "⚙️ Executing after commands..."
213
+ puts
214
+ end
215
+
216
+ success = execute_after_commands?(after_commands)
217
+ if !success && !@prompt
218
+ puts "⚠️ Some after commands failed"
219
+ puts
220
+ end
221
+ end
222
+ end
223
+
196
224
  # Clean up child processes and run symlink
197
225
  cleanup_processes
198
226
  cleanup_run_symlink
@@ -201,7 +229,7 @@ module ClaudeSwarm
201
229
 
202
230
  private
203
231
 
204
- def execute_before_commands(commands)
232
+ def execute_before_commands?(commands)
205
233
  log_file = File.join(@session_path, "session.log") if @session_path
206
234
 
207
235
  commands.each_with_index do |command, index|
@@ -217,7 +245,7 @@ module ClaudeSwarm
217
245
  puts "Debug: Executing command #{index + 1}/#{commands.size}: #{command}" if @debug && !@prompt
218
246
 
219
247
  # Use system with output capture
220
- output = `#{command} 2>&1`
248
+ output = %x(#{command} 2>&1)
221
249
  success = $CHILD_STATUS.success?
222
250
 
223
251
  # Log the output
@@ -256,6 +284,62 @@ module ClaudeSwarm
256
284
  true
257
285
  end
258
286
 
287
+ def execute_after_commands?(commands)
288
+ log_file = File.join(@session_path, "session.log") if @session_path
289
+ all_succeeded = true
290
+
291
+ commands.each_with_index do |command, index|
292
+ # Log the command execution to session log
293
+ if @session_path
294
+ File.open(log_file, "a") do |f|
295
+ f.puts "[#{Time.now.strftime("%Y-%m-%d %H:%M:%S")}] Executing after command #{index + 1}/#{commands.size}: #{command}"
296
+ end
297
+ end
298
+
299
+ # Execute the command and capture output
300
+ begin
301
+ puts "Debug: Executing after command #{index + 1}/#{commands.size}: #{command}" if @debug && !@prompt
302
+
303
+ # Use system with output capture
304
+ output = %x(#{command} 2>&1)
305
+ success = $CHILD_STATUS.success?
306
+
307
+ # Log the output
308
+ if @session_path
309
+ File.open(log_file, "a") do |f|
310
+ f.puts "Command output:"
311
+ f.puts output
312
+ f.puts "Exit status: #{$CHILD_STATUS.exitstatus}"
313
+ f.puts "-" * 80
314
+ end
315
+ end
316
+
317
+ # Show output if in debug mode or if command failed
318
+ if (@debug || !success) && !@prompt
319
+ puts "After command #{index + 1} output:"
320
+ puts output
321
+ puts "Exit status: #{$CHILD_STATUS.exitstatus}"
322
+ end
323
+
324
+ unless success
325
+ puts "❌ After command #{index + 1} failed: #{command}" unless @prompt
326
+ all_succeeded = false
327
+ end
328
+ rescue StandardError => e
329
+ puts "Error executing after command #{index + 1}: #{e.message}" unless @prompt
330
+ if @session_path
331
+ File.open(log_file, "a") do |f|
332
+ f.puts "Error: #{e.message}"
333
+ f.puts "-" * 80
334
+ end
335
+ end
336
+ all_succeeded = false
337
+ end
338
+ end
339
+
340
+ all_succeeded
341
+ end
342
+
259
343
  def save_swarm_config_path(session_path)
260
344
  # Copy the YAML config file to the session directory
261
345
  config_copy_path = File.join(session_path, "config.yml")
@@ -269,8 +353,9 @@ module ClaudeSwarm
269
353
  metadata = {
270
354
  "start_directory" => Dir.pwd,
271
355
  "timestamp" => Time.now.utc.iso8601,
356
+ "start_time" => @start_time.utc.iso8601,
272
357
  "swarm_name" => @config.swarm_name,
273
- "claude_swarm_version" => VERSION
358
+ "claude_swarm_version" => VERSION,
274
359
  }
275
360
 
276
361
  # Add worktree info if applicable
@@ -281,9 +366,23 @@ module ClaudeSwarm
281
366
  end
282
367
 
283
368
  def setup_signal_handlers
284
- %w[INT TERM QUIT].each do |signal|
369
+ ["INT", "TERM", "QUIT"].each do |signal|
285
370
  Signal.trap(signal) do
286
371
  puts "\n🛑 Received #{signal} signal, cleaning up..."
372
+ display_summary
373
+
374
+ # Execute after commands if configured
375
+ main_instance = @config.main_instance_config
376
+ after_commands = @config.after_commands
377
+ if after_commands.any? && !@restore_session_path && !@prompt
378
+ Dir.chdir(main_instance[:directory]) do
379
+ puts
380
+ puts "⚙️ Executing after commands..."
381
+ puts
382
+ execute_after_commands?(after_commands)
383
+ end
384
+ end
385
+
287
386
  cleanup_processes
288
387
  cleanup_run_symlink
289
388
  cleanup_worktrees
@@ -307,6 +406,71 @@ module ClaudeSwarm
307
406
  puts "⚠️ Error during worktree cleanup: #{e.message}"
308
407
  end
309
408
 
409
+ def display_summary
410
+ return unless @session_path && @start_time
411
+
412
+ end_time = Time.now
413
+ runtime_seconds = (end_time - @start_time).to_i
414
+
415
+ # Update session metadata with end time
416
+ update_session_end_time(end_time)
417
+
418
+ # Calculate total cost from session logs
419
+ total_cost = calculate_total_cost
420
+
421
+ puts
422
+ puts "=" * 50
423
+ puts "🏁 Claude Swarm Summary"
424
+ puts "=" * 50
425
+ puts "Runtime: #{format_duration(runtime_seconds)}"
426
+ puts "Total Cost: #{format_cost(total_cost)}"
427
+ puts "Session: #{File.basename(@session_path)}"
428
+ puts "=" * 50
429
+ end
430
+
431
+ def update_session_end_time(end_time)
432
+ metadata_file = File.join(@session_path, "session_metadata.json")
433
+ return unless File.exist?(metadata_file)
434
+
435
+ metadata = JSON.parse(File.read(metadata_file))
436
+ metadata["end_time"] = end_time.utc.iso8601
437
+ metadata["duration_seconds"] = (end_time - @start_time).to_i
438
+
439
+ File.write(metadata_file, JSON.pretty_generate(metadata))
440
+ rescue StandardError => e
441
+ puts "⚠️ Error updating session metadata: #{e.message}" unless @prompt
442
+ end
443
+
444
+ def calculate_total_cost
445
+ log_file = File.join(@session_path, "session.log.json")
446
+ result = SessionCostCalculator.calculate_total_cost(log_file)
447
+
448
+ # Check if main instance has cost data
449
+ main_instance_name = @config.main_instance
450
+ @main_has_cost = result[:instances_with_cost].include?(main_instance_name)
451
+
452
+ result[:total_cost]
453
+ end
454
+
455
+ def format_duration(seconds)
456
+ hours = seconds / 3600
457
+ minutes = (seconds % 3600) / 60
458
+ secs = seconds % 60
459
+
460
+ parts = []
461
+ parts << "#{hours}h" if hours.positive?
462
+ parts << "#{minutes}m" if minutes.positive?
463
+ parts << "#{secs}s"
464
+
465
+ parts.join(" ")
466
+ end
467
+
468
+ def format_cost(cost)
469
+ cost_str = format("$%.4f", cost)
470
+ cost_str += " (excluding main instance)" unless @main_has_cost
471
+ cost_str
472
+ end
473
+
310
474
  def create_run_symlink
311
475
  return unless @session_path
312
476
 
@@ -341,7 +505,7 @@ module ClaudeSwarm
341
505
  session_log_path = File.join(ENV.fetch("CLAUDE_SWARM_SESSION_PATH", nil), "session.log")
342
506
 
343
507
  # Wait for log file to be created
344
- sleep 0.1 until File.exist?(session_log_path)
508
+ sleep(0.1) until File.exist?(session_log_path)
345
509
 
346
510
  # Open file and seek to end
347
511
  File.open(session_log_path, "r") do |file|
@@ -350,10 +514,10 @@ module ClaudeSwarm
350
514
  loop do
351
515
  changes = file.read
352
516
  if changes
353
- print changes
517
+ print(changes)
354
518
  $stdout.flush
355
519
  else
356
- sleep 0.1
520
+ sleep(0.1)
357
521
  end
358
522
  end
359
523
  end
@@ -362,11 +526,21 @@ module ClaudeSwarm
362
526
  end
363
527
  end
364
528
 
529
+ def format_command_for_display(command)
530
+ command.map do |part|
531
+ if part.match?(/\s|'|"/)
532
+ "'#{part.gsub("'", "'\\\\''")}'"
533
+ else
534
+ part
535
+ end
536
+ end.join(" ")
537
+ end
538
+
365
539
  def build_main_command(instance)
366
540
  parts = [
367
541
  "claude",
368
542
  "--model",
369
- instance[:model]
543
+ instance[:model],
370
544
  ]
371
545
 
372
546
  # Add resume flag if restoring session
@@ -456,7 +630,9 @@ module ClaudeSwarm
456
630
  end
457
631
 
458
632
  # Restore worktrees using the saved configuration
459
- @worktree_manager = WorktreeManager.new(worktree_data["shared_name"])
633
+ # Extract session ID from the session path
634
+ session_id = File.basename(session_path)
635
+ @worktree_manager = WorktreeManager.new(worktree_data["shared_name"], session_id: session_id)
460
636
 
461
637
  # Get all instances and restore their worktree paths
462
638
  all_instances = @config.instances.values
@@ -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
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ClaudeSwarm
4
+ module SystemUtils
5
+ def system!(*args)
6
+ success = system(*args)
7
+ unless success
8
+ exit_status = $CHILD_STATUS&.exitstatus || 1
9
+ command_str = args.size == 1 ? args.first : args.join(" ")
10
+ warn("❌ Command failed with exit status: #{exit_status}")
11
+ raise Error, "Command failed with exit status #{exit_status}: #{command_str}"
12
+ end
13
+ success
14
+ end
15
+ end
16
+ end