claude_swarm 0.3.11 → 1.0.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.
@@ -6,7 +6,6 @@ module ClaudeSwarm
6
6
 
7
7
  attr_reader :config, :session_path, :session_log_path
8
8
 
9
- RUN_DIR = File.expand_path("~/.claude-swarm/run")
10
9
  ["INT", "TERM", "QUIT"].each do |signal|
11
10
  Signal.trap(signal) do
12
11
  puts "\nšŸ›‘ Received #{signal} signal."
@@ -201,15 +200,29 @@ module ClaudeSwarm
201
200
  main_pid_file = File.join(@session_path, "main_pid")
202
201
  File.write(main_pid_file, Process.pid.to_s)
203
202
 
204
- # Execute the main instance - this will cascade to other instances via MCP
205
- Dir.chdir(main_instance[:directory]) do
206
- # Execute before commands if specified
207
- before_commands = @config.before_commands
208
- if before_commands.any? && !@restore_session_path
209
- non_interactive_output do
210
- puts "āš™ļø Executing before commands..."
211
- end
203
+ # Execute before commands if specified
204
+ # If the main instance directory exists, run in it for backward compatibility
205
+ # If it doesn't exist, run in the parent directory so before commands can create it
206
+ before_commands = @config.before_commands
207
+ if before_commands.any? && !@restore_session_path
208
+ non_interactive_output do
209
+ puts "āš™ļø Executing before commands..."
210
+ end
211
+
212
+ # Determine where to run before commands
213
+ if File.exist?(main_instance[:directory])
214
+ # Directory exists, run commands in it (backward compatibility)
215
+ before_commands_dir = main_instance[:directory]
216
+ else
217
+ # Directory doesn't exist, run in parent directory
218
+ # This allows before commands to create the directory
219
+ parent_dir = File.dirname(File.expand_path(main_instance[:directory]))
220
+ # Ensure parent directory exists (important for worktrees)
221
+ FileUtils.mkdir_p(parent_dir)
222
+ before_commands_dir = parent_dir
223
+ end
212
224
 
225
+ Dir.chdir(before_commands_dir) do
213
226
  success = execute_before_commands?(before_commands)
214
227
  unless success
215
228
  non_interactive_output { print("āŒ Before commands failed. Aborting swarm launch.") }
@@ -218,26 +231,29 @@ module ClaudeSwarm
218
231
  cleanup_worktrees
219
232
  exit(1)
220
233
  end
234
+ end
221
235
 
222
- non_interactive_output do
223
- puts "āœ“ Before commands completed successfully"
224
- end
236
+ non_interactive_output do
237
+ puts "āœ“ Before commands completed successfully"
238
+ end
225
239
 
226
- # Validate directories after before commands have run
227
- begin
228
- @config.validate_directories
229
- non_interactive_output do
230
- puts "āœ“ All directories validated successfully"
231
- end
232
- rescue ClaudeSwarm::Error => e
233
- non_interactive_output { print("āŒ Directory validation failed: #{e.message}") }
234
- cleanup_processes
235
- cleanup_run_symlink
236
- cleanup_worktrees
237
- exit(1)
240
+ # Validate directories after before commands have run
241
+ begin
242
+ @config.validate_directories
243
+ non_interactive_output do
244
+ puts "āœ“ All directories validated successfully"
238
245
  end
246
+ rescue ClaudeSwarm::Error => e
247
+ non_interactive_output { print("āŒ Directory validation failed: #{e.message}") }
248
+ cleanup_processes
249
+ cleanup_run_symlink
250
+ cleanup_worktrees
251
+ exit(1)
239
252
  end
253
+ end
240
254
 
255
+ # Execute the main instance - this will cascade to other instances via MCP
256
+ Dir.chdir(main_instance[:directory]) do
241
257
  # Execute main Claude instance with unbundled environment to avoid bundler conflicts
242
258
  # This ensures the main instance runs in a clean environment without inheriting
243
259
  # Claude Swarm's BUNDLE_* environment variables
@@ -263,9 +279,20 @@ module ClaudeSwarm
263
279
  display_summary
264
280
 
265
281
  # Execute after commands if specified
282
+ # Use the same logic as before commands for consistency
266
283
  after_commands = @config.after_commands
267
284
  if after_commands.any? && !@restore_session_path
268
- Dir.chdir(main_instance[:directory]) do
285
+ # Determine where to run after commands (same logic as before commands)
286
+ if File.exist?(main_instance[:directory])
287
+ # Directory exists, run commands in it
288
+ after_commands_dir = main_instance[:directory]
289
+ else
290
+ # Directory doesn't exist (shouldn't happen after main instance runs, but be safe)
291
+ parent_dir = File.dirname(File.expand_path(main_instance[:directory]))
292
+ after_commands_dir = parent_dir
293
+ end
294
+
295
+ Dir.chdir(after_commands_dir) do
269
296
  non_interactive_output do
270
297
  print("āš™ļø Executing after commands...")
271
298
  end
@@ -311,7 +338,7 @@ module ClaudeSwarm
311
338
 
312
339
  # Save session metadata
313
340
  metadata_file = File.join(session_path, "session_metadata.json")
314
- File.write(metadata_file, JSON.pretty_generate(build_session_metadata))
341
+ JsonHandler.write_file!(metadata_file, build_session_metadata)
315
342
  end
316
343
 
317
344
  def build_session_metadata
@@ -367,11 +394,11 @@ module ClaudeSwarm
367
394
  metadata_file = File.join(@session_path, "session_metadata.json")
368
395
  return unless File.exist?(metadata_file)
369
396
 
370
- metadata = JSON.parse(File.read(metadata_file))
397
+ metadata = JsonHandler.parse_file!(metadata_file)
371
398
  metadata["end_time"] = end_time.utc.iso8601
372
399
  metadata["duration_seconds"] = (end_time - @start_time).to_i
373
400
 
374
- File.write(metadata_file, JSON.pretty_generate(metadata))
401
+ JsonHandler.write_file!(metadata_file, metadata)
375
402
  rescue StandardError => e
376
403
  non_interactive_output { print("āš ļø Error updating session metadata: #{e.message}") }
377
404
  end
@@ -409,11 +436,12 @@ module ClaudeSwarm
409
436
  def create_run_symlink
410
437
  return unless @session_path
411
438
 
412
- FileUtils.mkdir_p(RUN_DIR)
439
+ run_dir = ClaudeSwarm.joined_run_dir
440
+ FileUtils.mkdir_p(run_dir)
413
441
 
414
442
  # Session ID is the last part of the session path
415
443
  session_id = File.basename(@session_path)
416
- symlink_path = File.join(RUN_DIR, session_id)
444
+ symlink_path = ClaudeSwarm.joined_run_dir(session_id)
417
445
 
418
446
  # Remove stale symlink if exists
419
447
  File.unlink(symlink_path) if File.symlink?(symlink_path)
@@ -429,7 +457,7 @@ module ClaudeSwarm
429
457
  return unless @session_path
430
458
 
431
459
  session_id = File.basename(@session_path)
432
- symlink_path = File.join(RUN_DIR, session_id)
460
+ symlink_path = ClaudeSwarm.joined_run_dir(session_id)
433
461
  File.unlink(symlink_path) if File.symlink?(symlink_path)
434
462
  rescue StandardError
435
463
  # Ignore errors during cleanup
@@ -484,7 +512,7 @@ module ClaudeSwarm
484
512
 
485
513
  # Find the state file for the main instance
486
514
  state_files.each do |state_file|
487
- state_data = JSON.parse(File.read(state_file))
515
+ state_data = JsonHandler.parse_file!(state_file)
488
516
  next unless state_data["instance_name"] == main_instance_name
489
517
 
490
518
  claude_session_id = state_data["claude_session_id"]
@@ -569,7 +597,7 @@ module ClaudeSwarm
569
597
  metadata_file = File.join(session_path, "session_metadata.json")
570
598
  return unless File.exist?(metadata_file)
571
599
 
572
- metadata = JSON.parse(File.read(metadata_file))
600
+ metadata = JsonHandler.parse_file!(metadata_file)
573
601
  worktree_data = metadata["worktree"]
574
602
  return unless worktree_data && worktree_data["enabled"]
575
603
 
@@ -601,13 +629,11 @@ module ClaudeSwarm
601
629
 
602
630
  # Read and process the merged output
603
631
  stdout_and_stderr.each_line do |line|
604
- # Try to parse and prettify JSON lines
605
-
606
- json_data = JSON.parse(line.chomp)
607
- pretty_json = JSON.pretty_generate(json_data)
608
- logger.info { pretty_json }
609
- rescue JSON::ParserError
610
- logger.info { line.chomp }
632
+ logger.info do
633
+ chomped_line = line.chomp
634
+ json_data = JsonHandler.parse(chomped_line)
635
+ json_data == chomped_line ? chomped_line : JsonHandler.pretty_generate!(json_data)
636
+ end
611
637
  end
612
638
 
613
639
  wait_thr.value
@@ -636,11 +662,11 @@ module ClaudeSwarm
636
662
  line = file.gets
637
663
  if line
638
664
  begin
639
- # Parse JSONL entry
640
- transcript_entry = JSON.parse(line)
665
+ # Parse JSONL entry, silently skip unparseable lines
666
+ transcript_entry = JsonHandler.parse(line)
641
667
 
642
- # Skip summary entries - these are just conversation titles
643
- next if transcript_entry["type"] == "summary"
668
+ # Skip if parsing failed or if it's a summary entry
669
+ next if transcript_entry == line || transcript_entry["type"] == "summary"
644
670
 
645
671
  # Convert to session.log.json format
646
672
  session_entry = convert_transcript_to_session_format(transcript_entry)
@@ -654,8 +680,6 @@ module ClaudeSwarm
654
680
  log_file.puts(session_entry.to_json)
655
681
  end
656
682
  end
657
- rescue JSON::ParserError
658
- # Silently skip unparseable lines
659
683
  rescue StandardError
660
684
  # Silently handle other errors to keep thread running
661
685
  end
@@ -86,7 +86,9 @@ module ClaudeSwarm
86
86
  main_instance_cost = 0.0
87
87
 
88
88
  File.foreach(session_log_path) do |line|
89
- data = JSON.parse(line)
89
+ data = JsonHandler.parse(line)
90
+ next if data == line # Skip unparseable lines
91
+
90
92
  instance_name = data["instance"]
91
93
  instance_id = data["instance_id"]
92
94
 
@@ -108,8 +110,6 @@ module ClaudeSwarm
108
110
  instance_costs[instance_name] += cost
109
111
  end
110
112
  end
111
- rescue JSON::ParserError
112
- next
113
113
  end
114
114
 
115
115
  # Calculate total: sum of all instance costs + main instance token costs
@@ -137,7 +137,9 @@ module ClaudeSwarm
137
137
  return instances unless File.exist?(session_log_path)
138
138
 
139
139
  File.foreach(session_log_path) do |line|
140
- data = JSON.parse(line)
140
+ data = JsonHandler.parse(line)
141
+ next if data == line # Skip unparseable lines
142
+
141
143
  instance_name = data["instance"]
142
144
  instance_id = data["instance_id"]
143
145
  calling_instance = data["calling_instance"]
@@ -191,8 +193,6 @@ module ClaudeSwarm
191
193
  instances[instance_name][:has_cost_data] = true
192
194
  end
193
195
  end
194
- rescue JSON::ParserError
195
- next
196
196
  end
197
197
 
198
198
  # Set main instance costs (replace, don't add)
@@ -2,13 +2,7 @@
2
2
 
3
3
  module ClaudeSwarm
4
4
  module SessionPath
5
- SESSIONS_DIR = "sessions"
6
-
7
5
  class << self
8
- def swarm_home
9
- ENV["CLAUDE_SWARM_HOME"] || File.expand_path("~/.claude-swarm")
10
- end
11
-
12
6
  # Convert a directory path to a safe folder name using + as separator
13
7
  def project_folder_name(working_dir = Dir.pwd)
14
8
  # Don't expand path if it's already expanded (avoids double expansion on Windows)
@@ -27,7 +21,7 @@ module ClaudeSwarm
27
21
  # Generate a full session path for a given directory and session ID
28
22
  def generate(working_dir: Dir.pwd, session_id: SecureRandom.uuid)
29
23
  project_name = project_folder_name(working_dir)
30
- File.join(swarm_home, SESSIONS_DIR, project_name, session_id)
24
+ ClaudeSwarm.joined_sessions_dir(project_name, session_id)
31
25
  end
32
26
 
33
27
  # Ensure the session directory exists
@@ -35,7 +29,7 @@ module ClaudeSwarm
35
29
  FileUtils.mkdir_p(session_path)
36
30
 
37
31
  # Add .gitignore to swarm home if it doesn't exist
38
- gitignore_path = File.join(swarm_home, ".gitignore")
32
+ gitignore_path = ClaudeSwarm.joined_home_dir(".gitignore")
39
33
  File.write(gitignore_path, "*\n") unless File.exist?(gitignore_path)
40
34
  end
41
35
 
@@ -54,7 +54,7 @@ module ClaudeSwarm
54
54
  return if settings.empty?
55
55
 
56
56
  # Write settings file
57
- File.write(settings_path(name), JSON.pretty_generate(settings))
57
+ JsonHandler.write_file!(settings_path(name), settings)
58
58
  end
59
59
 
60
60
  def build_session_start_hook
@@ -225,3 +225,6 @@ The more precisely you explain what you want, the better Claude's response will
225
225
  * **Provide instructions as sequential steps:** Use numbered lists or bullet points to better ensure that Claude carries out the task the exact way you want it to.
226
226
 
227
227
  </prompt_best_practices>
228
+
229
+
230
+ IMPORTANT: Do not generate swarms with circular dependencies. For example, instance A connections to instance B, and instance B connections to instance A.
@@ -43,8 +43,20 @@ module ClaudeSwarm
43
43
 
44
44
  response = executor.execute(final_prompt, options)
45
45
 
46
+ # Validate the response has a result
47
+ unless response.is_a?(Hash) && response.key?("result")
48
+ raise "Invalid response from executor: missing 'result' field. Response structure: #{response.keys.join(", ")}"
49
+ end
50
+
51
+ result = response["result"]
52
+
53
+ # Validate the result is not empty
54
+ if result.nil? || (result.is_a?(String) && result.strip.empty?)
55
+ raise "Agent #{instance_config[:name]} returned an empty response. The task was executed but no content was provided."
56
+ end
57
+
46
58
  # Return just the result text as expected by MCP
47
- response["result"]
59
+ result
48
60
  end
49
61
  end
50
62
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ClaudeSwarm
4
- VERSION = "0.3.11"
4
+ VERSION = "1.0.0"
5
5
  end
@@ -158,7 +158,7 @@ module ClaudeSwarm
158
158
  # Remove session-specific worktree directory if it exists and is empty
159
159
  return unless @session_id
160
160
 
161
- session_worktree_dir = File.join(File.expand_path("~/.claude-swarm/worktrees"), @session_id)
161
+ session_worktree_dir = ClaudeSwarm.joined_worktrees_dir(@session_id)
162
162
  return unless File.exist?(session_worktree_dir)
163
163
 
164
164
  # Try to remove the directory tree
@@ -214,7 +214,7 @@ module ClaudeSwarm
214
214
  unique_repo_name = "#{repo_name}-#{path_hash}"
215
215
 
216
216
  # Build external path: ~/.claude-swarm/worktrees/[session_id]/[repo_name-hash]/[worktree_name]
217
- base_dir = File.expand_path("~/.claude-swarm/worktrees")
217
+ base_dir = ClaudeSwarm.joined_worktrees_dir
218
218
 
219
219
  # Validate base directory is accessible
220
220
  begin
data/lib/claude_swarm.rb CHANGED
@@ -41,7 +41,27 @@ module ClaudeSwarm
41
41
 
42
42
  class << self
43
43
  def root_dir
44
- ENV.fetch("CLAUDE_SWARM_ROOT_DIR", Dir.pwd)
44
+ ENV.fetch("CLAUDE_SWARM_ROOT_DIR") { Dir.pwd }
45
+ end
46
+
47
+ def home_dir
48
+ ENV.fetch("CLAUDE_SWARM_HOME") { File.expand_path("~/.claude-swarm") }
49
+ end
50
+
51
+ def joined_home_dir(*strings)
52
+ File.join(home_dir, *strings)
53
+ end
54
+
55
+ def joined_run_dir(*strings)
56
+ joined_home_dir("run", *strings)
57
+ end
58
+
59
+ def joined_sessions_dir(*strings)
60
+ joined_home_dir("sessions", *strings)
61
+ end
62
+
63
+ def joined_worktrees_dir(*strings)
64
+ joined_home_dir("worktrees", *strings)
45
65
  end
46
66
  end
47
67
  end