claude_swarm 0.3.8 → 0.3.9
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/CHANGELOG.md +18 -0
- data/lib/claude_swarm/cli.rb +6 -1
- data/lib/claude_swarm/hooks/session_start_hook.rb +42 -0
- data/lib/claude_swarm/openai/executor.rb +120 -82
- data/lib/claude_swarm/orchestrator.rb +125 -38
- data/lib/claude_swarm/settings_generator.rb +30 -7
- data/lib/claude_swarm/templates/generation_prompt.md.erb +4 -7
- data/lib/claude_swarm/version.rb +1 -1
- data/lib/claude_swarm.rb +0 -2
- metadata +16 -1
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 55711fd9483c68d996c0a0e448c4d31135d7423269d1ed691bb3590db5487627
|
4
|
+
data.tar.gz: a84c0b4e1165057cbbe22bcd4d6c02c8fcdaedb5abb2f18e150b667ee0083385
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: cc2808da58a6cb2b2ac545cfbe4803382602d4339017337d3af84b92274baab241952186f7de4e72adfbd413d0fcb448b955fb4cb7ebb5ebc014b90895988247
|
7
|
+
data.tar.gz: 2d58c3795f4ea80da550c6241ad7639ffa5d25fa868eb9af18bf58382961f8cad4fb930616c133197858193fb2f9627aead812bfd60a3565631ef38398351b16
|
data/CHANGELOG.md
CHANGED
@@ -1,3 +1,13 @@
|
|
1
|
+
## [0.3.9]
|
2
|
+
|
3
|
+
### Added
|
4
|
+
- **Main instance transcript integration**: Main Claude instance activity is now captured in session.log.json during interactive mode
|
5
|
+
- Automatically configures SessionStart hook for main instance to capture transcript path
|
6
|
+
- Background thread continuously tails transcript file and integrates entries into session.log.json
|
7
|
+
- Filters out summary entries to avoid duplicate conversation titles
|
8
|
+
- Uses file locking for thread-safe writes to maintain consistency
|
9
|
+
- Provides complete session history including main instance interactions
|
10
|
+
|
1
11
|
## [0.3.8]
|
2
12
|
|
3
13
|
### Added
|
@@ -9,6 +19,14 @@
|
|
9
19
|
- Connected instances receive hooks via SDK's `settings` attribute
|
10
20
|
- Full environment variable interpolation support in hook configurations
|
11
21
|
- See README.md "Hooks Configuration" section for usage examples
|
22
|
+
- **Persistent HTTP connections for OpenAI**: Added `faraday-net_http_persistent` dependency and configured OpenAI client to use persistent connections
|
23
|
+
- Improves performance when making multiple API requests by reusing HTTP connections
|
24
|
+
- Automatically configured for all OpenAI instances
|
25
|
+
|
26
|
+
### Changed
|
27
|
+
- **Improved OpenAI executor code organization**: Refactored internal methods for better maintainability
|
28
|
+
- Extracted configuration building and response handling into focused private methods
|
29
|
+
- Improved code readability with functional patterns
|
12
30
|
|
13
31
|
### Fixed
|
14
32
|
- **Settings integration**: Fixed passing settings to Claude instances
|
data/lib/claude_swarm/cli.rb
CHANGED
@@ -693,7 +693,12 @@ module ClaudeSwarm
|
|
693
693
|
def build_generation_prompt(readme_content, output_file)
|
694
694
|
template_path = File.expand_path("templates/generation_prompt.md.erb", __dir__)
|
695
695
|
template = File.read(template_path)
|
696
|
-
|
696
|
+
<<~PROMPT
|
697
|
+
#{ERB.new(template, trim_mode: "-").result(binding)}
|
698
|
+
|
699
|
+
Start the conversation by greeting the user and asking: 'What kind of project would you like to create a Claude Swarm for?'
|
700
|
+
Say: 'I am ready to start'
|
701
|
+
PROMPT
|
697
702
|
end
|
698
703
|
end
|
699
704
|
end
|
@@ -0,0 +1,42 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
# frozen_string_literal: true
|
3
|
+
|
4
|
+
# This hook is called when Claude Code starts a session
|
5
|
+
# It saves the transcript path for the main instance so the orchestrator can tail it
|
6
|
+
|
7
|
+
require "json"
|
8
|
+
require "fileutils"
|
9
|
+
|
10
|
+
# Read input from stdin
|
11
|
+
begin
|
12
|
+
stdin_data = $stdin.read
|
13
|
+
input = JSON.parse(stdin_data)
|
14
|
+
rescue => e
|
15
|
+
# Return error response
|
16
|
+
puts JSON.generate({
|
17
|
+
"success" => false,
|
18
|
+
"error" => "Failed to read/parse input: #{e.message}",
|
19
|
+
})
|
20
|
+
exit(1)
|
21
|
+
end
|
22
|
+
|
23
|
+
# Get session path from command-line argument or environment
|
24
|
+
session_path = ARGV[0] || ENV["CLAUDE_SWARM_SESSION_PATH"]
|
25
|
+
|
26
|
+
if session_path && input["transcript_path"]
|
27
|
+
# Write the transcript path to a known location
|
28
|
+
path_file = File.join(session_path, "main_instance_transcript.path")
|
29
|
+
File.write(path_file, input["transcript_path"])
|
30
|
+
|
31
|
+
# Return success
|
32
|
+
puts JSON.generate({
|
33
|
+
"success" => true,
|
34
|
+
})
|
35
|
+
else
|
36
|
+
# Return error if missing required data
|
37
|
+
puts JSON.generate({
|
38
|
+
"success" => false,
|
39
|
+
"error" => "Missing session path or transcript path",
|
40
|
+
})
|
41
|
+
exit(1)
|
42
|
+
end
|
@@ -1,8 +1,32 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
|
+
require "openai"
|
4
|
+
require "faraday/net_http_persistent"
|
5
|
+
require "faraday/retry"
|
6
|
+
|
3
7
|
module ClaudeSwarm
|
4
8
|
module OpenAI
|
5
9
|
class Executor < BaseExecutor
|
10
|
+
# Static configuration for Faraday retry middleware
|
11
|
+
FARADAY_RETRY_CONFIG = {
|
12
|
+
max: 3, # Maximum number of retries
|
13
|
+
interval: 0.5, # Initial delay between retries (in seconds)
|
14
|
+
interval_randomness: 0.5, # Randomness factor for retry intervals
|
15
|
+
backoff_factor: 2, # Exponential backoff factor
|
16
|
+
exceptions: [
|
17
|
+
Faraday::TimeoutError,
|
18
|
+
Faraday::ConnectionFailed,
|
19
|
+
Faraday::ServerError, # Retry on 5xx errors
|
20
|
+
].freeze,
|
21
|
+
retry_statuses: [429, 500, 502, 503, 504].freeze, # HTTP status codes to retry
|
22
|
+
}.freeze
|
23
|
+
|
24
|
+
# Static configuration for OpenAI client
|
25
|
+
OPENAI_CLIENT_CONFIG = {
|
26
|
+
log_errors: true,
|
27
|
+
request_timeout: 1800, # 30 minutes
|
28
|
+
}.freeze
|
29
|
+
|
6
30
|
def initialize(working_directory: Dir.pwd, model: nil, mcp_config: nil, vibe: false,
|
7
31
|
instance_name: nil, instance_id: nil, calling_instance: nil, calling_instance_id: nil,
|
8
32
|
claude_session_id: nil, additional_directories: [], debug: false,
|
@@ -56,19 +80,8 @@ module ClaudeSwarm
|
|
56
80
|
# Calculate duration
|
57
81
|
duration_ms = ((Time.now - start_time) * 1000).round
|
58
82
|
|
59
|
-
#
|
60
|
-
|
61
|
-
"type" => "result",
|
62
|
-
"result" => result,
|
63
|
-
"duration_ms" => duration_ms,
|
64
|
-
"total_cost" => calculate_cost(result),
|
65
|
-
"session_id" => @session_id,
|
66
|
-
}
|
67
|
-
|
68
|
-
log_response(response)
|
69
|
-
|
70
|
-
@last_response = response
|
71
|
-
response
|
83
|
+
# Build and return response
|
84
|
+
build_response(result, duration_ms)
|
72
85
|
rescue StandardError => e
|
73
86
|
logger.error { "Unexpected error for #{@instance_name}: #{e.class} - #{e.message}" }
|
74
87
|
logger.error { "Backtrace: #{e.backtrace.join("\n")}" }
|
@@ -92,35 +105,14 @@ module ClaudeSwarm
|
|
92
105
|
private
|
93
106
|
|
94
107
|
def setup_openai_client(token_env)
|
95
|
-
|
96
|
-
|
97
|
-
|
98
|
-
|
99
|
-
|
100
|
-
config[:uri_base] = @base_url if @base_url
|
108
|
+
openai_client_config = build_openai_client_config(token_env)
|
109
|
+
|
110
|
+
@openai_client = ::OpenAI::Client.new(openai_client_config) do |faraday|
|
111
|
+
# Use persistent HTTP connections for better performance
|
112
|
+
faraday.adapter(:net_http_persistent)
|
101
113
|
|
102
|
-
@openai_client = ::OpenAI::Client.new(config) do |faraday|
|
103
114
|
# Add retry middleware with custom configuration
|
104
|
-
faraday.request(
|
105
|
-
:retry,
|
106
|
-
max: 3, # Maximum number of retries
|
107
|
-
interval: 0.5, # Initial delay between retries (in seconds)
|
108
|
-
interval_randomness: 0.5, # Randomness factor for retry intervals
|
109
|
-
backoff_factor: 2, # Exponential backoff factor
|
110
|
-
exceptions: [
|
111
|
-
Faraday::TimeoutError,
|
112
|
-
Faraday::ConnectionFailed,
|
113
|
-
Faraday::ServerError, # Retry on 5xx errors
|
114
|
-
],
|
115
|
-
retry_statuses: [429, 500, 502, 503, 504], # HTTP status codes to retry
|
116
|
-
retry_block: lambda do |env:, options:, retry_count:, exception:, will_retry:|
|
117
|
-
if will_retry
|
118
|
-
@logger.warn("Request failed (attempt #{retry_count}/#{options.max}): #{exception&.message || "HTTP #{env.status}"}. Retrying in #{options.interval * (options.backoff_factor**(retry_count - 1))} seconds...")
|
119
|
-
else
|
120
|
-
@logger.warn("Request failed after #{retry_count} attempts: #{exception&.message || "HTTP #{env.status}"}. Giving up.")
|
121
|
-
end
|
122
|
-
end,
|
123
|
-
)
|
115
|
+
faraday.request(:retry, **build_faraday_retry_config)
|
124
116
|
end
|
125
117
|
rescue KeyError
|
126
118
|
raise ExecutionError, "OpenAI API key not found in environment variable: #{token_env}"
|
@@ -132,49 +124,21 @@ module ClaudeSwarm
|
|
132
124
|
# Read MCP config to find MCP servers
|
133
125
|
mcp_data = JSON.parse(File.read(@mcp_config))
|
134
126
|
|
135
|
-
#
|
136
|
-
|
137
|
-
|
138
|
-
|
139
|
-
|
140
|
-
|
141
|
-
|
142
|
-
|
143
|
-
|
144
|
-
|
145
|
-
|
146
|
-
|
147
|
-
command: command_array,
|
148
|
-
name: name,
|
149
|
-
)
|
150
|
-
stdio_config[:read_timeout] = 1800
|
151
|
-
mcp_configs << stdio_config
|
152
|
-
when "sse"
|
153
|
-
logger.warn { "SSE MCP servers not yet supported for OpenAI instances: #{name}" }
|
154
|
-
# TODO: Add SSE support when available in ruby-mcp-client
|
155
|
-
end
|
156
|
-
end
|
127
|
+
# Build MCP configurations from servers
|
128
|
+
mcp_configs = build_mcp_configs(mcp_data["mcpServers"])
|
129
|
+
return if mcp_configs.empty?
|
130
|
+
|
131
|
+
# Create MCP client with unbundled environment to avoid bundler conflicts
|
132
|
+
# This ensures MCP servers run in a clean environment without inheriting
|
133
|
+
# Claude Swarm's BUNDLE_* environment variables
|
134
|
+
Bundler.with_unbundled_env do
|
135
|
+
@mcp_client = MCPClient.create_client(
|
136
|
+
mcp_server_configs: mcp_configs,
|
137
|
+
logger: @logger,
|
138
|
+
)
|
157
139
|
|
158
|
-
|
159
|
-
|
160
|
-
# This ensures MCP servers run in a clean environment without inheriting
|
161
|
-
# Claude Swarm's BUNDLE_* environment variables
|
162
|
-
Bundler.with_unbundled_env do
|
163
|
-
@mcp_client = MCPClient.create_client(
|
164
|
-
mcp_server_configs: mcp_configs,
|
165
|
-
logger: @logger,
|
166
|
-
)
|
167
|
-
|
168
|
-
# List available tools from all MCP servers
|
169
|
-
begin
|
170
|
-
@available_tools = @mcp_client.list_tools
|
171
|
-
logger.info { "Loaded #{@available_tools.size} tools from #{mcp_configs.size} MCP server(s)" }
|
172
|
-
rescue StandardError => e
|
173
|
-
logger.error { "Failed to load MCP tools: #{e.message}" }
|
174
|
-
@available_tools = []
|
175
|
-
end
|
176
|
-
end
|
177
|
-
end
|
140
|
+
# List available tools from all MCP servers
|
141
|
+
load_mcp_tools(mcp_configs)
|
178
142
|
end
|
179
143
|
rescue StandardError => e
|
180
144
|
logger.error { "Failed to setup MCP client: #{e.message}" }
|
@@ -211,6 +175,80 @@ module ClaudeSwarm
|
|
211
175
|
# Log streaming content similar to ClaudeCodeExecutor
|
212
176
|
logger.debug { "#{instance_info} streaming: #{content}" }
|
213
177
|
end
|
178
|
+
|
179
|
+
def build_faraday_retry_config
|
180
|
+
FARADAY_RETRY_CONFIG.merge(
|
181
|
+
retry_block: method(:handle_retry_logging),
|
182
|
+
)
|
183
|
+
end
|
184
|
+
|
185
|
+
def handle_retry_logging(env:, options:, retry_count:, exception:, will_retry:)
|
186
|
+
retry_delay = options.interval * (options.backoff_factor**(retry_count - 1))
|
187
|
+
error_info = exception&.message || "HTTP #{env.status}"
|
188
|
+
|
189
|
+
message = if will_retry
|
190
|
+
"Request failed (attempt #{retry_count}/#{options.max}): #{error_info}. Retrying in #{retry_delay} seconds..."
|
191
|
+
else
|
192
|
+
"Request failed after #{retry_count} attempts: #{error_info}. Giving up."
|
193
|
+
end
|
194
|
+
|
195
|
+
@logger.warn(message)
|
196
|
+
end
|
197
|
+
|
198
|
+
def build_openai_client_config(token_env)
|
199
|
+
OPENAI_CLIENT_CONFIG.merge(access_token: ENV.fetch(token_env)).tap do |config|
|
200
|
+
config[:uri_base] = @base_url if @base_url
|
201
|
+
end
|
202
|
+
end
|
203
|
+
|
204
|
+
def build_stdio_config(name, server_config)
|
205
|
+
# Combine command and args into a single array
|
206
|
+
command_array = [server_config["command"]]
|
207
|
+
command_array.concat(server_config["args"] || [])
|
208
|
+
|
209
|
+
MCPClient.stdio_config(
|
210
|
+
command: command_array,
|
211
|
+
name: name,
|
212
|
+
).tap do |config|
|
213
|
+
config[:read_timeout] = 1800
|
214
|
+
end
|
215
|
+
end
|
216
|
+
|
217
|
+
def build_mcp_configs(mcp_servers)
|
218
|
+
return [] if mcp_servers.nil? || mcp_servers.empty?
|
219
|
+
|
220
|
+
mcp_servers.filter_map do |name, server_config|
|
221
|
+
case server_config["type"]
|
222
|
+
when "stdio"
|
223
|
+
build_stdio_config(name, server_config)
|
224
|
+
when "sse"
|
225
|
+
logger.warn { "SSE MCP servers not yet supported for OpenAI instances: #{name}" }
|
226
|
+
# TODO: Add SSE support when available in ruby-mcp-client
|
227
|
+
nil
|
228
|
+
end
|
229
|
+
end
|
230
|
+
end
|
231
|
+
|
232
|
+
def load_mcp_tools(mcp_configs)
|
233
|
+
@available_tools = @mcp_client.list_tools
|
234
|
+
logger.info { "Loaded #{@available_tools.size} tools from #{mcp_configs.size} MCP server(s)" }
|
235
|
+
rescue StandardError => e
|
236
|
+
logger.error { "Failed to load MCP tools: #{e.message}" }
|
237
|
+
@available_tools = []
|
238
|
+
end
|
239
|
+
|
240
|
+
def build_response(result, duration_ms)
|
241
|
+
{
|
242
|
+
"type" => "result",
|
243
|
+
"result" => result,
|
244
|
+
"duration_ms" => duration_ms,
|
245
|
+
"total_cost" => calculate_cost(result),
|
246
|
+
"session_id" => @session_id,
|
247
|
+
}.tap do |response|
|
248
|
+
log_response(response)
|
249
|
+
@last_response = response
|
250
|
+
end
|
251
|
+
end
|
214
252
|
end
|
215
253
|
end
|
216
254
|
end
|
@@ -17,15 +17,12 @@ module ClaudeSwarm
|
|
17
17
|
restore_session_path: nil, worktree: nil, session_id: nil)
|
18
18
|
@config = configuration
|
19
19
|
@generator = mcp_generator
|
20
|
-
@settings_generator = SettingsGenerator.new(configuration)
|
21
20
|
@vibe = vibe
|
22
21
|
@non_interactive_prompt = prompt
|
23
22
|
@interactive_prompt = interactive_prompt
|
24
23
|
@stream_logs = stream_logs
|
25
24
|
@debug = debug
|
26
25
|
@restore_session_path = restore_session_path
|
27
|
-
@session_path = nil
|
28
|
-
@session_log_path = nil
|
29
26
|
@provided_session_id = session_id
|
30
27
|
# Store worktree option for later use
|
31
28
|
@worktree_option = worktree
|
@@ -35,9 +32,41 @@ module ClaudeSwarm
|
|
35
32
|
@modified_instances = nil
|
36
33
|
# Track start time for runtime calculation
|
37
34
|
@start_time = nil
|
35
|
+
# Track transcript tailing thread
|
36
|
+
@transcript_thread = nil
|
38
37
|
|
39
38
|
# Set environment variable for prompt mode to suppress output
|
40
39
|
ENV["CLAUDE_SWARM_PROMPT"] = "1" if @non_interactive_prompt
|
40
|
+
|
41
|
+
# Initialize session path
|
42
|
+
if @restore_session_path
|
43
|
+
# Use existing session path for restoration
|
44
|
+
@session_path = @restore_session_path
|
45
|
+
@session_log_path = File.join(@session_path, "session.log")
|
46
|
+
else
|
47
|
+
# Generate new session path
|
48
|
+
session_params = { working_dir: ClaudeSwarm.root_dir }
|
49
|
+
session_params[:session_id] = @provided_session_id if @provided_session_id
|
50
|
+
@session_path = SessionPath.generate(**session_params)
|
51
|
+
SessionPath.ensure_directory(@session_path)
|
52
|
+
@session_log_path = File.join(@session_path, "session.log")
|
53
|
+
|
54
|
+
# Extract session ID from path (the timestamp part)
|
55
|
+
@session_id = File.basename(@session_path)
|
56
|
+
|
57
|
+
end
|
58
|
+
ENV["CLAUDE_SWARM_SESSION_PATH"] = @session_path
|
59
|
+
ENV["CLAUDE_SWARM_ROOT_DIR"] = ClaudeSwarm.root_dir
|
60
|
+
|
61
|
+
# Initialize components that depend on session path
|
62
|
+
@process_tracker = ProcessTracker.new(@session_path)
|
63
|
+
@settings_generator = SettingsGenerator.new(@config)
|
64
|
+
|
65
|
+
# Initialize WorktreeManager if needed
|
66
|
+
if @needs_worktree_manager && !@restore_session_path
|
67
|
+
cli_option = @worktree_option.is_a?(String) && !@worktree_option.empty? ? @worktree_option : nil
|
68
|
+
@worktree_manager = WorktreeManager.new(cli_option, session_id: @session_id)
|
69
|
+
end
|
41
70
|
end
|
42
71
|
|
43
72
|
def start
|
@@ -50,25 +79,15 @@ module ClaudeSwarm
|
|
50
79
|
puts "😎 Vibe mode ON" if @vibe
|
51
80
|
end
|
52
81
|
|
53
|
-
# Use existing session path
|
54
|
-
session_path = @restore_session_path
|
55
|
-
@session_path = session_path
|
56
|
-
@session_log_path = File.join(@session_path, "session.log")
|
57
|
-
ENV["CLAUDE_SWARM_SESSION_PATH"] = session_path
|
58
|
-
ENV["CLAUDE_SWARM_ROOT_DIR"] = ClaudeSwarm.root_dir
|
59
|
-
|
60
82
|
# Create run symlink for restored session
|
61
83
|
create_run_symlink
|
62
84
|
|
63
85
|
non_interactive_output do
|
64
|
-
puts "📝 Using existing session: #{session_path}/"
|
86
|
+
puts "📝 Using existing session: #{@session_path}/"
|
65
87
|
end
|
66
88
|
|
67
|
-
# Initialize process tracker
|
68
|
-
@process_tracker = ProcessTracker.new(session_path)
|
69
|
-
|
70
89
|
# Check if the original session used worktrees
|
71
|
-
restore_worktrees_if_needed(session_path)
|
90
|
+
restore_worktrees_if_needed(@session_path)
|
72
91
|
|
73
92
|
# Regenerate MCP configurations with session IDs for restoration
|
74
93
|
@generator.generate_all
|
@@ -87,34 +106,15 @@ module ClaudeSwarm
|
|
87
106
|
puts "😎 Vibe mode ON" if @vibe
|
88
107
|
end
|
89
108
|
|
90
|
-
# Generate and set session path for all instances
|
91
|
-
session_params = { working_dir: ClaudeSwarm.root_dir }
|
92
|
-
session_params[:session_id] = @provided_session_id if @provided_session_id
|
93
|
-
session_path = SessionPath.generate(**session_params)
|
94
|
-
SessionPath.ensure_directory(session_path)
|
95
|
-
@session_path = session_path
|
96
|
-
@session_log_path = File.join(@session_path, "session.log")
|
97
|
-
|
98
|
-
# Extract session ID from path (the timestamp part)
|
99
|
-
@session_id = File.basename(session_path)
|
100
|
-
|
101
|
-
ENV["CLAUDE_SWARM_SESSION_PATH"] = session_path
|
102
|
-
ENV["CLAUDE_SWARM_ROOT_DIR"] = ClaudeSwarm.root_dir
|
103
|
-
|
104
109
|
# Create run symlink for new session
|
105
110
|
create_run_symlink
|
106
111
|
|
107
112
|
non_interactive_output do
|
108
|
-
puts "📝 Session files will be saved to: #{session_path}/"
|
113
|
+
puts "📝 Session files will be saved to: #{@session_path}/"
|
109
114
|
end
|
110
115
|
|
111
|
-
#
|
112
|
-
@
|
113
|
-
|
114
|
-
# Create WorktreeManager if needed with session ID
|
115
|
-
if @needs_worktree_manager
|
116
|
-
cli_option = @worktree_option.is_a?(String) && !@worktree_option.empty? ? @worktree_option : nil
|
117
|
-
@worktree_manager = WorktreeManager.new(cli_option, session_id: @session_id)
|
116
|
+
# Setup worktrees if needed
|
117
|
+
if @worktree_manager
|
118
118
|
non_interactive_output { print("🌳 Setting up Git worktrees...") }
|
119
119
|
|
120
120
|
# Get all instances for worktree setup
|
@@ -141,7 +141,7 @@ module ClaudeSwarm
|
|
141
141
|
end
|
142
142
|
|
143
143
|
# Save swarm config path for restoration
|
144
|
-
save_swarm_config_path(session_path)
|
144
|
+
save_swarm_config_path(@session_path)
|
145
145
|
end
|
146
146
|
|
147
147
|
# Launch the main instance (fetch after worktree setup to get modified paths)
|
@@ -172,6 +172,9 @@ module ClaudeSwarm
|
|
172
172
|
log_thread = nil
|
173
173
|
log_thread = start_log_streaming if @non_interactive_prompt && @stream_logs
|
174
174
|
|
175
|
+
# Start transcript tailing thread for main instance
|
176
|
+
@transcript_thread = start_transcript_tailing
|
177
|
+
|
175
178
|
# Write the current process PID (orchestrator) to a file for easy access
|
176
179
|
main_pid_file = File.join(@session_path, "main_pid")
|
177
180
|
File.write(main_pid_file, Process.pid.to_s)
|
@@ -217,6 +220,9 @@ module ClaudeSwarm
|
|
217
220
|
log_thread.join
|
218
221
|
end
|
219
222
|
|
223
|
+
# Clean up transcript tailing thread
|
224
|
+
cleanup_transcript_thread
|
225
|
+
|
220
226
|
# Display runtime and cost summary
|
221
227
|
display_summary
|
222
228
|
|
@@ -289,6 +295,7 @@ module ClaudeSwarm
|
|
289
295
|
|
290
296
|
def cleanup_processes
|
291
297
|
@process_tracker.cleanup_all
|
298
|
+
cleanup_transcript_thread
|
292
299
|
puts "✓ Cleanup complete"
|
293
300
|
rescue StandardError => e
|
294
301
|
puts "⚠️ Error during cleanup: #{e.message}"
|
@@ -576,6 +583,86 @@ module ClaudeSwarm
|
|
576
583
|
end
|
577
584
|
end
|
578
585
|
|
586
|
+
def start_transcript_tailing
|
587
|
+
Thread.new do
|
588
|
+
path_file = File.join(@session_path, "main_instance_transcript.path")
|
589
|
+
|
590
|
+
# Wait for path file to exist (created by SessionStart hook)
|
591
|
+
sleep(0.5) until File.exist?(path_file)
|
592
|
+
|
593
|
+
# Read the transcript path
|
594
|
+
transcript_path = File.read(path_file).strip
|
595
|
+
|
596
|
+
# Wait for transcript file to exist
|
597
|
+
sleep(0.5) until File.exist?(transcript_path)
|
598
|
+
|
599
|
+
# Tail the transcript file continuously (like tail -f)
|
600
|
+
File.open(transcript_path, "r") do |file|
|
601
|
+
# Start from the beginning to capture all entries
|
602
|
+
file.seek(0, IO::SEEK_SET) # Start at beginning of file
|
603
|
+
|
604
|
+
loop do
|
605
|
+
line = file.gets
|
606
|
+
if line
|
607
|
+
begin
|
608
|
+
# Parse JSONL entry
|
609
|
+
transcript_entry = JSON.parse(line)
|
610
|
+
|
611
|
+
# Skip summary entries - these are just conversation titles
|
612
|
+
next if transcript_entry["type"] == "summary"
|
613
|
+
|
614
|
+
# Convert to session.log.json format
|
615
|
+
session_entry = convert_transcript_to_session_format(transcript_entry)
|
616
|
+
|
617
|
+
# Write with file locking (same pattern as BaseExecutor)
|
618
|
+
session_json_path = File.join(@session_path, "session.log.json")
|
619
|
+
File.open(session_json_path, File::WRONLY | File::APPEND | File::CREAT) do |log_file|
|
620
|
+
log_file.flock(File::LOCK_EX)
|
621
|
+
log_file.puts(session_entry.to_json)
|
622
|
+
log_file.flock(File::LOCK_UN)
|
623
|
+
end
|
624
|
+
rescue JSON::ParserError
|
625
|
+
# Silently skip unparseable lines
|
626
|
+
rescue StandardError
|
627
|
+
# Silently handle other errors to keep thread running
|
628
|
+
end
|
629
|
+
else
|
630
|
+
# No new data, sleep briefly
|
631
|
+
sleep(0.1)
|
632
|
+
end
|
633
|
+
end
|
634
|
+
end
|
635
|
+
rescue StandardError
|
636
|
+
# Silently handle thread errors
|
637
|
+
end
|
638
|
+
end
|
639
|
+
|
640
|
+
def convert_transcript_to_session_format(transcript_entry)
|
641
|
+
{
|
642
|
+
instance: @config.main_instance,
|
643
|
+
instance_id: "main",
|
644
|
+
timestamp: transcript_entry["timestamp"] || Time.now.iso8601,
|
645
|
+
event: {
|
646
|
+
type: "transcript",
|
647
|
+
source: "main_instance",
|
648
|
+
data: transcript_entry,
|
649
|
+
},
|
650
|
+
}
|
651
|
+
end
|
652
|
+
|
653
|
+
def cleanup_transcript_thread
|
654
|
+
return unless @transcript_thread
|
655
|
+
|
656
|
+
@transcript_thread.terminate if @transcript_thread.alive?
|
657
|
+
@transcript_thread.join(1) # Wait up to 1 second for thread to finish
|
658
|
+
rescue StandardError => e
|
659
|
+
logger.error { "Error cleaning up transcript thread: #{e.message}" }
|
660
|
+
end
|
661
|
+
|
662
|
+
def logger
|
663
|
+
@logger ||= Logger.new(File.join(@session_path, "session.log"), level: :info, progname: "orchestrator")
|
664
|
+
end
|
665
|
+
|
579
666
|
def execute_commands(commands, phase:, fail_fast:)
|
580
667
|
all_succeeded = true
|
581
668
|
|
@@ -21,13 +21,7 @@ module ClaudeSwarm
|
|
21
21
|
private
|
22
22
|
|
23
23
|
def session_path
|
24
|
-
|
25
|
-
@session_path ||= if ENV["CLAUDE_SWARM_SESSION_PATH"]
|
26
|
-
SessionPath.from_env
|
27
|
-
else
|
28
|
-
# This should only happen in unit tests
|
29
|
-
Dir.pwd
|
30
|
-
end
|
24
|
+
@session_path ||= SessionPath.from_env
|
31
25
|
end
|
32
26
|
|
33
27
|
def ensure_session_directory
|
@@ -44,11 +38,40 @@ module ClaudeSwarm
|
|
44
38
|
settings["hooks"] = instance[:hooks]
|
45
39
|
end
|
46
40
|
|
41
|
+
# Add SessionStart hook for main instance to capture transcript path
|
42
|
+
if name == @config.main_instance
|
43
|
+
session_start_hook = build_session_start_hook
|
44
|
+
|
45
|
+
# Initialize hooks if not present
|
46
|
+
settings["hooks"] ||= {}
|
47
|
+
settings["hooks"]["SessionStart"] ||= []
|
48
|
+
|
49
|
+
# Add our hook to the SessionStart hooks
|
50
|
+
settings["hooks"]["SessionStart"] << session_start_hook
|
51
|
+
end
|
52
|
+
|
47
53
|
# Only write settings file if there are settings to write
|
48
54
|
return if settings.empty?
|
49
55
|
|
50
56
|
# Write settings file
|
51
57
|
File.write(settings_path(name), JSON.pretty_generate(settings))
|
52
58
|
end
|
59
|
+
|
60
|
+
def build_session_start_hook
|
61
|
+
hook_script_path = File.expand_path("hooks/session_start_hook.rb", __dir__)
|
62
|
+
# Pass session path as an argument since ENV may not be inherited
|
63
|
+
session_path_arg = session_path
|
64
|
+
|
65
|
+
{
|
66
|
+
"matcher" => "startup",
|
67
|
+
"hooks" => [
|
68
|
+
{
|
69
|
+
"type" => "command",
|
70
|
+
"command" => "ruby #{hook_script_path} '#{session_path_arg}'",
|
71
|
+
"timeout" => 5,
|
72
|
+
},
|
73
|
+
],
|
74
|
+
}
|
75
|
+
end
|
53
76
|
end
|
54
77
|
end
|
@@ -1,4 +1,4 @@
|
|
1
|
-
You are a Claude Swarm configuration generator assistant. Your role is to help the user create a well-structured
|
1
|
+
You are a Claude Swarm configuration generator assistant. Your role is to help the user create a well-structured swarm YAML file through an interactive conversation.
|
2
2
|
|
3
3
|
## Claude Swarm Overview
|
4
4
|
Claude Swarm is a Ruby gem that orchestrates multiple Claude Code instances as a collaborative AI development team. It enables running AI agents with specialized roles, tools, and directory contexts, communicating via MCP (Model Context Protocol).
|
@@ -10,6 +10,7 @@ Key capabilities:
|
|
10
10
|
- Run instances in different directories or Git worktrees
|
11
11
|
- Support for custom system prompts per instance
|
12
12
|
- Choose appropriate models (opus for complex tasks, sonnet for simpler ones)
|
13
|
+
- Support for OpenAI instances
|
13
14
|
|
14
15
|
## Your Task
|
15
16
|
1. Start by asking about the user's project structure and development needs
|
@@ -43,7 +44,7 @@ swarm:
|
|
43
44
|
instance_name:
|
44
45
|
description: "Clear description of role and responsibilities"
|
45
46
|
directory: ./path/to/directory
|
46
|
-
model:
|
47
|
+
model: opus
|
47
48
|
allowed_tools: [Read, Edit, Write, Bash]
|
48
49
|
connections: [other_instance_names] # Optional
|
49
50
|
prompt: |
|
@@ -57,7 +58,7 @@ swarm:
|
|
57
58
|
- Write clear descriptions explaining each instance's responsibilities
|
58
59
|
- Choose opus model for complex architectural or algorithmic tasks and routine development.
|
59
60
|
- Choose sonnet model for simpler tasks
|
60
|
-
- Set up logical connections (e.g., lead → team members, architect → implementers), but
|
61
|
+
- Set up logical connections (e.g., lead → team members, architect → implementers), but do not create circular dependencies.
|
61
62
|
- Always add this to the end of every prompt: `For maximum efficiency, whenever you need to perform multiple independent operations, invoke all relevant tools simultaneously rather than sequentially.`
|
62
63
|
- Select tools based on each instance's actual needs:
|
63
64
|
- Read: For code review and analysis roles
|
@@ -72,7 +73,6 @@ swarm:
|
|
72
73
|
|
73
74
|
## Interactive Questions to Ask
|
74
75
|
- What type of project are you working on?
|
75
|
-
- What's your project's directory structure?
|
76
76
|
- What are the main technologies/frameworks you're using?
|
77
77
|
- What development tasks do you need help with?
|
78
78
|
- Do you need specialized roles (testing, DevOps, documentation)?
|
@@ -225,6 +225,3 @@ 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
|
-
Start the conversation by greeting the user and asking: "What kind of project would you like to create a Claude Swarm for?"
|
230
|
-
Say: I am ready to start
|
data/lib/claude_swarm/version.rb
CHANGED
data/lib/claude_swarm.rb
CHANGED
metadata
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: claude_swarm
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.3.
|
4
|
+
version: 0.3.9
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Paulo Arruda
|
@@ -51,6 +51,20 @@ dependencies:
|
|
51
51
|
- - "~>"
|
52
52
|
- !ruby/object:Gem::Version
|
53
53
|
version: '0.1'
|
54
|
+
- !ruby/object:Gem::Dependency
|
55
|
+
name: faraday-net_http_persistent
|
56
|
+
requirement: !ruby/object:Gem::Requirement
|
57
|
+
requirements:
|
58
|
+
- - "~>"
|
59
|
+
- !ruby/object:Gem::Version
|
60
|
+
version: '2.0'
|
61
|
+
type: :runtime
|
62
|
+
prerelease: false
|
63
|
+
version_requirements: !ruby/object:Gem::Requirement
|
64
|
+
requirements:
|
65
|
+
- - "~>"
|
66
|
+
- !ruby/object:Gem::Version
|
67
|
+
version: '2.0'
|
54
68
|
- !ruby/object:Gem::Dependency
|
55
69
|
name: faraday-retry
|
56
70
|
requirement: !ruby/object:Gem::Requirement
|
@@ -155,6 +169,7 @@ files:
|
|
155
169
|
- lib/claude_swarm/commands/ps.rb
|
156
170
|
- lib/claude_swarm/commands/show.rb
|
157
171
|
- lib/claude_swarm/configuration.rb
|
172
|
+
- lib/claude_swarm/hooks/session_start_hook.rb
|
158
173
|
- lib/claude_swarm/mcp_generator.rb
|
159
174
|
- lib/claude_swarm/openai/chat_completion.rb
|
160
175
|
- lib/claude_swarm/openai/executor.rb
|