claude_swarm 0.3.2 → 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.
@@ -3,7 +3,14 @@
3
3
  module ClaudeSwarm
4
4
  class Orchestrator
5
5
  include SystemUtils
6
- RUN_DIR = File.expand_path("~/.claude-swarm/run")
6
+
7
+ attr_reader :config, :session_path, :session_log_path
8
+
9
+ ["INT", "TERM", "QUIT"].each do |signal|
10
+ Signal.trap(signal) do
11
+ puts "\nšŸ›‘ Received #{signal} signal."
12
+ end
13
+ end
7
14
 
8
15
  def initialize(configuration, mcp_generator, vibe: false, prompt: nil, interactive_prompt: nil, stream_logs: false, debug: false,
9
16
  restore_session_path: nil, worktree: nil, session_id: nil)
@@ -15,7 +22,6 @@ module ClaudeSwarm
15
22
  @stream_logs = stream_logs
16
23
  @debug = debug
17
24
  @restore_session_path = restore_session_path
18
- @session_path = nil
19
25
  @provided_session_id = session_id
20
26
  # Store worktree option for later use
21
27
  @worktree_option = worktree
@@ -25,119 +31,143 @@ module ClaudeSwarm
25
31
  @modified_instances = nil
26
32
  # Track start time for runtime calculation
27
33
  @start_time = nil
34
+ # Track transcript tailing thread
35
+ @transcript_thread = nil
28
36
 
29
37
  # Set environment variable for prompt mode to suppress output
30
38
  ENV["CLAUDE_SWARM_PROMPT"] = "1" if @non_interactive_prompt
39
+
40
+ # Initialize session path
41
+ if @restore_session_path
42
+ # Use existing session path for restoration
43
+ @session_path = @restore_session_path
44
+ @session_log_path = File.join(@session_path, "session.log")
45
+ else
46
+ # Generate new session path
47
+ session_params = { working_dir: ClaudeSwarm.root_dir }
48
+ session_params[:session_id] = @provided_session_id if @provided_session_id
49
+ @session_path = SessionPath.generate(**session_params)
50
+ SessionPath.ensure_directory(@session_path)
51
+ @session_log_path = File.join(@session_path, "session.log")
52
+
53
+ # Extract session ID from path (the timestamp part)
54
+ @session_id = File.basename(@session_path)
55
+
56
+ end
57
+ ENV["CLAUDE_SWARM_SESSION_PATH"] = @session_path
58
+ ENV["CLAUDE_SWARM_ROOT_DIR"] = ClaudeSwarm.root_dir
59
+
60
+ # Initialize components that depend on session path
61
+ @process_tracker = ProcessTracker.new(@session_path)
62
+ @settings_generator = SettingsGenerator.new(@config)
63
+
64
+ # Initialize WorktreeManager if needed
65
+ if @needs_worktree_manager && !@restore_session_path
66
+ cli_option = @worktree_option.is_a?(String) && !@worktree_option.empty? ? @worktree_option : nil
67
+ @worktree_manager = WorktreeManager.new(cli_option, session_id: @session_id)
68
+ end
31
69
  end
32
70
 
33
71
  def start
34
72
  # Track start time
35
73
  @start_time = Time.now
36
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
37
89
  if @restore_session_path
38
- unless @non_interactive_prompt
90
+ non_interactive_output do
39
91
  puts "šŸ”„ Restoring Claude Swarm: #{@config.swarm_name}"
40
92
  puts "šŸ˜Ž Vibe mode ON" if @vibe
41
- puts
42
93
  end
43
94
 
44
- # Use existing session path
45
- session_path = @restore_session_path
46
- @session_path = session_path
47
- ENV["CLAUDE_SWARM_SESSION_PATH"] = session_path
48
- ENV["CLAUDE_SWARM_ROOT_DIR"] = ClaudeSwarm.root_dir
49
-
50
95
  # Create run symlink for restored session
51
96
  create_run_symlink
52
97
 
53
- unless @non_interactive_prompt
54
- puts "šŸ“ Using existing session: #{session_path}/"
55
- puts
98
+ non_interactive_output do
99
+ puts "šŸ“ Using existing session: #{@session_path}/"
56
100
  end
57
101
 
58
- # Initialize process tracker
59
- @process_tracker = ProcessTracker.new(session_path)
60
-
61
- # Set up signal handlers to clean up child processes
62
- setup_signal_handlers
63
-
64
102
  # Check if the original session used worktrees
65
- restore_worktrees_if_needed(session_path)
103
+ restore_worktrees_if_needed(@session_path)
66
104
 
67
105
  # Regenerate MCP configurations with session IDs for restoration
68
106
  @generator.generate_all
69
- unless @non_interactive_prompt
107
+ non_interactive_output do
70
108
  puts "āœ“ Regenerated MCP configurations with session IDs"
71
- puts
109
+ end
110
+
111
+ # Generate settings files
112
+ @settings_generator.generate_all
113
+ non_interactive_output do
114
+ puts "āœ“ Generated settings files with hooks"
72
115
  end
73
116
  else
74
- unless @non_interactive_prompt
117
+ non_interactive_output do
75
118
  puts "šŸ Starting Claude Swarm: #{@config.swarm_name}"
76
119
  puts "šŸ˜Ž Vibe mode ON" if @vibe
77
- puts
78
120
  end
79
121
 
80
- # Generate and set session path for all instances
81
- session_path = if @provided_session_id
82
- SessionPath.generate(working_dir: ClaudeSwarm.root_dir, session_id: @provided_session_id)
83
- else
84
- SessionPath.generate(working_dir: ClaudeSwarm.root_dir)
85
- end
86
- SessionPath.ensure_directory(session_path)
87
- @session_path = session_path
88
-
89
- # Extract session ID from path (the timestamp part)
90
- @session_id = File.basename(session_path)
91
-
92
- ENV["CLAUDE_SWARM_SESSION_PATH"] = session_path
93
- ENV["CLAUDE_SWARM_ROOT_DIR"] = ClaudeSwarm.root_dir
94
-
95
122
  # Create run symlink for new session
96
123
  create_run_symlink
97
124
 
98
- unless @non_interactive_prompt
99
- puts "šŸ“ Session files will be saved to: #{session_path}/"
100
- puts
125
+ non_interactive_output do
126
+ puts "šŸ“ Session files will be saved to: #{@session_path}/"
101
127
  end
102
128
 
103
- # Initialize process tracker
104
- @process_tracker = ProcessTracker.new(session_path)
105
-
106
- # Set up signal handlers to clean up child processes
107
- setup_signal_handlers
108
-
109
- # Create WorktreeManager if needed with session ID
110
- if @needs_worktree_manager
111
- cli_option = @worktree_option.is_a?(String) && !@worktree_option.empty? ? @worktree_option : nil
112
- @worktree_manager = WorktreeManager.new(cli_option, session_id: @session_id)
113
- puts "🌳 Setting up Git worktrees..." unless @non_interactive_prompt
129
+ # Setup worktrees if needed
130
+ if @worktree_manager
131
+ begin
132
+ non_interactive_output { print("🌳 Setting up Git worktrees...") }
114
133
 
115
- # Get all instances for worktree setup
116
- # Note: instances.values already includes the main instance
117
- 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
118
137
 
119
- @worktree_manager.setup_worktrees(all_instances)
138
+ @worktree_manager.setup_worktrees(all_instances)
120
139
 
121
- unless @non_interactive_prompt
122
- puts "āœ“ Worktrees created with branch: #{@worktree_manager.worktree_name}"
123
- puts
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
124
149
  end
125
150
  end
126
151
 
127
152
  # Generate all MCP configuration files
128
153
  @generator.generate_all
129
- unless @non_interactive_prompt
154
+ non_interactive_output do
130
155
  puts "āœ“ Generated MCP configurations in session directory"
131
- puts
156
+ end
157
+
158
+ # Generate settings files
159
+ @settings_generator.generate_all
160
+ non_interactive_output do
161
+ puts "āœ“ Generated settings files with hooks"
132
162
  end
133
163
 
134
164
  # Save swarm config path for restoration
135
- save_swarm_config_path(session_path)
165
+ save_swarm_config_path(@session_path)
136
166
  end
137
167
 
138
168
  # Launch the main instance (fetch after worktree setup to get modified paths)
139
169
  main_instance = @config.main_instance_config
140
- unless @non_interactive_prompt
170
+ non_interactive_output do
141
171
  puts "šŸš€ Launching main instance: #{@config.main_instance}"
142
172
  puts " Model: #{main_instance[:model]}"
143
173
  if main_instance[:directories].size == 1
@@ -150,53 +180,89 @@ module ClaudeSwarm
150
180
  puts " Disallowed tools: #{main_instance[:disallowed_tools].join(", ")}" if main_instance[:disallowed_tools]&.any?
151
181
  puts " Connections: #{main_instance[:connections].join(", ")}" if main_instance[:connections].any?
152
182
  puts " šŸ˜Ž Vibe mode ON for this instance" if main_instance[:vibe]
153
- puts
154
183
  end
155
184
 
156
185
  command = build_main_command(main_instance)
157
- if @debug && !@non_interactive_prompt
158
- puts "šŸƒ Running: #{format_command_for_display(command)}"
159
- puts
186
+ if @debug
187
+ non_interactive_output do
188
+ puts "šŸƒ Running: #{format_command_for_display(command)}"
189
+ end
160
190
  end
161
191
 
162
192
  # Start log streaming thread if in non-interactive mode with --stream-logs
163
193
  log_thread = nil
164
194
  log_thread = start_log_streaming if @non_interactive_prompt && @stream_logs
165
195
 
196
+ # Start transcript tailing thread for main instance
197
+ @transcript_thread = start_transcript_tailing
198
+
166
199
  # Write the current process PID (orchestrator) to a file for easy access
167
200
  main_pid_file = File.join(@session_path, "main_pid")
168
201
  File.write(main_pid_file, Process.pid.to_s)
169
202
 
170
- # Execute the main instance - this will cascade to other instances via MCP
171
- Dir.chdir(main_instance[:directory]) do
172
- # Execute before commands if specified
173
- before_commands = @config.before_commands
174
- if before_commands.any? && !@restore_session_path
175
- unless @non_interactive_prompt
176
- puts "āš™ļø Executing before commands..."
177
- puts
178
- 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
179
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
180
226
  success = execute_before_commands?(before_commands)
181
227
  unless success
182
- puts "āŒ Before commands failed. Aborting swarm launch." unless @non_interactive_prompt
228
+ non_interactive_output { print("āŒ Before commands failed. Aborting swarm launch.") }
183
229
  cleanup_processes
184
230
  cleanup_run_symlink
185
231
  cleanup_worktrees
186
232
  exit(1)
187
233
  end
234
+ end
235
+
236
+ non_interactive_output do
237
+ puts "āœ“ Before commands completed successfully"
238
+ end
188
239
 
189
- unless @non_interactive_prompt
190
- puts "āœ“ Before commands completed successfully"
191
- puts
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"
192
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)
193
252
  end
253
+ end
194
254
 
255
+ # Execute the main instance - this will cascade to other instances via MCP
256
+ Dir.chdir(main_instance[:directory]) do
195
257
  # Execute main Claude instance with unbundled environment to avoid bundler conflicts
196
258
  # This ensures the main instance runs in a clean environment without inheriting
197
259
  # Claude Swarm's BUNDLE_* environment variables
198
260
  Bundler.with_unbundled_env do
199
- system!(*command)
261
+ if @non_interactive_prompt
262
+ stream_to_session_log(*command)
263
+ else
264
+ system!(*command)
265
+ end
200
266
  end
201
267
  end
202
268
 
@@ -206,23 +272,36 @@ module ClaudeSwarm
206
272
  log_thread.join
207
273
  end
208
274
 
275
+ # Clean up transcript tailing thread
276
+ cleanup_transcript_thread
277
+
209
278
  # Display runtime and cost summary
210
279
  display_summary
211
280
 
212
281
  # Execute after commands if specified
282
+ # Use the same logic as before commands for consistency
213
283
  after_commands = @config.after_commands
214
284
  if after_commands.any? && !@restore_session_path
215
- Dir.chdir(main_instance[:directory]) do
216
- unless @non_interactive_prompt
217
- puts
218
- puts "āš™ļø Executing after commands..."
219
- puts
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
296
+ non_interactive_output do
297
+ print("āš™ļø Executing after commands...")
220
298
  end
221
299
 
222
300
  success = execute_after_commands?(after_commands)
223
- if !success && !@non_interactive_prompt
224
- puts "āš ļø Some after commands failed"
225
- puts
301
+ unless success
302
+ non_interactive_output do
303
+ puts "āš ļø Some after commands failed"
304
+ end
226
305
  end
227
306
  end
228
307
  end
@@ -233,117 +312,19 @@ module ClaudeSwarm
233
312
  cleanup_worktrees
234
313
  end
235
314
 
236
- private
237
-
238
- def execute_before_commands?(commands)
239
- log_file = File.join(@session_path, "session.log") if @session_path
240
-
241
- commands.each_with_index do |command, index|
242
- # Log the command execution to session log
243
- if @session_path
244
- File.open(log_file, "a") do |f|
245
- f.puts "[#{Time.now.strftime("%Y-%m-%d %H:%M:%S")}] Executing before command #{index + 1}/#{commands.size}: #{command}"
246
- end
247
- end
248
-
249
- # Execute the command and capture output
250
- begin
251
- puts "Debug: Executing command #{index + 1}/#{commands.size}: #{command}" if @debug && !@non_interactive_prompt
252
-
253
- # Use system with output capture
254
- output = %x(#{command} 2>&1)
255
- success = $CHILD_STATUS.success?
256
-
257
- # Log the output
258
- if @session_path
259
- File.open(log_file, "a") do |f|
260
- f.puts "Command output:"
261
- f.puts output
262
- f.puts "Exit status: #{$CHILD_STATUS.exitstatus}"
263
- f.puts "-" * 80
264
- end
265
- end
266
-
267
- # Show output if in debug mode or if command failed
268
- if (@debug || !success) && !@non_interactive_prompt
269
- puts "Command #{index + 1} output:"
270
- puts output
271
- puts "Exit status: #{$CHILD_STATUS.exitstatus}"
272
- end
315
+ def non_interactive_output
316
+ return if @non_interactive_prompt
273
317
 
274
- unless success
275
- puts "āŒ Before command #{index + 1} failed: #{command}" unless @non_interactive_prompt
276
- return false
277
- end
278
- rescue StandardError => e
279
- puts "Error executing before command #{index + 1}: #{e.message}" unless @non_interactive_prompt
280
- if @session_path
281
- File.open(log_file, "a") do |f|
282
- f.puts "Error: #{e.message}"
283
- f.puts "-" * 80
284
- end
285
- end
286
- return false
287
- end
288
- end
318
+ yield
319
+ puts
320
+ end
289
321
 
290
- true
322
+ def execute_before_commands?(commands)
323
+ execute_commands(commands, phase: "before", fail_fast: true)
291
324
  end
292
325
 
293
326
  def execute_after_commands?(commands)
294
- log_file = File.join(@session_path, "session.log") if @session_path
295
- all_succeeded = true
296
-
297
- commands.each_with_index do |command, index|
298
- # Log the command execution to session log
299
- if @session_path
300
- File.open(log_file, "a") do |f|
301
- f.puts "[#{Time.now.strftime("%Y-%m-%d %H:%M:%S")}] Executing after command #{index + 1}/#{commands.size}: #{command}"
302
- end
303
- end
304
-
305
- # Execute the command and capture output
306
- begin
307
- puts "Debug: Executing after command #{index + 1}/#{commands.size}: #{command}" if @debug && !@non_interactive_prompt
308
-
309
- # Use system with output capture
310
- output = %x(#{command} 2>&1)
311
- success = $CHILD_STATUS.success?
312
-
313
- # Log the output
314
- if @session_path
315
- File.open(log_file, "a") do |f|
316
- f.puts "Command output:"
317
- f.puts output
318
- f.puts "Exit status: #{$CHILD_STATUS.exitstatus}"
319
- f.puts "-" * 80
320
- end
321
- end
322
-
323
- # Show output if in debug mode or if command failed
324
- if (@debug || !success) && !@non_interactive_prompt
325
- puts "After command #{index + 1} output:"
326
- puts output
327
- puts "Exit status: #{$CHILD_STATUS.exitstatus}"
328
- end
329
-
330
- unless success
331
- puts "āŒ After command #{index + 1} failed: #{command}" unless @non_interactive_prompt
332
- all_succeeded = false
333
- end
334
- rescue StandardError => e
335
- puts "Error executing after command #{index + 1}: #{e.message}" unless @non_interactive_prompt
336
- if @session_path
337
- File.open(log_file, "a") do |f|
338
- f.puts "Error: #{e.message}"
339
- f.puts "-" * 80
340
- end
341
- end
342
- all_succeeded = false
343
- end
344
- end
345
-
346
- all_succeeded
327
+ execute_commands(commands, phase: "after", fail_fast: false)
347
328
  end
348
329
 
349
330
  def save_swarm_config_path(session_path)
@@ -356,58 +337,33 @@ module ClaudeSwarm
356
337
  File.write(root_dir_file, ClaudeSwarm.root_dir)
357
338
 
358
339
  # Save session metadata
359
- metadata = {
340
+ metadata_file = File.join(session_path, "session_metadata.json")
341
+ JsonHandler.write_file!(metadata_file, build_session_metadata)
342
+ end
343
+
344
+ def build_session_metadata
345
+ {
360
346
  "root_directory" => ClaudeSwarm.root_dir,
361
347
  "timestamp" => Time.now.utc.iso8601,
362
348
  "start_time" => @start_time.utc.iso8601,
363
349
  "swarm_name" => @config.swarm_name,
364
350
  "claude_swarm_version" => VERSION,
365
- }
366
-
367
- # Add worktree info if applicable
368
- metadata["worktree"] = @worktree_manager.session_metadata if @worktree_manager
369
-
370
- metadata_file = File.join(session_path, "session_metadata.json")
371
- File.write(metadata_file, JSON.pretty_generate(metadata))
372
- end
373
-
374
- def setup_signal_handlers
375
- ["INT", "TERM", "QUIT"].each do |signal|
376
- Signal.trap(signal) do
377
- puts "\nšŸ›‘ Received #{signal} signal, cleaning up..."
378
- display_summary
379
-
380
- # Execute after commands if configured
381
- main_instance = @config.main_instance_config
382
- after_commands = @config.after_commands
383
- if after_commands.any? && !@restore_session_path && !@non_interactive_prompt
384
- Dir.chdir(main_instance[:directory]) do
385
- puts
386
- puts "āš™ļø Executing after commands..."
387
- puts
388
- execute_after_commands?(after_commands)
389
- end
390
- end
391
-
392
- cleanup_processes
393
- cleanup_run_symlink
394
- cleanup_worktrees
395
- exit
396
- end
351
+ }.tap do |metadata|
352
+ # Add worktree info if applicable
353
+ metadata["worktree"] = @worktree_manager.session_metadata if @worktree_manager
397
354
  end
398
355
  end
399
356
 
400
357
  def cleanup_processes
401
358
  @process_tracker.cleanup_all
359
+ cleanup_transcript_thread
402
360
  puts "āœ“ Cleanup complete"
403
361
  rescue StandardError => e
404
362
  puts "āš ļø Error during cleanup: #{e.message}"
405
363
  end
406
364
 
407
365
  def cleanup_worktrees
408
- return unless @worktree_manager
409
-
410
- @worktree_manager.cleanup_worktrees
366
+ @worktree_manager&.cleanup_worktrees
411
367
  rescue StandardError => e
412
368
  puts "āš ļø Error during worktree cleanup: #{e.message}"
413
369
  end
@@ -438,13 +394,13 @@ module ClaudeSwarm
438
394
  metadata_file = File.join(@session_path, "session_metadata.json")
439
395
  return unless File.exist?(metadata_file)
440
396
 
441
- metadata = JSON.parse(File.read(metadata_file))
397
+ metadata = JsonHandler.parse_file!(metadata_file)
442
398
  metadata["end_time"] = end_time.utc.iso8601
443
399
  metadata["duration_seconds"] = (end_time - @start_time).to_i
444
400
 
445
- File.write(metadata_file, JSON.pretty_generate(metadata))
401
+ JsonHandler.write_file!(metadata_file, metadata)
446
402
  rescue StandardError => e
447
- puts "āš ļø Error updating session metadata: #{e.message}" unless @non_interactive_prompt
403
+ non_interactive_output { print("āš ļø Error updating session metadata: #{e.message}") }
448
404
  end
449
405
 
450
406
  def calculate_total_cost
@@ -480,11 +436,12 @@ module ClaudeSwarm
480
436
  def create_run_symlink
481
437
  return unless @session_path
482
438
 
483
- FileUtils.mkdir_p(RUN_DIR)
439
+ run_dir = ClaudeSwarm.joined_run_dir
440
+ FileUtils.mkdir_p(run_dir)
484
441
 
485
442
  # Session ID is the last part of the session path
486
443
  session_id = File.basename(@session_path)
487
- symlink_path = File.join(RUN_DIR, session_id)
444
+ symlink_path = ClaudeSwarm.joined_run_dir(session_id)
488
445
 
489
446
  # Remove stale symlink if exists
490
447
  File.unlink(symlink_path) if File.symlink?(symlink_path)
@@ -493,14 +450,14 @@ module ClaudeSwarm
493
450
  File.symlink(@session_path, symlink_path)
494
451
  rescue StandardError => e
495
452
  # Don't fail the process if symlink creation fails
496
- puts "āš ļø Warning: Could not create run symlink: #{e.message}" unless @non_interactive_prompt
453
+ non_interactive_output { print("āš ļø Warning: Could not create run symlink: #{e.message}") }
497
454
  end
498
455
 
499
456
  def cleanup_run_symlink
500
457
  return unless @session_path
501
458
 
502
459
  session_id = File.basename(@session_path)
503
- symlink_path = File.join(RUN_DIR, session_id)
460
+ symlink_path = ClaudeSwarm.joined_run_dir(session_id)
504
461
  File.unlink(symlink_path) if File.symlink?(symlink_path)
505
462
  rescue StandardError
506
463
  # Ignore errors during cleanup
@@ -508,15 +465,11 @@ module ClaudeSwarm
508
465
 
509
466
  def start_log_streaming
510
467
  Thread.new do
511
- session_log_path = File.join(ENV.fetch("CLAUDE_SWARM_SESSION_PATH", nil), "session.log")
512
-
513
468
  # Wait for log file to be created
514
- sleep(0.1) until File.exist?(session_log_path)
469
+ sleep(0.1) until File.exist?(@session_log_path)
515
470
 
516
471
  # Open file and seek to end
517
- File.open(session_log_path, "r") do |file|
518
- file.seek(0, IO::SEEK_END)
519
-
472
+ File.open(@session_log_path, "r") do |file|
520
473
  loop do
521
474
  changes = file.read
522
475
  if changes
@@ -543,11 +496,13 @@ module ClaudeSwarm
543
496
  end
544
497
 
545
498
  def build_main_command(instance)
546
- parts = [
547
- "claude",
548
- "--model",
549
- instance[:model],
550
- ]
499
+ parts = ["claude"]
500
+
501
+ # Only add --model if ANTHROPIC_MODEL env var is not set
502
+ unless ENV["ANTHROPIC_MODEL"]
503
+ parts << "--model"
504
+ parts << instance[:model]
505
+ end
551
506
 
552
507
  # Add resume flag if restoring session
553
508
  if @restore_session_path
@@ -557,7 +512,7 @@ module ClaudeSwarm
557
512
 
558
513
  # Find the state file for the main instance
559
514
  state_files.each do |state_file|
560
- state_data = JSON.parse(File.read(state_file))
515
+ state_data = JsonHandler.parse_file!(state_file)
561
516
  next unless state_data["instance_name"] == main_instance_name
562
517
 
563
518
  claude_session_id = state_data["claude_session_id"]
@@ -615,11 +570,20 @@ module ClaudeSwarm
615
570
  parts << "--mcp-config"
616
571
  parts << mcp_config_path
617
572
 
573
+ # Add settings file if it exists for the main instance
574
+ settings_file = @settings_generator.settings_path(@config.main_instance)
575
+ if File.exist?(settings_file)
576
+ parts << "--settings"
577
+ parts << settings_file
578
+ end
579
+
618
580
  # Handle different modes
619
581
  if @non_interactive_prompt
620
582
  # Non-interactive mode with -p
621
583
  parts << "-p"
622
584
  parts << @non_interactive_prompt
585
+ parts << "--verbose"
586
+ parts << "--output-format=stream-json"
623
587
  elsif @interactive_prompt
624
588
  # Interactive mode with initial prompt (no -p flag)
625
589
  parts << @interactive_prompt
@@ -633,13 +597,12 @@ module ClaudeSwarm
633
597
  metadata_file = File.join(session_path, "session_metadata.json")
634
598
  return unless File.exist?(metadata_file)
635
599
 
636
- metadata = JSON.parse(File.read(metadata_file))
600
+ metadata = JsonHandler.parse_file!(metadata_file)
637
601
  worktree_data = metadata["worktree"]
638
602
  return unless worktree_data && worktree_data["enabled"]
639
603
 
640
- unless @non_interactive_prompt
604
+ non_interactive_output do
641
605
  puts "🌳 Restoring Git worktrees..."
642
- puts
643
606
  end
644
607
 
645
608
  # Restore worktrees using the saved configuration
@@ -651,10 +614,242 @@ module ClaudeSwarm
651
614
  all_instances = @config.instances.values
652
615
  @worktree_manager.setup_worktrees(all_instances)
653
616
 
654
- return if @non_interactive_prompt
617
+ non_interactive_output do
618
+ puts "āœ“ Worktrees restored with branch: #{@worktree_manager.worktree_name}"
619
+ end
620
+ end
655
621
 
656
- puts "āœ“ Worktrees restored with branch: #{@worktree_manager.worktree_name}"
657
- puts
622
+ def stream_to_session_log(*command)
623
+ # Setup logger for session logging
624
+ logger = Logger.new(@session_log_path, level: :info, progname: @config.main_instance)
625
+
626
+ # Use Open3.popen2e to capture stdout and stderr merged for formatting
627
+ Open3.popen2e(*command) do |stdin, stdout_and_stderr, wait_thr|
628
+ stdin.close
629
+
630
+ # Read and process the merged output
631
+ stdout_and_stderr.each_line do |line|
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
637
+ end
638
+
639
+ wait_thr.value
640
+ end
641
+ end
642
+
643
+ def start_transcript_tailing
644
+ Thread.new do
645
+ path_file = File.join(@session_path, "main_instance_transcript.path")
646
+
647
+ # Wait for path file to exist (created by SessionStart hook)
648
+ sleep(0.5) until File.exist?(path_file)
649
+
650
+ # Read the transcript path
651
+ transcript_path = File.read(path_file).strip
652
+
653
+ # Wait for transcript file to exist
654
+ sleep(0.5) until File.exist?(transcript_path)
655
+
656
+ # Tail the transcript file continuously (like tail -f)
657
+ File.open(transcript_path, "r") do |file|
658
+ # Start from the beginning to capture all entries
659
+ file.seek(0, IO::SEEK_SET) # Start at beginning of file
660
+
661
+ loop do
662
+ line = file.gets
663
+ if line
664
+ begin
665
+ # Parse JSONL entry, silently skip unparseable lines
666
+ transcript_entry = JsonHandler.parse(line)
667
+
668
+ # Skip if parsing failed or if it's a summary entry
669
+ next if transcript_entry == line || transcript_entry["type"] == "summary"
670
+
671
+ # Convert to session.log.json format
672
+ session_entry = convert_transcript_to_session_format(transcript_entry)
673
+
674
+ # Only write if we got a valid conversion (skips summary and other non-relevant entries)
675
+ if session_entry
676
+ # Write with file locking (same pattern as BaseExecutor)
677
+ session_json_path = File.join(@session_path, "session.log.json")
678
+ File.open(session_json_path, File::WRONLY | File::APPEND | File::CREAT) do |log_file|
679
+ log_file.flock(File::LOCK_EX)
680
+ log_file.puts(session_entry.to_json)
681
+ end
682
+ end
683
+ rescue StandardError
684
+ # Silently handle other errors to keep thread running
685
+ end
686
+ else
687
+ # No new data, sleep briefly
688
+ sleep(0.1)
689
+ end
690
+ end
691
+ end
692
+ rescue StandardError
693
+ # Silently handle thread errors
694
+ end
695
+ end
696
+
697
+ def convert_transcript_to_session_format(transcript_entry)
698
+ # Skip if no type
699
+ return unless transcript_entry["type"]
700
+
701
+ instance_name = @config.main_instance
702
+ instance_id = "main"
703
+ timestamp = transcript_entry["timestamp"] || Time.now.iso8601
704
+
705
+ case transcript_entry["type"]
706
+ when "user"
707
+ # User message - format as request from user to main instance
708
+ message = transcript_entry["message"]
709
+
710
+ # Extract prompt text - message might be a string or an object
711
+ prompt_text = if message.is_a?(String)
712
+ message
713
+ elsif message.is_a?(Hash)
714
+ content = message["content"]
715
+ if content.is_a?(String)
716
+ content
717
+ elsif content.is_a?(Array)
718
+ # For tool results or complex content, extract text
719
+ extract_text_from_array(content)
720
+ else
721
+ ""
722
+ end
723
+ else
724
+ ""
725
+ end
726
+
727
+ {
728
+ instance: instance_name,
729
+ instance_id: instance_id,
730
+ timestamp: timestamp,
731
+ event: {
732
+ type: "request",
733
+ from_instance: "user",
734
+ from_instance_id: "user",
735
+ to_instance: instance_name,
736
+ to_instance_id: instance_id,
737
+ prompt: prompt_text,
738
+ timestamp: timestamp,
739
+ },
740
+ }
741
+ when "assistant"
742
+ # Assistant message - format as assistant response
743
+ message = transcript_entry["message"]
744
+
745
+ # Build a clean message structure without transcript-specific fields
746
+ clean_message = {
747
+ "type" => "message",
748
+ "role" => "assistant",
749
+ }
750
+
751
+ # Handle different message formats
752
+ if message.is_a?(String)
753
+ # Simple string message
754
+ clean_message["content"] = [{ "type" => "text", "text" => message }]
755
+ elsif message.is_a?(Hash)
756
+ # Only include the fields that other instances include
757
+ clean_message["content"] = message["content"] if message["content"]
758
+ clean_message["model"] = message["model"] if message["model"]
759
+ clean_message["usage"] = message["usage"] if message["usage"]
760
+ end
761
+
762
+ {
763
+ instance: instance_name,
764
+ instance_id: instance_id,
765
+ timestamp: timestamp,
766
+ event: {
767
+ type: "assistant",
768
+ message: clean_message,
769
+ session_id: transcript_entry["sessionId"],
770
+ },
771
+ }
772
+ end
773
+ # For other types (like summary), return nil to skip them
774
+ end
775
+
776
+ def extract_text_from_array(content)
777
+ content.map do |item|
778
+ if item.is_a?(Hash)
779
+ item["text"] || item["content"] || ""
780
+ else
781
+ item.to_s
782
+ end
783
+ end.join("\n")
784
+ end
785
+
786
+ def cleanup_transcript_thread
787
+ return unless @transcript_thread
788
+
789
+ @transcript_thread.terminate if @transcript_thread.alive?
790
+ @transcript_thread.join(1) # Wait up to 1 second for thread to finish
791
+ rescue StandardError => e
792
+ logger.error { "Error cleaning up transcript thread: #{e.message}" }
793
+ end
794
+
795
+ def logger
796
+ @logger ||= Logger.new(File.join(@session_path, "session.log"), level: :info, progname: "orchestrator")
797
+ end
798
+
799
+ def execute_commands(commands, phase:, fail_fast:)
800
+ all_succeeded = true
801
+
802
+ # Setup logger for session logging if we have a session path
803
+ logger = Logger.new(@session_log_path, level: :info)
804
+
805
+ commands.each_with_index do |command, index|
806
+ # Log the command execution to session log
807
+ logger.info { "Executing #{phase} command #{index + 1}/#{commands.size}: #{command}" }
808
+
809
+ # Execute the command and capture output
810
+ begin
811
+ if @debug
812
+ non_interactive_output do
813
+ debug_prefix = phase == "after" ? "after " : ""
814
+ print("Debug: Executing #{debug_prefix} command #{index + 1}/#{commands.size}: #{format_command_for_display(command)}")
815
+ end
816
+ end
817
+
818
+ output = %x(#{command} 2>&1)
819
+ success = $CHILD_STATUS.success?
820
+ output_separator = "-" * 80
821
+
822
+ logger.info { "Command output:" }
823
+ logger.info { output }
824
+ logger.info { "Exit status: #{$CHILD_STATUS.exitstatus}" }
825
+ logger.info { output_separator }
826
+
827
+ # Show output if in debug mode or if command failed
828
+ if @debug || !success
829
+ non_interactive_output do
830
+ output_prefix = phase == "after" ? "After command" : "Command"
831
+ puts "#{output_prefix} #{index + 1} output:"
832
+ puts output
833
+ print("Exit status: #{$CHILD_STATUS.exitstatus}")
834
+ end
835
+ end
836
+
837
+ unless success
838
+ error_prefix = phase.capitalize
839
+ non_interactive_output { print("āŒ #{error_prefix} command #{index + 1} failed: #{command}") }
840
+ all_succeeded = false
841
+ return false if fail_fast
842
+ end
843
+ rescue StandardError => e
844
+ non_interactive_output { print("Error executing #{phase} command #{index + 1}: #{e.message}") }
845
+ logger.info { "Error: #{e.message}" }
846
+ logger.info { output_separator }
847
+ all_succeeded = false
848
+ return false if fail_fast
849
+ end
850
+ end
851
+
852
+ all_succeeded
658
853
  end
659
854
  end
660
855
  end