claude_swarm 0.1.20 → 0.2.1
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.
- checksums.yaml +4 -4
- data/.rubocop.yml +9 -66
- data/.rubocop_todo.yml +11 -0
- data/CHANGELOG.md +106 -0
- data/CLAUDE.md +61 -0
- data/README.md +174 -16
- data/Rakefile +1 -1
- data/examples/mixed-provider-swarm.yml +23 -0
- data/lib/claude_swarm/claude_code_executor.rb +7 -12
- data/lib/claude_swarm/claude_mcp_server.rb +26 -12
- data/lib/claude_swarm/cli.rb +293 -165
- data/lib/claude_swarm/commands/ps.rb +22 -24
- data/lib/claude_swarm/commands/show.rb +45 -63
- data/lib/claude_swarm/configuration.rb +161 -8
- data/lib/claude_swarm/mcp_generator.rb +39 -14
- data/lib/claude_swarm/openai/chat_completion.rb +264 -0
- data/lib/claude_swarm/openai/executor.rb +301 -0
- data/lib/claude_swarm/openai/responses.rb +338 -0
- data/lib/claude_swarm/orchestrator.rb +205 -39
- data/lib/claude_swarm/process_tracker.rb +7 -7
- data/lib/claude_swarm/session_cost_calculator.rb +93 -0
- data/lib/claude_swarm/session_path.rb +3 -5
- data/lib/claude_swarm/system_utils.rb +1 -3
- data/lib/claude_swarm/tools/reset_session_tool.rb +24 -0
- data/lib/claude_swarm/tools/session_info_tool.rb +24 -0
- data/lib/claude_swarm/tools/task_tool.rb +43 -0
- data/lib/claude_swarm/version.rb +1 -1
- data/lib/claude_swarm/worktree_manager.rb +39 -22
- data/lib/claude_swarm.rb +23 -10
- data/single.yml +481 -6
- metadata +54 -14
- data/claude-swarm.yml +0 -64
- data/lib/claude_swarm/reset_session_tool.rb +0 -22
- data/lib/claude_swarm/session_info_tool.rb +0 -22
- data/lib/claude_swarm/task_tool.rb +0 -39
- /data/{example → examples}/claude-swarm.yml +0 -0
- /data/{example → examples}/microservices-team.yml +0 -0
- /data/{example → examples}/session-restoration-demo.yml +0 -0
- /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
|
-
|
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
|
-
|
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 =
|
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 =
|
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
|
-
|
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
|
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
|
517
|
+
print(changes)
|
352
518
|
$stdout.flush
|
353
519
|
else
|
354
|
-
sleep
|
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
|