claude_swarm 0.3.5 → 0.3.7

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.
@@ -3,6 +3,9 @@
3
3
  module ClaudeSwarm
4
4
  class Orchestrator
5
5
  include SystemUtils
6
+
7
+ attr_reader :config, :session_path, :session_log_path
8
+
6
9
  RUN_DIR = File.expand_path("~/.claude-swarm/run")
7
10
  ["INT", "TERM", "QUIT"].each do |signal|
8
11
  Signal.trap(signal) do
@@ -21,6 +24,7 @@ module ClaudeSwarm
21
24
  @debug = debug
22
25
  @restore_session_path = restore_session_path
23
26
  @session_path = nil
27
+ @session_log_path = nil
24
28
  @provided_session_id = session_id
25
29
  # Store worktree option for later use
26
30
  @worktree_option = worktree
@@ -40,24 +44,23 @@ module ClaudeSwarm
40
44
  @start_time = Time.now
41
45
 
42
46
  if @restore_session_path
43
- unless @non_interactive_prompt
47
+ non_interactive_output do
44
48
  puts "🔄 Restoring Claude Swarm: #{@config.swarm_name}"
45
49
  puts "😎 Vibe mode ON" if @vibe
46
- puts
47
50
  end
48
51
 
49
52
  # Use existing session path
50
53
  session_path = @restore_session_path
51
54
  @session_path = session_path
55
+ @session_log_path = File.join(@session_path, "session.log")
52
56
  ENV["CLAUDE_SWARM_SESSION_PATH"] = session_path
53
57
  ENV["CLAUDE_SWARM_ROOT_DIR"] = ClaudeSwarm.root_dir
54
58
 
55
59
  # Create run symlink for restored session
56
60
  create_run_symlink
57
61
 
58
- unless @non_interactive_prompt
62
+ non_interactive_output do
59
63
  puts "📝 Using existing session: #{session_path}/"
60
- puts
61
64
  end
62
65
 
63
66
  # Initialize process tracker
@@ -68,25 +71,22 @@ module ClaudeSwarm
68
71
 
69
72
  # Regenerate MCP configurations with session IDs for restoration
70
73
  @generator.generate_all
71
- unless @non_interactive_prompt
74
+ non_interactive_output do
72
75
  puts "✓ Regenerated MCP configurations with session IDs"
73
- puts
74
76
  end
75
77
  else
76
- unless @non_interactive_prompt
78
+ non_interactive_output do
77
79
  puts "🐝 Starting Claude Swarm: #{@config.swarm_name}"
78
80
  puts "😎 Vibe mode ON" if @vibe
79
- puts
80
81
  end
81
82
 
82
83
  # Generate and set session path for all instances
83
- session_path = if @provided_session_id
84
- SessionPath.generate(working_dir: ClaudeSwarm.root_dir, session_id: @provided_session_id)
85
- else
86
- SessionPath.generate(working_dir: ClaudeSwarm.root_dir)
87
- end
84
+ session_params = { working_dir: ClaudeSwarm.root_dir }
85
+ session_params[:session_id] = @provided_session_id if @provided_session_id
86
+ session_path = SessionPath.generate(**session_params)
88
87
  SessionPath.ensure_directory(session_path)
89
88
  @session_path = session_path
89
+ @session_log_path = File.join(@session_path, "session.log")
90
90
 
91
91
  # Extract session ID from path (the timestamp part)
92
92
  @session_id = File.basename(session_path)
@@ -97,9 +97,8 @@ module ClaudeSwarm
97
97
  # Create run symlink for new session
98
98
  create_run_symlink
99
99
 
100
- unless @non_interactive_prompt
100
+ non_interactive_output do
101
101
  puts "📝 Session files will be saved to: #{session_path}/"
102
- puts
103
102
  end
104
103
 
105
104
  # Initialize process tracker
@@ -109,7 +108,7 @@ module ClaudeSwarm
109
108
  if @needs_worktree_manager
110
109
  cli_option = @worktree_option.is_a?(String) && !@worktree_option.empty? ? @worktree_option : nil
111
110
  @worktree_manager = WorktreeManager.new(cli_option, session_id: @session_id)
112
- puts "🌳 Setting up Git worktrees..." unless @non_interactive_prompt
111
+ non_interactive_output { print("🌳 Setting up Git worktrees...") }
113
112
 
114
113
  # Get all instances for worktree setup
115
114
  # Note: instances.values already includes the main instance
@@ -117,17 +116,15 @@ module ClaudeSwarm
117
116
 
118
117
  @worktree_manager.setup_worktrees(all_instances)
119
118
 
120
- unless @non_interactive_prompt
119
+ non_interactive_output do
121
120
  puts "✓ Worktrees created with branch: #{@worktree_manager.worktree_name}"
122
- puts
123
121
  end
124
122
  end
125
123
 
126
124
  # Generate all MCP configuration files
127
125
  @generator.generate_all
128
- unless @non_interactive_prompt
126
+ non_interactive_output do
129
127
  puts "✓ Generated MCP configurations in session directory"
130
- puts
131
128
  end
132
129
 
133
130
  # Save swarm config path for restoration
@@ -136,7 +133,7 @@ module ClaudeSwarm
136
133
 
137
134
  # Launch the main instance (fetch after worktree setup to get modified paths)
138
135
  main_instance = @config.main_instance_config
139
- unless @non_interactive_prompt
136
+ non_interactive_output do
140
137
  puts "🚀 Launching main instance: #{@config.main_instance}"
141
138
  puts " Model: #{main_instance[:model]}"
142
139
  if main_instance[:directories].size == 1
@@ -149,13 +146,13 @@ module ClaudeSwarm
149
146
  puts " Disallowed tools: #{main_instance[:disallowed_tools].join(", ")}" if main_instance[:disallowed_tools]&.any?
150
147
  puts " Connections: #{main_instance[:connections].join(", ")}" if main_instance[:connections].any?
151
148
  puts " 😎 Vibe mode ON for this instance" if main_instance[:vibe]
152
- puts
153
149
  end
154
150
 
155
151
  command = build_main_command(main_instance)
156
- if @debug && !@non_interactive_prompt
157
- puts "🏃 Running: #{format_command_for_display(command)}"
158
- puts
152
+ if @debug
153
+ non_interactive_output do
154
+ puts "🏃 Running: #{format_command_for_display(command)}"
155
+ end
159
156
  end
160
157
 
161
158
  # Start log streaming thread if in non-interactive mode with --stream-logs
@@ -171,23 +168,21 @@ module ClaudeSwarm
171
168
  # Execute before commands if specified
172
169
  before_commands = @config.before_commands
173
170
  if before_commands.any? && !@restore_session_path
174
- unless @non_interactive_prompt
171
+ non_interactive_output do
175
172
  puts "⚙️ Executing before commands..."
176
- puts
177
173
  end
178
174
 
179
175
  success = execute_before_commands?(before_commands)
180
176
  unless success
181
- puts "❌ Before commands failed. Aborting swarm launch." unless @non_interactive_prompt
177
+ non_interactive_output { print("❌ Before commands failed. Aborting swarm launch.") }
182
178
  cleanup_processes
183
179
  cleanup_run_symlink
184
180
  cleanup_worktrees
185
181
  exit(1)
186
182
  end
187
183
 
188
- unless @non_interactive_prompt
184
+ non_interactive_output do
189
185
  puts "✓ Before commands completed successfully"
190
- puts
191
186
  end
192
187
  end
193
188
 
@@ -195,7 +190,11 @@ module ClaudeSwarm
195
190
  # This ensures the main instance runs in a clean environment without inheriting
196
191
  # Claude Swarm's BUNDLE_* environment variables
197
192
  Bundler.with_unbundled_env do
198
- system!(*command)
193
+ if @non_interactive_prompt
194
+ stream_to_session_log(*command)
195
+ else
196
+ system!(*command)
197
+ end
199
198
  end
200
199
  end
201
200
 
@@ -212,16 +211,15 @@ module ClaudeSwarm
212
211
  after_commands = @config.after_commands
213
212
  if after_commands.any? && !@restore_session_path
214
213
  Dir.chdir(main_instance[:directory]) do
215
- unless @non_interactive_prompt
216
- puts
217
- puts "⚙️ Executing after commands..."
218
- puts
214
+ non_interactive_output do
215
+ print("⚙️ Executing after commands...")
219
216
  end
220
217
 
221
218
  success = execute_after_commands?(after_commands)
222
- if !success && !@non_interactive_prompt
223
- puts "⚠️ Some after commands failed"
224
- puts
219
+ unless success
220
+ non_interactive_output do
221
+ puts "⚠️ Some after commands failed"
222
+ end
225
223
  end
226
224
  end
227
225
  end
@@ -234,115 +232,19 @@ module ClaudeSwarm
234
232
 
235
233
  private
236
234
 
237
- def execute_before_commands?(commands)
238
- log_file = File.join(@session_path, "session.log") if @session_path
239
-
240
- commands.each_with_index do |command, index|
241
- # Log the command execution to session log
242
- if @session_path
243
- File.open(log_file, "a") do |f|
244
- f.puts "[#{Time.now.strftime("%Y-%m-%d %H:%M:%S")}] Executing before command #{index + 1}/#{commands.size}: #{command}"
245
- end
246
- end
247
-
248
- # Execute the command and capture output
249
- begin
250
- puts "Debug: Executing command #{index + 1}/#{commands.size}: #{command}" if @debug && !@non_interactive_prompt
251
-
252
- # Use system with output capture
253
- output = %x(#{command} 2>&1)
254
- success = $CHILD_STATUS.success?
255
-
256
- # Log the output
257
- if @session_path
258
- File.open(log_file, "a") do |f|
259
- f.puts "Command output:"
260
- f.puts output
261
- f.puts "Exit status: #{$CHILD_STATUS.exitstatus}"
262
- f.puts "-" * 80
263
- end
264
- end
265
-
266
- # Show output if in debug mode or if command failed
267
- if (@debug || !success) && !@non_interactive_prompt
268
- puts "Command #{index + 1} output:"
269
- puts output
270
- puts "Exit status: #{$CHILD_STATUS.exitstatus}"
271
- end
235
+ def non_interactive_output
236
+ return if @non_interactive_prompt
272
237
 
273
- unless success
274
- puts "❌ Before command #{index + 1} failed: #{command}" unless @non_interactive_prompt
275
- return false
276
- end
277
- rescue StandardError => e
278
- puts "Error executing before command #{index + 1}: #{e.message}" unless @non_interactive_prompt
279
- if @session_path
280
- File.open(log_file, "a") do |f|
281
- f.puts "Error: #{e.message}"
282
- f.puts "-" * 80
283
- end
284
- end
285
- return false
286
- end
287
- end
238
+ yield
239
+ puts
240
+ end
288
241
 
289
- true
242
+ def execute_before_commands?(commands)
243
+ execute_commands(commands, phase: "before", fail_fast: true)
290
244
  end
291
245
 
292
246
  def execute_after_commands?(commands)
293
- log_file = File.join(@session_path, "session.log") if @session_path
294
- all_succeeded = true
295
-
296
- commands.each_with_index do |command, index|
297
- # Log the command execution to session log
298
- if @session_path
299
- File.open(log_file, "a") do |f|
300
- f.puts "[#{Time.now.strftime("%Y-%m-%d %H:%M:%S")}] Executing after command #{index + 1}/#{commands.size}: #{command}"
301
- end
302
- end
303
-
304
- # Execute the command and capture output
305
- begin
306
- puts "Debug: Executing after command #{index + 1}/#{commands.size}: #{command}" if @debug && !@non_interactive_prompt
307
-
308
- # Use system with output capture
309
- output = %x(#{command} 2>&1)
310
- success = $CHILD_STATUS.success?
311
-
312
- # Log the output
313
- if @session_path
314
- File.open(log_file, "a") do |f|
315
- f.puts "Command output:"
316
- f.puts output
317
- f.puts "Exit status: #{$CHILD_STATUS.exitstatus}"
318
- f.puts "-" * 80
319
- end
320
- end
321
-
322
- # Show output if in debug mode or if command failed
323
- if (@debug || !success) && !@non_interactive_prompt
324
- puts "After command #{index + 1} output:"
325
- puts output
326
- puts "Exit status: #{$CHILD_STATUS.exitstatus}"
327
- end
328
-
329
- unless success
330
- puts "❌ After command #{index + 1} failed: #{command}" unless @non_interactive_prompt
331
- all_succeeded = false
332
- end
333
- rescue StandardError => e
334
- puts "Error executing after command #{index + 1}: #{e.message}" unless @non_interactive_prompt
335
- if @session_path
336
- File.open(log_file, "a") do |f|
337
- f.puts "Error: #{e.message}"
338
- f.puts "-" * 80
339
- end
340
- end
341
- all_succeeded = false
342
- end
343
- end
344
-
345
- all_succeeded
247
+ execute_commands(commands, phase: "after", fail_fast: false)
346
248
  end
347
249
 
348
250
  def save_swarm_config_path(session_path)
@@ -355,19 +257,21 @@ module ClaudeSwarm
355
257
  File.write(root_dir_file, ClaudeSwarm.root_dir)
356
258
 
357
259
  # Save session metadata
358
- metadata = {
260
+ metadata_file = File.join(session_path, "session_metadata.json")
261
+ File.write(metadata_file, JSON.pretty_generate(build_session_metadata))
262
+ end
263
+
264
+ def build_session_metadata
265
+ {
359
266
  "root_directory" => ClaudeSwarm.root_dir,
360
267
  "timestamp" => Time.now.utc.iso8601,
361
268
  "start_time" => @start_time.utc.iso8601,
362
269
  "swarm_name" => @config.swarm_name,
363
270
  "claude_swarm_version" => VERSION,
364
- }
365
-
366
- # Add worktree info if applicable
367
- metadata["worktree"] = @worktree_manager.session_metadata if @worktree_manager
368
-
369
- metadata_file = File.join(session_path, "session_metadata.json")
370
- File.write(metadata_file, JSON.pretty_generate(metadata))
271
+ }.tap do |metadata|
272
+ # Add worktree info if applicable
273
+ metadata["worktree"] = @worktree_manager.session_metadata if @worktree_manager
274
+ end
371
275
  end
372
276
 
373
277
  def cleanup_processes
@@ -378,9 +282,7 @@ module ClaudeSwarm
378
282
  end
379
283
 
380
284
  def cleanup_worktrees
381
- return unless @worktree_manager
382
-
383
- @worktree_manager.cleanup_worktrees
285
+ @worktree_manager&.cleanup_worktrees
384
286
  rescue StandardError => e
385
287
  puts "⚠️ Error during worktree cleanup: #{e.message}"
386
288
  end
@@ -417,7 +319,7 @@ module ClaudeSwarm
417
319
 
418
320
  File.write(metadata_file, JSON.pretty_generate(metadata))
419
321
  rescue StandardError => e
420
- puts "⚠️ Error updating session metadata: #{e.message}" unless @non_interactive_prompt
322
+ non_interactive_output { print("⚠️ Error updating session metadata: #{e.message}") }
421
323
  end
422
324
 
423
325
  def calculate_total_cost
@@ -466,7 +368,7 @@ module ClaudeSwarm
466
368
  File.symlink(@session_path, symlink_path)
467
369
  rescue StandardError => e
468
370
  # Don't fail the process if symlink creation fails
469
- puts "⚠️ Warning: Could not create run symlink: #{e.message}" unless @non_interactive_prompt
371
+ non_interactive_output { print("⚠️ Warning: Could not create run symlink: #{e.message}") }
470
372
  end
471
373
 
472
374
  def cleanup_run_symlink
@@ -481,15 +383,11 @@ module ClaudeSwarm
481
383
 
482
384
  def start_log_streaming
483
385
  Thread.new do
484
- session_log_path = File.join(ENV.fetch("CLAUDE_SWARM_SESSION_PATH", nil), "session.log")
485
-
486
386
  # Wait for log file to be created
487
- sleep(0.1) until File.exist?(session_log_path)
387
+ sleep(0.1) until File.exist?(@session_log_path)
488
388
 
489
389
  # Open file and seek to end
490
- File.open(session_log_path, "r") do |file|
491
- file.seek(0, IO::SEEK_END)
492
-
390
+ File.open(@session_log_path, "r") do |file|
493
391
  loop do
494
392
  changes = file.read
495
393
  if changes
@@ -516,11 +414,13 @@ module ClaudeSwarm
516
414
  end
517
415
 
518
416
  def build_main_command(instance)
519
- parts = [
520
- "claude",
521
- "--model",
522
- instance[:model],
523
- ]
417
+ parts = ["claude"]
418
+
419
+ # Only add --model if ANTHROPIC_MODEL env var is not set
420
+ unless ENV["ANTHROPIC_MODEL"]
421
+ parts << "--model"
422
+ parts << instance[:model]
423
+ end
524
424
 
525
425
  # Add resume flag if restoring session
526
426
  if @restore_session_path
@@ -593,6 +493,8 @@ module ClaudeSwarm
593
493
  # Non-interactive mode with -p
594
494
  parts << "-p"
595
495
  parts << @non_interactive_prompt
496
+ parts << "--verbose"
497
+ parts << "--output-format=stream-json"
596
498
  elsif @interactive_prompt
597
499
  # Interactive mode with initial prompt (no -p flag)
598
500
  parts << @interactive_prompt
@@ -610,9 +512,8 @@ module ClaudeSwarm
610
512
  worktree_data = metadata["worktree"]
611
513
  return unless worktree_data && worktree_data["enabled"]
612
514
 
613
- unless @non_interactive_prompt
515
+ non_interactive_output do
614
516
  puts "🌳 Restoring Git worktrees..."
615
- puts
616
517
  end
617
518
 
618
519
  # Restore worktrees using the saved configuration
@@ -624,10 +525,91 @@ module ClaudeSwarm
624
525
  all_instances = @config.instances.values
625
526
  @worktree_manager.setup_worktrees(all_instances)
626
527
 
627
- return if @non_interactive_prompt
528
+ non_interactive_output do
529
+ puts "✓ Worktrees restored with branch: #{@worktree_manager.worktree_name}"
530
+ end
531
+ end
628
532
 
629
- puts "✓ Worktrees restored with branch: #{@worktree_manager.worktree_name}"
630
- puts
533
+ def stream_to_session_log(*command)
534
+ # Setup logger for session logging
535
+ logger = Logger.new(@session_log_path, level: :info, progname: @config.main_instance)
536
+
537
+ # Use Open3.popen2e to capture stdout and stderr merged for formatting
538
+ Open3.popen2e(*command) do |stdin, stdout_and_stderr, wait_thr|
539
+ stdin.close
540
+
541
+ # Read and process the merged output
542
+ stdout_and_stderr.each_line do |line|
543
+ # Try to parse and prettify JSON lines
544
+
545
+ json_data = JSON.parse(line.chomp)
546
+ pretty_json = JSON.pretty_generate(json_data)
547
+ logger.info { pretty_json }
548
+ rescue JSON::ParserError
549
+ # Warn about non-JSON output since we expect stream-json format
550
+ warn("⚠️ Warning: Non-JSON output detected in stream-json mode: #{line.chomp}")
551
+ # Log the line as-is
552
+ logger.info { line.chomp }
553
+ end
554
+
555
+ wait_thr.value
556
+ end
557
+ end
558
+
559
+ def execute_commands(commands, phase:, fail_fast:)
560
+ all_succeeded = true
561
+
562
+ # Setup logger for session logging if we have a session path
563
+ logger = Logger.new(@session_log_path, level: :info)
564
+
565
+ commands.each_with_index do |command, index|
566
+ # Log the command execution to session log
567
+ logger.info { "Executing #{phase} command #{index + 1}/#{commands.size}: #{command}" }
568
+
569
+ # Execute the command and capture output
570
+ begin
571
+ if @debug
572
+ non_interactive_output do
573
+ debug_prefix = phase == "after" ? "after " : ""
574
+ print("Debug: Executing #{debug_prefix} command #{index + 1}/#{commands.size}: #{format_command_for_display(command)}")
575
+ end
576
+ end
577
+
578
+ output = %x(#{command} 2>&1)
579
+ success = $CHILD_STATUS.success?
580
+ output_separator = "-" * 80
581
+
582
+ logger.info { "Command output:" }
583
+ logger.info { output }
584
+ logger.info { "Exit status: #{$CHILD_STATUS.exitstatus}" }
585
+ logger.info { output_separator }
586
+
587
+ # Show output if in debug mode or if command failed
588
+ if @debug || !success
589
+ non_interactive_output do
590
+ output_prefix = phase == "after" ? "After command" : "Command"
591
+ puts "#{output_prefix} #{index + 1} output:"
592
+ puts output
593
+ print("Exit status: #{$CHILD_STATUS.exitstatus}")
594
+ end
595
+ end
596
+
597
+ unless success
598
+ error_prefix = phase.capitalize
599
+ non_interactive_output { print("❌ #{error_prefix} command #{index + 1} failed: #{command}") }
600
+ all_succeeded = false
601
+ return false if fail_fast
602
+ end
603
+ rescue StandardError => e
604
+ non_interactive_output { print("Error executing #{phase} command #{index + 1}: #{e.message}") }
605
+ logger.info { "Error: #{e.message}" }
606
+ logger.info { output_separator }
607
+ all_succeeded = false
608
+ return false if fail_fast
609
+ end
610
+ end
611
+
612
+ all_succeeded
631
613
  end
632
614
  end
633
615
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ClaudeSwarm
4
- VERSION = "0.3.5"
4
+ VERSION = "0.3.7"
5
5
  end
data/lib/claude_swarm.rb CHANGED
@@ -23,6 +23,7 @@ require "yaml"
23
23
  # External dependencies
24
24
  require "claude_sdk"
25
25
  require "fast_mcp_annotations"
26
+ require "faraday/retry"
26
27
  require "mcp_client"
27
28
  require "openai"
28
29
  require "thor"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: claude_swarm
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.5
4
+ version: 0.3.7
5
5
  platform: ruby
6
6
  authors:
7
7
  - Paulo Arruda
@@ -51,6 +51,20 @@ dependencies:
51
51
  - - "~>"
52
52
  - !ruby/object:Gem::Version
53
53
  version: '0.1'
54
+ - !ruby/object:Gem::Dependency
55
+ name: faraday-retry
56
+ requirement: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - "~>"
59
+ - !ruby/object:Gem::Version
60
+ version: '2.0'
61
+ type: :runtime
62
+ prerelease: false
63
+ version_requirements: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - "~>"
66
+ - !ruby/object:Gem::Version
67
+ version: '2.0'
54
68
  - !ruby/object:Gem::Dependency
55
69
  name: fast-mcp-annotations
56
70
  requirement: !ruby/object:Gem::Requirement
@@ -133,6 +147,7 @@ files:
133
147
  - examples/with-before-commands.yml
134
148
  - exe/claude-swarm
135
149
  - lib/claude_swarm.rb
150
+ - lib/claude_swarm/base_executor.rb
136
151
  - lib/claude_swarm/claude_code_executor.rb
137
152
  - lib/claude_swarm/claude_mcp_server.rb
138
153
  - lib/claude_swarm/cli.rb