claude_swarm 0.1.20 → 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 (39) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +9 -66
  3. data/.rubocop_todo.yml +11 -0
  4. data/CHANGELOG.md +93 -0
  5. data/CLAUDE.md +61 -0
  6. data/README.md +172 -15
  7. data/Rakefile +1 -1
  8. data/examples/mixed-provider-swarm.yml +23 -0
  9. data/lib/claude_swarm/claude_code_executor.rb +7 -12
  10. data/lib/claude_swarm/claude_mcp_server.rb +26 -12
  11. data/lib/claude_swarm/cli.rb +293 -165
  12. data/lib/claude_swarm/commands/ps.rb +22 -24
  13. data/lib/claude_swarm/commands/show.rb +45 -63
  14. data/lib/claude_swarm/configuration.rb +137 -8
  15. data/lib/claude_swarm/mcp_generator.rb +39 -14
  16. data/lib/claude_swarm/openai/chat_completion.rb +264 -0
  17. data/lib/claude_swarm/openai/executor.rb +301 -0
  18. data/lib/claude_swarm/openai/responses.rb +338 -0
  19. data/lib/claude_swarm/orchestrator.rb +205 -39
  20. data/lib/claude_swarm/process_tracker.rb +7 -7
  21. data/lib/claude_swarm/session_cost_calculator.rb +93 -0
  22. data/lib/claude_swarm/session_path.rb +3 -5
  23. data/lib/claude_swarm/system_utils.rb +1 -3
  24. data/lib/claude_swarm/tools/reset_session_tool.rb +24 -0
  25. data/lib/claude_swarm/tools/session_info_tool.rb +24 -0
  26. data/lib/claude_swarm/tools/task_tool.rb +43 -0
  27. data/lib/claude_swarm/version.rb +1 -1
  28. data/lib/claude_swarm/worktree_manager.rb +13 -20
  29. data/lib/claude_swarm.rb +23 -10
  30. data/single.yml +482 -6
  31. metadata +50 -16
  32. data/claude-swarm.yml +0 -64
  33. data/lib/claude_swarm/reset_session_tool.rb +0 -22
  34. data/lib/claude_swarm/session_info_tool.rb +0 -22
  35. data/lib/claude_swarm/task_tool.rb +0 -39
  36. /data/{example → examples}/claude-swarm.yml +0 -0
  37. /data/{example → examples}/microservices-team.yml +0 -0
  38. /data/{example → examples}/session-restoration-demo.yml +0 -0
  39. /data/{example → examples}/test-generation.yml +0 -0
@@ -0,0 +1,338 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ClaudeSwarm
4
+ module OpenAI
5
+ class Responses
6
+ MAX_TURNS_WITH_TOOLS = 100_000 # virtually infinite
7
+
8
+ def initialize(openai_client:, mcp_client:, available_tools:, logger:, instance_name:, model:, temperature: nil, reasoning_effort: nil)
9
+ @openai_client = openai_client
10
+ @mcp_client = mcp_client
11
+ @available_tools = available_tools
12
+ @executor = logger # This is actually the executor, not a logger
13
+ @instance_name = instance_name
14
+ @model = model
15
+ @temperature = temperature
16
+ @reasoning_effort = reasoning_effort
17
+ @system_prompt = nil
18
+ end
19
+
20
+ def execute(prompt, options = {})
21
+ # Store system prompt for first call
22
+ @system_prompt = options[:system_prompt] if options[:system_prompt]
23
+
24
+ # Start with initial prompt
25
+ initial_input = prompt
26
+
27
+ # Process with recursive tool handling - start with empty conversation
28
+ process_responses_api(initial_input, [], nil)
29
+ end
30
+
31
+ def reset_session
32
+ @system_prompt = nil
33
+ end
34
+
35
+ private
36
+
37
+ def process_responses_api(input, conversation_array, previous_response_id, depth = 0)
38
+ # Prevent infinite recursion
39
+ if depth > MAX_TURNS_WITH_TOOLS
40
+ @executor.error("Maximum recursion depth reached in tool execution")
41
+ return "Error: Maximum tool call depth exceeded"
42
+ end
43
+
44
+ # Build parameters
45
+ parameters = {
46
+ model: @model,
47
+ }
48
+
49
+ # Only add temperature for non-o-series models
50
+ # O-series models don't support temperature parameter
51
+ unless @model.match?(ClaudeSwarm::Configuration::O_SERIES_MODEL_PATTERN)
52
+ parameters[:temperature] = @temperature
53
+ end
54
+
55
+ # Only add reasoning effort for o-series models
56
+ # reasoning is only supported by o-series models: o1, o1 Preview, o1-mini, o1-pro, o3, o3-mini, o3-pro, o3-deep-research, o4-mini, o4-mini-deep-research, etc.
57
+ if @reasoning_effort && @model.match?(ClaudeSwarm::Configuration::O_SERIES_MODEL_PATTERN)
58
+ parameters[:reasoning] = { effort: @reasoning_effort }
59
+ end
60
+
61
+ # On first call, use string input (can include system prompt)
62
+ # On subsequent calls with function results, use array input
63
+ if conversation_array.empty?
64
+ # Initial call - string input
65
+ parameters[:input] = if depth.zero? && @system_prompt
66
+ "#{@system_prompt}\n\n#{input}"
67
+ else
68
+ input
69
+ end
70
+ else
71
+ # Follow-up call with conversation array (function calls + outputs)
72
+ parameters[:input] = conversation_array
73
+
74
+ # Log conversation array to debug duplicates
75
+ @executor.info("Conversation array size: #{conversation_array.size}")
76
+ conversation_ids = conversation_array.map do |item|
77
+ item["call_id"] || item["id"] || "no-id-#{item["type"]}"
78
+ end.compact
79
+ @executor.info("Conversation item IDs: #{conversation_ids.inspect}")
80
+ end
81
+
82
+ # Add previous response ID for conversation continuity
83
+ parameters[:previous_response_id] = previous_response_id if previous_response_id
84
+
85
+ # Add tools if available
86
+ if @available_tools&.any?
87
+ # Convert tools to responses API format
88
+ parameters[:tools] = @available_tools.map do |tool|
89
+ {
90
+ "type" => "function",
91
+ "name" => tool.name,
92
+ "description" => tool.description,
93
+ "parameters" => tool.schema || {},
94
+ }
95
+ end
96
+ @executor.info("Available tools for responses API: #{parameters[:tools].map { |t| t["name"] }.join(", ")}")
97
+ end
98
+
99
+ # Log the request parameters
100
+ @executor.info("Responses API Request (depth=#{depth}): #{JSON.pretty_generate(parameters)}")
101
+
102
+ # Append to session JSON
103
+ append_to_session_json({
104
+ type: "openai_request",
105
+ api: "responses",
106
+ depth: depth,
107
+ parameters: parameters,
108
+ })
109
+
110
+ # Make the API call without streaming
111
+ begin
112
+ response = @openai_client.responses.create(parameters: parameters)
113
+ rescue StandardError => e
114
+ @executor.error("Responses API error: #{e.class} - #{e.message}")
115
+ @executor.error("Request parameters: #{JSON.pretty_generate(parameters)}")
116
+
117
+ # Try to extract and log the response body for better debugging
118
+ if e.respond_to?(:response)
119
+ begin
120
+ error_body = e.response[:body]
121
+ @executor.error("Error response body: #{error_body}")
122
+ rescue StandardError => parse_error
123
+ @executor.error("Could not parse error response: #{parse_error.message}")
124
+ end
125
+ end
126
+
127
+ # Log error to session JSON
128
+ append_to_session_json({
129
+ type: "openai_error",
130
+ api: "responses",
131
+ error: {
132
+ class: e.class.to_s,
133
+ message: e.message,
134
+ response_body: e.respond_to?(:response) ? e.response[:body] : nil,
135
+ backtrace: e.backtrace.first(5),
136
+ },
137
+ })
138
+
139
+ return "Error calling OpenAI responses API: #{e.message}"
140
+ end
141
+
142
+ # Log the full response
143
+ @executor.info("Responses API Full Response (depth=#{depth}): #{JSON.pretty_generate(response)}")
144
+
145
+ # Append to session JSON
146
+ append_to_session_json({
147
+ type: "openai_response",
148
+ api: "responses",
149
+ depth: depth,
150
+ response: response,
151
+ })
152
+
153
+ # Extract response details
154
+ response_id = response["id"]
155
+
156
+ # Handle response based on output structure
157
+ output = response["output"]
158
+
159
+ if output.nil?
160
+ @executor.error("No output in response")
161
+ return "Error: No output in OpenAI response"
162
+ end
163
+
164
+ # Check if output is an array (as per documentation)
165
+ if output.is_a?(Array) && !output.empty?
166
+ # Check if there are function calls
167
+ function_calls = output.select { |item| item["type"] == "function_call" }
168
+
169
+ if function_calls.any?
170
+ # Check if we already have a conversation going
171
+ if conversation_array.empty?
172
+ # First depth - build new conversation
173
+ new_conversation = build_conversation_with_outputs(function_calls)
174
+ else
175
+ # Subsequent depth - append to existing conversation
176
+ # Don't re-add function calls, just add the new ones and their outputs
177
+ new_conversation = conversation_array.dup
178
+ append_new_outputs(function_calls, new_conversation)
179
+ end
180
+
181
+ # Recursively process with updated conversation
182
+ process_responses_api(nil, new_conversation, response_id, depth + 1)
183
+ else
184
+ # Look for text response
185
+ extract_text_response(output)
186
+ end
187
+ else
188
+ @executor.error("Unexpected output format: #{output.inspect}")
189
+ "Error: Unexpected response format"
190
+ end
191
+ end
192
+
193
+ def extract_text_response(output)
194
+ text_output = output.find { |item| item["content"] }
195
+ return "" unless text_output && text_output["content"].is_a?(Array)
196
+
197
+ text_content = text_output["content"].find { |item| item["text"] }
198
+ text_content ? text_content["text"] : ""
199
+ end
200
+
201
+ def build_conversation_with_outputs(function_calls)
202
+ # Log tool calls
203
+ @executor.info("Responses API - Handling #{function_calls.size} function calls")
204
+
205
+ # Log IDs to check for duplicates
206
+ call_ids = function_calls.map { |fc| fc["call_id"] || fc["id"] }
207
+ @executor.info("Function call IDs: #{call_ids.inspect}")
208
+ @executor.warn("WARNING: Duplicate function call IDs detected!") if call_ids.size != call_ids.uniq.size
209
+
210
+ # Append to session JSON
211
+ append_to_session_json({
212
+ type: "tool_calls",
213
+ api: "responses",
214
+ tool_calls: function_calls,
215
+ })
216
+
217
+ # Build conversation array with function outputs only
218
+ # The API already knows about the function calls from the previous response
219
+ conversation = []
220
+
221
+ # Then execute tools and add outputs
222
+ function_calls.each do |function_call|
223
+ tool_name = function_call["name"]
224
+ tool_args_str = function_call["arguments"]
225
+ # Use the call_id field for matching with function outputs
226
+ call_id = function_call["call_id"]
227
+
228
+ # Log both IDs to debug
229
+ @executor.info("Function call has id=#{function_call["id"]}, call_id=#{function_call["call_id"]}")
230
+
231
+ begin
232
+ # Parse arguments
233
+ tool_args = JSON.parse(tool_args_str)
234
+
235
+ # Log tool execution
236
+ @executor.info("Responses API - Executing tool: #{tool_name} with args: #{JSON.pretty_generate(tool_args)}")
237
+
238
+ # Execute tool via MCP
239
+ result = @mcp_client.call_tool(tool_name, tool_args)
240
+
241
+ # Log result
242
+ @executor.info("Responses API - Tool result for #{tool_name}: #{result}")
243
+
244
+ # Append to session JSON
245
+ append_to_session_json({
246
+ type: "tool_execution",
247
+ api: "responses",
248
+ tool_name: tool_name,
249
+ arguments: tool_args,
250
+ result: result.to_s,
251
+ })
252
+
253
+ # Add function output to conversation
254
+ conversation << {
255
+ type: "function_call_output",
256
+ call_id: call_id,
257
+ output: result.to_json, # Must be JSON string
258
+ }
259
+ rescue StandardError => e
260
+ @executor.error("Responses API - Tool execution failed for #{tool_name}: #{e.message}")
261
+ @executor.error(e.backtrace.join("\n"))
262
+
263
+ # Append error to session JSON
264
+ append_to_session_json({
265
+ type: "tool_error",
266
+ api: "responses",
267
+ tool_name: tool_name,
268
+ arguments: tool_args_str,
269
+ error: {
270
+ class: e.class.to_s,
271
+ message: e.message,
272
+ backtrace: e.backtrace.first(5),
273
+ },
274
+ })
275
+
276
+ # Add error output to conversation
277
+ conversation << {
278
+ type: "function_call_output",
279
+ call_id: call_id,
280
+ output: { error: e.message }.to_json,
281
+ }
282
+ end
283
+ end
284
+
285
+ @executor.info("Responses API - Built conversation with #{conversation.size} function outputs")
286
+ @executor.debug("Final conversation structure: #{JSON.pretty_generate(conversation)}")
287
+ conversation
288
+ end
289
+
290
+ def append_new_outputs(function_calls, conversation)
291
+ # Only add the new function outputs
292
+ # Don't add function calls - the API already knows about them
293
+
294
+ function_calls.each do |fc|
295
+ # Execute and add output only
296
+ tool_name = fc["name"]
297
+ tool_args_str = fc["arguments"]
298
+ call_id = fc["call_id"]
299
+
300
+ begin
301
+ # Parse arguments
302
+ tool_args = JSON.parse(tool_args_str)
303
+
304
+ # Log tool execution
305
+ @executor.info("Responses API - Executing tool: #{tool_name} with args: #{JSON.pretty_generate(tool_args)}")
306
+
307
+ # Execute tool via MCP
308
+ result = @mcp_client.call_tool(tool_name, tool_args)
309
+
310
+ # Log result
311
+ @executor.info("Responses API - Tool result for #{tool_name}: #{result}")
312
+
313
+ # Add function output to conversation
314
+ conversation << {
315
+ type: "function_call_output",
316
+ call_id: call_id,
317
+ output: result.to_json, # Must be JSON string
318
+ }
319
+ rescue StandardError => e
320
+ @executor.error("Responses API - Tool execution failed for #{tool_name}: #{e.message}")
321
+
322
+ # Add error output to conversation
323
+ conversation << {
324
+ type: "function_call_output",
325
+ call_id: call_id,
326
+ output: { error: e.message }.to_json,
327
+ }
328
+ end
329
+ end
330
+ end
331
+
332
+ def append_to_session_json(event)
333
+ # Delegate to the executor's log method
334
+ @executor.log(event) if @executor.respond_to?(:log)
335
+ end
336
+ end
337
+ end
338
+ end
@@ -1,17 +1,12 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "English"
4
- require "shellwords"
5
- require "json"
6
- require "fileutils"
7
-
8
3
  module ClaudeSwarm
9
4
  class Orchestrator
10
5
  include SystemUtils
11
6
  RUN_DIR = File.expand_path("~/.claude-swarm/run")
12
7
 
13
8
  def initialize(configuration, mcp_generator, vibe: false, prompt: nil, stream_logs: false, debug: false,
14
- restore_session_path: nil, worktree: nil)
9
+ restore_session_path: nil, worktree: nil, session_id: nil)
15
10
  @config = configuration
16
11
  @generator = mcp_generator
17
12
  @vibe = vibe
@@ -20,18 +15,24 @@ module ClaudeSwarm
20
15
  @debug = debug
21
16
  @restore_session_path = restore_session_path
22
17
  @session_path = nil
18
+ @provided_session_id = session_id
23
19
  # Store worktree option for later use
24
20
  @worktree_option = worktree
25
21
  @needs_worktree_manager = worktree.is_a?(String) || worktree == "" ||
26
- configuration.instances.values.any? { |inst| !inst[:worktree].nil? }
22
+ configuration.instances.values.any? { |inst| !inst[:worktree].nil? }
27
23
  # Store modified instances after worktree setup
28
24
  @modified_instances = nil
25
+ # Track start time for runtime calculation
26
+ @start_time = nil
29
27
 
30
28
  # Set environment variable for prompt mode to suppress output
31
29
  ENV["CLAUDE_SWARM_PROMPT"] = "1" if @prompt
32
30
  end
33
31
 
34
32
  def start
33
+ # Track start time
34
+ @start_time = Time.now
35
+
35
36
  if @restore_session_path
36
37
  unless @prompt
37
38
  puts "🔄 Restoring Claude Swarm: #{@config.swarm_name}"
@@ -76,7 +77,11 @@ module ClaudeSwarm
76
77
  end
77
78
 
78
79
  # Generate and set session path for all instances
79
- 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
80
85
  SessionPath.ensure_directory(session_path)
81
86
  @session_path = session_path
82
87
 
@@ -129,29 +134,6 @@ module ClaudeSwarm
129
134
  save_swarm_config_path(session_path)
130
135
  end
131
136
 
132
- # Execute before commands if specified
133
- before_commands = @config.before_commands
134
- if before_commands.any? && !@restore_session_path
135
- unless @prompt
136
- puts "⚙️ Executing before commands..."
137
- puts
138
- end
139
-
140
- success = execute_before_commands(before_commands)
141
- unless success
142
- puts "❌ Before commands failed. Aborting swarm launch." unless @prompt
143
- cleanup_processes
144
- cleanup_run_symlink
145
- cleanup_worktrees
146
- exit 1
147
- end
148
-
149
- unless @prompt
150
- puts "✓ Before commands completed successfully"
151
- puts
152
- end
153
- end
154
-
155
137
  # Launch the main instance (fetch after worktree setup to get modified paths)
156
138
  main_instance = @config.main_instance_config
157
139
  unless @prompt
@@ -180,8 +162,35 @@ module ClaudeSwarm
180
162
  log_thread = nil
181
163
  log_thread = start_log_streaming if @prompt && @stream_logs
182
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
+
183
169
  # Execute the main instance - this will cascade to other instances via MCP
184
170
  Dir.chdir(main_instance[:directory]) do
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
+
185
194
  system!(*command)
186
195
  end
187
196
 
@@ -191,6 +200,27 @@ module ClaudeSwarm
191
200
  log_thread.join
192
201
  end
193
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
+
194
224
  # Clean up child processes and run symlink
195
225
  cleanup_processes
196
226
  cleanup_run_symlink
@@ -199,7 +229,7 @@ module ClaudeSwarm
199
229
 
200
230
  private
201
231
 
202
- def execute_before_commands(commands)
232
+ def execute_before_commands?(commands)
203
233
  log_file = File.join(@session_path, "session.log") if @session_path
204
234
 
205
235
  commands.each_with_index do |command, index|
@@ -215,7 +245,7 @@ module ClaudeSwarm
215
245
  puts "Debug: Executing command #{index + 1}/#{commands.size}: #{command}" if @debug && !@prompt
216
246
 
217
247
  # Use system with output capture
218
- output = `#{command} 2>&1`
248
+ output = %x(#{command} 2>&1)
219
249
  success = $CHILD_STATUS.success?
220
250
 
221
251
  # Log the output
@@ -254,6 +284,62 @@ module ClaudeSwarm
254
284
  true
255
285
  end
256
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
+
257
343
  def save_swarm_config_path(session_path)
258
344
  # Copy the YAML config file to the session directory
259
345
  config_copy_path = File.join(session_path, "config.yml")
@@ -267,8 +353,9 @@ module ClaudeSwarm
267
353
  metadata = {
268
354
  "start_directory" => Dir.pwd,
269
355
  "timestamp" => Time.now.utc.iso8601,
356
+ "start_time" => @start_time.utc.iso8601,
270
357
  "swarm_name" => @config.swarm_name,
271
- "claude_swarm_version" => VERSION
358
+ "claude_swarm_version" => VERSION,
272
359
  }
273
360
 
274
361
  # Add worktree info if applicable
@@ -279,9 +366,23 @@ module ClaudeSwarm
279
366
  end
280
367
 
281
368
  def setup_signal_handlers
282
- %w[INT TERM QUIT].each do |signal|
369
+ ["INT", "TERM", "QUIT"].each do |signal|
283
370
  Signal.trap(signal) do
284
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
+
285
386
  cleanup_processes
286
387
  cleanup_run_symlink
287
388
  cleanup_worktrees
@@ -305,6 +406,71 @@ module ClaudeSwarm
305
406
  puts "⚠️ Error during worktree cleanup: #{e.message}"
306
407
  end
307
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
+
308
474
  def create_run_symlink
309
475
  return unless @session_path
310
476
 
@@ -339,7 +505,7 @@ module ClaudeSwarm
339
505
  session_log_path = File.join(ENV.fetch("CLAUDE_SWARM_SESSION_PATH", nil), "session.log")
340
506
 
341
507
  # Wait for log file to be created
342
- sleep 0.1 until File.exist?(session_log_path)
508
+ sleep(0.1) until File.exist?(session_log_path)
343
509
 
344
510
  # Open file and seek to end
345
511
  File.open(session_log_path, "r") do |file|
@@ -348,10 +514,10 @@ module ClaudeSwarm
348
514
  loop do
349
515
  changes = file.read
350
516
  if changes
351
- print changes
517
+ print(changes)
352
518
  $stdout.flush
353
519
  else
354
- sleep 0.1
520
+ sleep(0.1)
355
521
  end
356
522
  end
357
523
  end
@@ -374,7 +540,7 @@ module ClaudeSwarm
374
540
  parts = [
375
541
  "claude",
376
542
  "--model",
377
- instance[:model]
543
+ instance[:model],
378
544
  ]
379
545
 
380
546
  # Add resume flag if restoring session