claude_swarm 0.3.10 → 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.
@@ -97,7 +97,7 @@ module ClaudeSwarm
97
97
  end
98
98
 
99
99
  # Log the request parameters
100
- @executor.logger.info { "Responses API Request (depth=#{depth}): #{JSON.pretty_generate(parameters)}" }
100
+ @executor.logger.info { "Responses API Request (depth=#{depth}): #{JsonHandler.pretty_generate!(parameters)}" }
101
101
 
102
102
  # Append to session JSON
103
103
  append_to_session_json({
@@ -112,7 +112,7 @@ module ClaudeSwarm
112
112
  response = @openai_client.responses.create(parameters: parameters)
113
113
  rescue StandardError => e
114
114
  @executor.logger.error { "Responses API error: #{e.class} - #{e.message}" }
115
- @executor.logger.error { "Request parameters: #{JSON.pretty_generate(parameters)}" }
115
+ @executor.logger.error { "Request parameters: #{JsonHandler.pretty_generate!(parameters)}" }
116
116
 
117
117
  # Try to extract and log the response body for better debugging
118
118
  if e.respond_to?(:response)
@@ -140,7 +140,7 @@ module ClaudeSwarm
140
140
  end
141
141
 
142
142
  # Log the full response
143
- @executor.logger.info { "Responses API Full Response (depth=#{depth}): #{JSON.pretty_generate(response)}" }
143
+ @executor.logger.info { "Responses API Full Response (depth=#{depth}): #{JsonHandler.pretty_generate!(response)}" }
144
144
 
145
145
  # Append to session JSON
146
146
  append_to_session_json({
@@ -230,10 +230,10 @@ module ClaudeSwarm
230
230
 
231
231
  begin
232
232
  # Parse arguments
233
- tool_args = JSON.parse(tool_args_str)
233
+ tool_args = JsonHandler.parse!(tool_args_str)
234
234
 
235
235
  # Log tool execution
236
- @executor.logger.info { "Responses API - Executing tool: #{tool_name} with args: #{JSON.pretty_generate(tool_args)}" }
236
+ @executor.logger.info { "Responses API - Executing tool: #{tool_name} with args: #{JsonHandler.pretty_generate!(tool_args)}" }
237
237
 
238
238
  # Execute tool via MCP
239
239
  result = @mcp_client.call_tool(tool_name, tool_args)
@@ -283,7 +283,7 @@ module ClaudeSwarm
283
283
  end
284
284
 
285
285
  @executor.logger.info { "Responses API - Built conversation with #{conversation.size} function outputs" }
286
- @executor.logger.debug { "Final conversation structure: #{JSON.pretty_generate(conversation)}" }
286
+ @executor.logger.debug { "Final conversation structure: #{JsonHandler.pretty_generate!(conversation)}" }
287
287
  conversation
288
288
  end
289
289
 
@@ -299,10 +299,10 @@ module ClaudeSwarm
299
299
 
300
300
  begin
301
301
  # Parse arguments
302
- tool_args = JSON.parse(tool_args_str)
302
+ tool_args = JsonHandler.parse!(tool_args_str)
303
303
 
304
304
  # Log tool execution
305
- @executor.logger.info { "Responses API - Executing tool: #{tool_name} with args: #{JSON.pretty_generate(tool_args)}" }
305
+ @executor.logger.info { "Responses API - Executing tool: #{tool_name} with args: #{JsonHandler.pretty_generate!(tool_args)}" }
306
306
 
307
307
  # Execute tool via MCP
308
308
  result = @mcp_client.call_tool(tool_name, tool_args)
@@ -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."
@@ -73,6 +72,20 @@ module ClaudeSwarm
73
72
  # Track start time
74
73
  @start_time = Time.now
75
74
 
75
+ begin
76
+ start_internal
77
+ rescue StandardError => e
78
+ # Ensure cleanup happens even on unexpected errors
79
+ cleanup_processes
80
+ cleanup_run_symlink
81
+ cleanup_worktrees
82
+ raise e
83
+ end
84
+ end
85
+
86
+ private
87
+
88
+ def start_internal
76
89
  if @restore_session_path
77
90
  non_interactive_output do
78
91
  puts "šŸ”„ Restoring Claude Swarm: #{@config.swarm_name}"
@@ -115,16 +128,24 @@ module ClaudeSwarm
115
128
 
116
129
  # Setup worktrees if needed
117
130
  if @worktree_manager
118
- non_interactive_output { print("🌳 Setting up Git worktrees...") }
131
+ begin
132
+ non_interactive_output { print("🌳 Setting up Git worktrees...") }
119
133
 
120
- # Get all instances for worktree setup
121
- # Note: instances.values already includes the main instance
122
- all_instances = @config.instances.values
134
+ # Get all instances for worktree setup
135
+ # Note: instances.values already includes the main instance
136
+ all_instances = @config.instances.values
123
137
 
124
- @worktree_manager.setup_worktrees(all_instances)
138
+ @worktree_manager.setup_worktrees(all_instances)
125
139
 
126
- non_interactive_output do
127
- puts "āœ“ Worktrees created with branch: #{@worktree_manager.worktree_name}"
140
+ non_interactive_output do
141
+ puts "āœ“ Worktrees created with branch: #{@worktree_manager.worktree_name}"
142
+ end
143
+ rescue StandardError => e
144
+ non_interactive_output { print("āŒ Failed to setup worktrees: #{e.message}") }
145
+ cleanup_processes
146
+ cleanup_run_symlink
147
+ cleanup_worktrees
148
+ raise
128
149
  end
129
150
  end
130
151
 
@@ -179,15 +200,29 @@ module ClaudeSwarm
179
200
  main_pid_file = File.join(@session_path, "main_pid")
180
201
  File.write(main_pid_file, Process.pid.to_s)
181
202
 
182
- # Execute the main instance - this will cascade to other instances via MCP
183
- Dir.chdir(main_instance[:directory]) do
184
- # Execute before commands if specified
185
- before_commands = @config.before_commands
186
- if before_commands.any? && !@restore_session_path
187
- non_interactive_output do
188
- puts "āš™ļø Executing before commands..."
189
- 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
190
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
224
+
225
+ Dir.chdir(before_commands_dir) do
191
226
  success = execute_before_commands?(before_commands)
192
227
  unless success
193
228
  non_interactive_output { print("āŒ Before commands failed. Aborting swarm launch.") }
@@ -196,12 +231,29 @@ module ClaudeSwarm
196
231
  cleanup_worktrees
197
232
  exit(1)
198
233
  end
234
+ end
199
235
 
236
+ non_interactive_output do
237
+ puts "āœ“ Before commands completed successfully"
238
+ end
239
+
240
+ # Validate directories after before commands have run
241
+ begin
242
+ @config.validate_directories
200
243
  non_interactive_output do
201
- puts "āœ“ Before commands completed successfully"
244
+ puts "āœ“ All directories validated successfully"
202
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)
203
252
  end
253
+ end
204
254
 
255
+ # Execute the main instance - this will cascade to other instances via MCP
256
+ Dir.chdir(main_instance[:directory]) do
205
257
  # Execute main Claude instance with unbundled environment to avoid bundler conflicts
206
258
  # This ensures the main instance runs in a clean environment without inheriting
207
259
  # Claude Swarm's BUNDLE_* environment variables
@@ -227,9 +279,20 @@ module ClaudeSwarm
227
279
  display_summary
228
280
 
229
281
  # Execute after commands if specified
282
+ # Use the same logic as before commands for consistency
230
283
  after_commands = @config.after_commands
231
284
  if after_commands.any? && !@restore_session_path
232
- 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
233
296
  non_interactive_output do
234
297
  print("āš™ļø Executing after commands...")
235
298
  end
@@ -249,8 +312,6 @@ module ClaudeSwarm
249
312
  cleanup_worktrees
250
313
  end
251
314
 
252
- private
253
-
254
315
  def non_interactive_output
255
316
  return if @non_interactive_prompt
256
317
 
@@ -277,7 +338,7 @@ module ClaudeSwarm
277
338
 
278
339
  # Save session metadata
279
340
  metadata_file = File.join(session_path, "session_metadata.json")
280
- File.write(metadata_file, JSON.pretty_generate(build_session_metadata))
341
+ JsonHandler.write_file!(metadata_file, build_session_metadata)
281
342
  end
282
343
 
283
344
  def build_session_metadata
@@ -333,11 +394,11 @@ module ClaudeSwarm
333
394
  metadata_file = File.join(@session_path, "session_metadata.json")
334
395
  return unless File.exist?(metadata_file)
335
396
 
336
- metadata = JSON.parse(File.read(metadata_file))
397
+ metadata = JsonHandler.parse_file!(metadata_file)
337
398
  metadata["end_time"] = end_time.utc.iso8601
338
399
  metadata["duration_seconds"] = (end_time - @start_time).to_i
339
400
 
340
- File.write(metadata_file, JSON.pretty_generate(metadata))
401
+ JsonHandler.write_file!(metadata_file, metadata)
341
402
  rescue StandardError => e
342
403
  non_interactive_output { print("āš ļø Error updating session metadata: #{e.message}") }
343
404
  end
@@ -375,11 +436,12 @@ module ClaudeSwarm
375
436
  def create_run_symlink
376
437
  return unless @session_path
377
438
 
378
- FileUtils.mkdir_p(RUN_DIR)
439
+ run_dir = ClaudeSwarm.joined_run_dir
440
+ FileUtils.mkdir_p(run_dir)
379
441
 
380
442
  # Session ID is the last part of the session path
381
443
  session_id = File.basename(@session_path)
382
- symlink_path = File.join(RUN_DIR, session_id)
444
+ symlink_path = ClaudeSwarm.joined_run_dir(session_id)
383
445
 
384
446
  # Remove stale symlink if exists
385
447
  File.unlink(symlink_path) if File.symlink?(symlink_path)
@@ -395,7 +457,7 @@ module ClaudeSwarm
395
457
  return unless @session_path
396
458
 
397
459
  session_id = File.basename(@session_path)
398
- symlink_path = File.join(RUN_DIR, session_id)
460
+ symlink_path = ClaudeSwarm.joined_run_dir(session_id)
399
461
  File.unlink(symlink_path) if File.symlink?(symlink_path)
400
462
  rescue StandardError
401
463
  # Ignore errors during cleanup
@@ -450,7 +512,7 @@ module ClaudeSwarm
450
512
 
451
513
  # Find the state file for the main instance
452
514
  state_files.each do |state_file|
453
- state_data = JSON.parse(File.read(state_file))
515
+ state_data = JsonHandler.parse_file!(state_file)
454
516
  next unless state_data["instance_name"] == main_instance_name
455
517
 
456
518
  claude_session_id = state_data["claude_session_id"]
@@ -535,7 +597,7 @@ module ClaudeSwarm
535
597
  metadata_file = File.join(session_path, "session_metadata.json")
536
598
  return unless File.exist?(metadata_file)
537
599
 
538
- metadata = JSON.parse(File.read(metadata_file))
600
+ metadata = JsonHandler.parse_file!(metadata_file)
539
601
  worktree_data = metadata["worktree"]
540
602
  return unless worktree_data && worktree_data["enabled"]
541
603
 
@@ -567,13 +629,11 @@ module ClaudeSwarm
567
629
 
568
630
  # Read and process the merged output
569
631
  stdout_and_stderr.each_line do |line|
570
- # Try to parse and prettify JSON lines
571
-
572
- json_data = JSON.parse(line.chomp)
573
- pretty_json = JSON.pretty_generate(json_data)
574
- logger.info { pretty_json }
575
- rescue JSON::ParserError
576
- 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
577
637
  end
578
638
 
579
639
  wait_thr.value
@@ -602,11 +662,11 @@ module ClaudeSwarm
602
662
  line = file.gets
603
663
  if line
604
664
  begin
605
- # Parse JSONL entry
606
- transcript_entry = JSON.parse(line)
665
+ # Parse JSONL entry, silently skip unparseable lines
666
+ transcript_entry = JsonHandler.parse(line)
607
667
 
608
- # Skip summary entries - these are just conversation titles
609
- 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"
610
670
 
611
671
  # Convert to session.log.json format
612
672
  session_entry = convert_transcript_to_session_format(transcript_entry)
@@ -620,8 +680,6 @@ module ClaudeSwarm
620
680
  log_file.puts(session_entry.to_json)
621
681
  end
622
682
  end
623
- rescue JSON::ParserError
624
- # Silently skip unparseable lines
625
683
  rescue StandardError
626
684
  # Silently handle other errors to keep thread running
627
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.10"
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