claude_swarm 0.3.8 → 0.3.10
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/.claude/commands/release.md +27 -0
- data/CHANGELOG.md +43 -0
- data/README.md +2 -2
- data/lib/claude_swarm/cli.rb +6 -1
- data/lib/claude_swarm/commands/ps.rb +21 -2
- 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 +203 -41
- data/lib/claude_swarm/session_cost_calculator.rb +124 -8
- 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 +17 -1
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: a44d1b33e538b1efaeca7eb223f09bd617fcb4ba2288d7844773ae5908a10248
|
4
|
+
data.tar.gz: 28312f81eada550b05bbf568807be51c5077366c08d168b928a35a4d356148da
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 2d8ba31f8a64f91060b980ee90bea57c736e4541410eeb36c4c78bdc602f7c3335ea82a388c0b61847a04c90210fb432c2d525df4f4bfa85cbe3725a0034b260
|
7
|
+
data.tar.gz: 15180a4a4df3f9ccc55da9dd40769435c1ba650a575b476c10d7c0b16d650bb1f65524b8d44a10d731e9aed3dcda886b0bb63fd2b64e5665c5ba1cc22633182b
|
@@ -0,0 +1,27 @@
|
|
1
|
+
---
|
2
|
+
description: Bump version, update changelog, and prepare for release
|
3
|
+
allowed-tools: [Read, Edit, Bash]
|
4
|
+
---
|
5
|
+
|
6
|
+
Prepare a new release for Claude Swarm by:
|
7
|
+
|
8
|
+
1. Read the current version from @lib/claude_swarm/version.rb
|
9
|
+
2. Determine the new version number: $ARGUMENTS (should be in format like 0.3.11 or use patch/minor/major)
|
10
|
+
3. Update the version in @lib/claude_swarm/version.rb
|
11
|
+
4. Update @CHANGELOG.md:
|
12
|
+
- Change "## [Unreleased]" to "## [new_version]"
|
13
|
+
- Add a new "## [Unreleased]" section at the top for future changes
|
14
|
+
5. Run these commands:
|
15
|
+
- `git add .`
|
16
|
+
- `bundle install`
|
17
|
+
- `git add .`
|
18
|
+
- `git commit -m "Release version X.X.X"`
|
19
|
+
- `git push`
|
20
|
+
|
21
|
+
Make sure all tests pass before releasing. The version argument should be either:
|
22
|
+
- A specific version number (e.g., 0.3.11)
|
23
|
+
- "patch" for incrementing the patch version (0.3.10 -> 0.3.11)
|
24
|
+
- "minor" for incrementing the minor version (0.3.10 -> 0.4.0)
|
25
|
+
- "major" for incrementing the major version (0.3.10 -> 1.0.0)
|
26
|
+
|
27
|
+
If no argument is provided, default to "patch".
|
data/CHANGELOG.md
CHANGED
@@ -1,3 +1,38 @@
|
|
1
|
+
## [0.3.10]
|
2
|
+
|
3
|
+
### Added
|
4
|
+
- **Token-based cost calculation for main instance**: Main instance costs in interactive mode are now calculated from token usage using Claude model pricing
|
5
|
+
- Opus: $15/MTok input, $75/MTok output, $18.75/MTok cache write, $1.50/MTok cache read
|
6
|
+
- Sonnet: $3/MTok input, $15/MTok output, $3.75/MTok cache write, $0.30/MTok cache read
|
7
|
+
- Haiku: $0.80/MTok input, $4/MTok output, $1/MTok cache write, $0.08/MTok cache read
|
8
|
+
- Automatically extracts usage data from assistant messages in session logs
|
9
|
+
|
10
|
+
### Changed
|
11
|
+
- **Simplified cost calculation**: Switched from cumulative `total_cost_usd` to per-request `cost_usd` for non-main instances
|
12
|
+
- Removed complex session reset detection logic
|
13
|
+
- Now uses simple summation of individual request costs
|
14
|
+
- More accurate and maintainable cost tracking
|
15
|
+
- **Improved ps command cost display**:
|
16
|
+
- Only shows cost warning when sessions are missing main instance data
|
17
|
+
- Adds asterisk (*) indicator to costs that exclude main instance
|
18
|
+
- Displays accurate total costs including main instance when available
|
19
|
+
|
20
|
+
### Fixed
|
21
|
+
- **Main instance log format consistency**: Fixed transcript logs in interactive mode to match standard instance log format
|
22
|
+
- Converted transcript wrapper to request/assistant event structure
|
23
|
+
- Properly extracts text content from nested message arrays
|
24
|
+
- Ensures uniform log parsing across all instances
|
25
|
+
|
26
|
+
## [0.3.9]
|
27
|
+
|
28
|
+
### Added
|
29
|
+
- **Main instance transcript integration**: Main Claude instance activity is now captured in session.log.json during interactive mode
|
30
|
+
- Automatically configures SessionStart hook for main instance to capture transcript path
|
31
|
+
- Background thread continuously tails transcript file and integrates entries into session.log.json
|
32
|
+
- Filters out summary entries to avoid duplicate conversation titles
|
33
|
+
- Uses file locking for thread-safe writes to maintain consistency
|
34
|
+
- Provides complete session history including main instance interactions
|
35
|
+
|
1
36
|
## [0.3.8]
|
2
37
|
|
3
38
|
### Added
|
@@ -9,6 +44,14 @@
|
|
9
44
|
- Connected instances receive hooks via SDK's `settings` attribute
|
10
45
|
- Full environment variable interpolation support in hook configurations
|
11
46
|
- See README.md "Hooks Configuration" section for usage examples
|
47
|
+
- **Persistent HTTP connections for OpenAI**: Added `faraday-net_http_persistent` dependency and configured OpenAI client to use persistent connections
|
48
|
+
- Improves performance when making multiple API requests by reusing HTTP connections
|
49
|
+
- Automatically configured for all OpenAI instances
|
50
|
+
|
51
|
+
### Changed
|
52
|
+
- **Improved OpenAI executor code organization**: Refactored internal methods for better maintainability
|
53
|
+
- Extracted configuration building and response handling into focused private methods
|
54
|
+
- Improved code readability with functional patterns
|
12
55
|
|
13
56
|
### Fixed
|
14
57
|
- **Settings integration**: Fixed passing settings to Claude instances
|
data/README.md
CHANGED
@@ -824,8 +824,8 @@ swarm:
|
|
824
824
|
claude-swarm
|
825
825
|
|
826
826
|
# Specify a different configuration file
|
827
|
-
claude-swarm my-swarm.yml
|
828
|
-
claude-swarm team-config.yml
|
827
|
+
claude-swarm start my-swarm.yml
|
828
|
+
claude-swarm start team-config.yml
|
829
829
|
|
830
830
|
# Run with --dangerously-skip-permissions for all instances
|
831
831
|
claude-swarm --vibe
|
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
|
@@ -34,6 +34,9 @@ module ClaudeSwarm
|
|
34
34
|
return
|
35
35
|
end
|
36
36
|
|
37
|
+
# Check if any session is missing main instance costs
|
38
|
+
any_missing_main = sessions.any? { |s| !s[:main_has_cost] }
|
39
|
+
|
37
40
|
# Column widths
|
38
41
|
col_session = 15
|
39
42
|
col_swarm = 25
|
@@ -50,13 +53,23 @@ module ClaudeSwarm
|
|
50
53
|
} #{
|
51
54
|
"UPTIME".ljust(col_uptime)
|
52
55
|
} DIRECTORY"
|
53
|
-
|
56
|
+
|
57
|
+
# Only show warning if any session is missing main instance costs
|
58
|
+
if any_missing_main
|
59
|
+
puts "\n⚠️ \e[3mTotal cost does not include the cost of the main instance for some sessions\e[0m\n\n"
|
60
|
+
else
|
61
|
+
puts
|
62
|
+
end
|
63
|
+
|
54
64
|
puts header
|
55
65
|
puts "-" * header.length
|
56
66
|
|
57
67
|
# Display sessions sorted by start time (newest first)
|
58
68
|
sessions.sort_by { |s| s[:start_time] }.reverse.each do |session|
|
59
69
|
cost_str = format("$%.4f", session[:cost])
|
70
|
+
# Add asterisk if this session is missing main instance cost
|
71
|
+
cost_str += "*" unless session[:main_has_cost]
|
72
|
+
|
60
73
|
puts "#{
|
61
74
|
session[:id].ljust(col_session)
|
62
75
|
} #{
|
@@ -107,7 +120,12 @@ module ClaudeSwarm
|
|
107
120
|
|
108
121
|
# Calculate total cost from JSON log
|
109
122
|
log_file = File.join(session_dir, "session.log.json")
|
110
|
-
|
123
|
+
cost_result = SessionCostCalculator.calculate_total_cost(log_file)
|
124
|
+
total_cost = cost_result[:total_cost]
|
125
|
+
|
126
|
+
# Check if main instance has cost data
|
127
|
+
instances_with_cost = cost_result[:instances_with_cost]
|
128
|
+
main_has_cost = main_instance && instances_with_cost.include?(main_instance)
|
111
129
|
|
112
130
|
# Get uptime from session metadata or fallback to directory creation time
|
113
131
|
start_time = get_start_time(session_dir)
|
@@ -117,6 +135,7 @@ module ClaudeSwarm
|
|
117
135
|
id: session_id,
|
118
136
|
name: swarm_name,
|
119
137
|
cost: total_cost,
|
138
|
+
main_has_cost: main_has_cost,
|
120
139
|
uptime: uptime,
|
121
140
|
directory: directories_str,
|
122
141
|
start_time: start_time,
|
@@ -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}"
|
@@ -566,9 +573,6 @@ module ClaudeSwarm
|
|
566
573
|
pretty_json = JSON.pretty_generate(json_data)
|
567
574
|
logger.info { pretty_json }
|
568
575
|
rescue JSON::ParserError
|
569
|
-
# Warn about non-JSON output since we expect stream-json format
|
570
|
-
warn("⚠️ Warning: Non-JSON output detected in stream-json mode: #{line.chomp}")
|
571
|
-
# Log the line as-is
|
572
576
|
logger.info { line.chomp }
|
573
577
|
end
|
574
578
|
|
@@ -576,6 +580,164 @@ module ClaudeSwarm
|
|
576
580
|
end
|
577
581
|
end
|
578
582
|
|
583
|
+
def start_transcript_tailing
|
584
|
+
Thread.new do
|
585
|
+
path_file = File.join(@session_path, "main_instance_transcript.path")
|
586
|
+
|
587
|
+
# Wait for path file to exist (created by SessionStart hook)
|
588
|
+
sleep(0.5) until File.exist?(path_file)
|
589
|
+
|
590
|
+
# Read the transcript path
|
591
|
+
transcript_path = File.read(path_file).strip
|
592
|
+
|
593
|
+
# Wait for transcript file to exist
|
594
|
+
sleep(0.5) until File.exist?(transcript_path)
|
595
|
+
|
596
|
+
# Tail the transcript file continuously (like tail -f)
|
597
|
+
File.open(transcript_path, "r") do |file|
|
598
|
+
# Start from the beginning to capture all entries
|
599
|
+
file.seek(0, IO::SEEK_SET) # Start at beginning of file
|
600
|
+
|
601
|
+
loop do
|
602
|
+
line = file.gets
|
603
|
+
if line
|
604
|
+
begin
|
605
|
+
# Parse JSONL entry
|
606
|
+
transcript_entry = JSON.parse(line)
|
607
|
+
|
608
|
+
# Skip summary entries - these are just conversation titles
|
609
|
+
next if transcript_entry["type"] == "summary"
|
610
|
+
|
611
|
+
# Convert to session.log.json format
|
612
|
+
session_entry = convert_transcript_to_session_format(transcript_entry)
|
613
|
+
|
614
|
+
# Only write if we got a valid conversion (skips summary and other non-relevant entries)
|
615
|
+
if session_entry
|
616
|
+
# Write with file locking (same pattern as BaseExecutor)
|
617
|
+
session_json_path = File.join(@session_path, "session.log.json")
|
618
|
+
File.open(session_json_path, File::WRONLY | File::APPEND | File::CREAT) do |log_file|
|
619
|
+
log_file.flock(File::LOCK_EX)
|
620
|
+
log_file.puts(session_entry.to_json)
|
621
|
+
end
|
622
|
+
end
|
623
|
+
rescue JSON::ParserError
|
624
|
+
# Silently skip unparseable lines
|
625
|
+
rescue StandardError
|
626
|
+
# Silently handle other errors to keep thread running
|
627
|
+
end
|
628
|
+
else
|
629
|
+
# No new data, sleep briefly
|
630
|
+
sleep(0.1)
|
631
|
+
end
|
632
|
+
end
|
633
|
+
end
|
634
|
+
rescue StandardError
|
635
|
+
# Silently handle thread errors
|
636
|
+
end
|
637
|
+
end
|
638
|
+
|
639
|
+
def convert_transcript_to_session_format(transcript_entry)
|
640
|
+
# Skip if no type
|
641
|
+
return unless transcript_entry["type"]
|
642
|
+
|
643
|
+
instance_name = @config.main_instance
|
644
|
+
instance_id = "main"
|
645
|
+
timestamp = transcript_entry["timestamp"] || Time.now.iso8601
|
646
|
+
|
647
|
+
case transcript_entry["type"]
|
648
|
+
when "user"
|
649
|
+
# User message - format as request from user to main instance
|
650
|
+
message = transcript_entry["message"]
|
651
|
+
|
652
|
+
# Extract prompt text - message might be a string or an object
|
653
|
+
prompt_text = if message.is_a?(String)
|
654
|
+
message
|
655
|
+
elsif message.is_a?(Hash)
|
656
|
+
content = message["content"]
|
657
|
+
if content.is_a?(String)
|
658
|
+
content
|
659
|
+
elsif content.is_a?(Array)
|
660
|
+
# For tool results or complex content, extract text
|
661
|
+
extract_text_from_array(content)
|
662
|
+
else
|
663
|
+
""
|
664
|
+
end
|
665
|
+
else
|
666
|
+
""
|
667
|
+
end
|
668
|
+
|
669
|
+
{
|
670
|
+
instance: instance_name,
|
671
|
+
instance_id: instance_id,
|
672
|
+
timestamp: timestamp,
|
673
|
+
event: {
|
674
|
+
type: "request",
|
675
|
+
from_instance: "user",
|
676
|
+
from_instance_id: "user",
|
677
|
+
to_instance: instance_name,
|
678
|
+
to_instance_id: instance_id,
|
679
|
+
prompt: prompt_text,
|
680
|
+
timestamp: timestamp,
|
681
|
+
},
|
682
|
+
}
|
683
|
+
when "assistant"
|
684
|
+
# Assistant message - format as assistant response
|
685
|
+
message = transcript_entry["message"]
|
686
|
+
|
687
|
+
# Build a clean message structure without transcript-specific fields
|
688
|
+
clean_message = {
|
689
|
+
"type" => "message",
|
690
|
+
"role" => "assistant",
|
691
|
+
}
|
692
|
+
|
693
|
+
# Handle different message formats
|
694
|
+
if message.is_a?(String)
|
695
|
+
# Simple string message
|
696
|
+
clean_message["content"] = [{ "type" => "text", "text" => message }]
|
697
|
+
elsif message.is_a?(Hash)
|
698
|
+
# Only include the fields that other instances include
|
699
|
+
clean_message["content"] = message["content"] if message["content"]
|
700
|
+
clean_message["model"] = message["model"] if message["model"]
|
701
|
+
clean_message["usage"] = message["usage"] if message["usage"]
|
702
|
+
end
|
703
|
+
|
704
|
+
{
|
705
|
+
instance: instance_name,
|
706
|
+
instance_id: instance_id,
|
707
|
+
timestamp: timestamp,
|
708
|
+
event: {
|
709
|
+
type: "assistant",
|
710
|
+
message: clean_message,
|
711
|
+
session_id: transcript_entry["sessionId"],
|
712
|
+
},
|
713
|
+
}
|
714
|
+
end
|
715
|
+
# For other types (like summary), return nil to skip them
|
716
|
+
end
|
717
|
+
|
718
|
+
def extract_text_from_array(content)
|
719
|
+
content.map do |item|
|
720
|
+
if item.is_a?(Hash)
|
721
|
+
item["text"] || item["content"] || ""
|
722
|
+
else
|
723
|
+
item.to_s
|
724
|
+
end
|
725
|
+
end.join("\n")
|
726
|
+
end
|
727
|
+
|
728
|
+
def cleanup_transcript_thread
|
729
|
+
return unless @transcript_thread
|
730
|
+
|
731
|
+
@transcript_thread.terminate if @transcript_thread.alive?
|
732
|
+
@transcript_thread.join(1) # Wait up to 1 second for thread to finish
|
733
|
+
rescue StandardError => e
|
734
|
+
logger.error { "Error cleaning up transcript thread: #{e.message}" }
|
735
|
+
end
|
736
|
+
|
737
|
+
def logger
|
738
|
+
@logger ||= Logger.new(File.join(@session_path, "session.log"), level: :info, progname: "orchestrator")
|
739
|
+
end
|
740
|
+
|
579
741
|
def execute_commands(commands, phase:, fail_fast:)
|
580
742
|
all_succeeded = true
|
581
743
|
|
@@ -4,26 +4,118 @@ module ClaudeSwarm
|
|
4
4
|
module SessionCostCalculator
|
5
5
|
extend self
|
6
6
|
|
7
|
+
# Model pricing in dollars per million tokens
|
8
|
+
MODEL_PRICING = {
|
9
|
+
opus: {
|
10
|
+
input: 15.0,
|
11
|
+
output: 75.0,
|
12
|
+
cache_write: 18.75,
|
13
|
+
cache_read: 1.50,
|
14
|
+
},
|
15
|
+
sonnet: {
|
16
|
+
input: 3.0,
|
17
|
+
output: 15.0,
|
18
|
+
cache_write: 3.75,
|
19
|
+
cache_read: 0.30,
|
20
|
+
},
|
21
|
+
haiku: {
|
22
|
+
input: 0.80,
|
23
|
+
output: 4.0,
|
24
|
+
cache_write: 1.0,
|
25
|
+
cache_read: 0.08,
|
26
|
+
},
|
27
|
+
}.freeze
|
28
|
+
|
29
|
+
# Determine model type from model name
|
30
|
+
def model_type_from_name(model_name)
|
31
|
+
return unless model_name
|
32
|
+
|
33
|
+
model_name_lower = model_name.downcase
|
34
|
+
if model_name_lower.include?("opus")
|
35
|
+
:opus
|
36
|
+
elsif model_name_lower.include?("sonnet")
|
37
|
+
:sonnet
|
38
|
+
elsif model_name_lower.include?("haiku")
|
39
|
+
:haiku
|
40
|
+
end
|
41
|
+
end
|
42
|
+
|
43
|
+
# Calculate cost from token usage
|
44
|
+
def calculate_token_cost(usage, model_name)
|
45
|
+
model_type = model_type_from_name(model_name)
|
46
|
+
return 0.0 unless model_type && usage
|
47
|
+
|
48
|
+
pricing = MODEL_PRICING[model_type]
|
49
|
+
return 0.0 unless pricing
|
50
|
+
|
51
|
+
cost = 0.0
|
52
|
+
|
53
|
+
# Regular input tokens
|
54
|
+
if usage["input_tokens"]
|
55
|
+
cost += (usage["input_tokens"] / 1_000_000.0) * pricing[:input]
|
56
|
+
end
|
57
|
+
|
58
|
+
# Output tokens
|
59
|
+
if usage["output_tokens"]
|
60
|
+
cost += (usage["output_tokens"] / 1_000_000.0) * pricing[:output]
|
61
|
+
end
|
62
|
+
|
63
|
+
# Cache creation tokens (write)
|
64
|
+
if usage["cache_creation_input_tokens"]
|
65
|
+
cost += (usage["cache_creation_input_tokens"] / 1_000_000.0) * pricing[:cache_write]
|
66
|
+
end
|
67
|
+
|
68
|
+
# Cache read tokens
|
69
|
+
if usage["cache_read_input_tokens"]
|
70
|
+
cost += (usage["cache_read_input_tokens"] / 1_000_000.0) * pricing[:cache_read]
|
71
|
+
end
|
72
|
+
|
73
|
+
cost
|
74
|
+
end
|
75
|
+
|
7
76
|
# Calculate total cost from session log file
|
8
77
|
# Returns a hash with:
|
9
|
-
# - total_cost: Total cost in USD
|
78
|
+
# - total_cost: Total cost in USD (sum of cost_usd for instances, token costs for main)
|
10
79
|
# - instances_with_cost: Set of instance names that have cost data
|
11
80
|
def calculate_total_cost(session_log_path)
|
12
81
|
return { total_cost: 0.0, instances_with_cost: Set.new } unless File.exist?(session_log_path)
|
13
82
|
|
14
|
-
|
83
|
+
# Track costs per instance - simple sum of cost_usd
|
84
|
+
instance_costs = {}
|
15
85
|
instances_with_cost = Set.new
|
86
|
+
main_instance_cost = 0.0
|
16
87
|
|
17
88
|
File.foreach(session_log_path) do |line|
|
18
89
|
data = JSON.parse(line)
|
19
|
-
|
20
|
-
|
21
|
-
|
90
|
+
instance_name = data["instance"]
|
91
|
+
instance_id = data["instance_id"]
|
92
|
+
|
93
|
+
# Handle main instance token-based costs
|
94
|
+
if instance_id == "main" && data.dig("event", "type") == "assistant"
|
95
|
+
usage = data.dig("event", "message", "usage")
|
96
|
+
model = data.dig("event", "message", "model")
|
97
|
+
if usage && model
|
98
|
+
token_cost = calculate_token_cost(usage, model)
|
99
|
+
main_instance_cost += token_cost
|
100
|
+
instances_with_cost << instance_name if token_cost > 0
|
101
|
+
end
|
102
|
+
# Handle other instances with cost_usd (non-cumulative)
|
103
|
+
elsif instance_id != "main" && data.dig("event", "type") == "result"
|
104
|
+
# Use cost_usd (non-cumulative) instead of total_cost_usd (cumulative)
|
105
|
+
if (cost = data.dig("event", "cost_usd"))
|
106
|
+
instances_with_cost << instance_name
|
107
|
+
instance_costs[instance_name] ||= 0.0
|
108
|
+
instance_costs[instance_name] += cost
|
109
|
+
end
|
22
110
|
end
|
23
111
|
rescue JSON::ParserError
|
24
112
|
next
|
25
113
|
end
|
26
114
|
|
115
|
+
# Calculate total: sum of all instance costs + main instance token costs
|
116
|
+
other_instances_cost = instance_costs.values.sum
|
117
|
+
total_cost = other_instances_cost + main_instance_cost
|
118
|
+
|
27
119
|
{
|
28
120
|
total_cost: total_cost,
|
29
121
|
instances_with_cost: instances_with_cost,
|
@@ -39,6 +131,8 @@ module ClaudeSwarm
|
|
39
131
|
# Returns a hash of instances with their cost data and relationships
|
40
132
|
def parse_instance_hierarchy(session_log_path)
|
41
133
|
instances = {}
|
134
|
+
# Track main instance token costs
|
135
|
+
main_instance_costs = {}
|
42
136
|
|
43
137
|
return instances unless File.exist?(session_log_path)
|
44
138
|
|
@@ -75,10 +169,24 @@ module ClaudeSwarm
|
|
75
169
|
instances[calling_instance][:calls_to] << instance_name
|
76
170
|
end
|
77
171
|
|
78
|
-
#
|
79
|
-
if data.dig("event", "type") == "
|
172
|
+
# Handle main instance token-based costs
|
173
|
+
if instance_id == "main" && data.dig("event", "type") == "assistant"
|
174
|
+
usage = data.dig("event", "message", "usage")
|
175
|
+
model = data.dig("event", "message", "model")
|
176
|
+
if usage && model
|
177
|
+
token_cost = calculate_token_cost(usage, model)
|
178
|
+
if token_cost > 0
|
179
|
+
main_instance_costs[instance_name] ||= 0.0
|
180
|
+
main_instance_costs[instance_name] += token_cost
|
181
|
+
instances[instance_name][:has_cost_data] = true
|
182
|
+
instances[instance_name][:calls] += 1
|
183
|
+
end
|
184
|
+
end
|
185
|
+
# Track costs and calls for non-main instances using cost_usd
|
186
|
+
elsif data.dig("event", "type") == "result" && instance_id != "main"
|
80
187
|
instances[instance_name][:calls] += 1
|
81
|
-
|
188
|
+
# Use cost_usd (non-cumulative) instead of total_cost_usd
|
189
|
+
if (cost = data.dig("event", "cost_usd"))
|
82
190
|
instances[instance_name][:cost] += cost
|
83
191
|
instances[instance_name][:has_cost_data] = true
|
84
192
|
end
|
@@ -87,6 +195,14 @@ module ClaudeSwarm
|
|
87
195
|
next
|
88
196
|
end
|
89
197
|
|
198
|
+
# Set main instance costs (replace, don't add)
|
199
|
+
main_instance_costs.each do |name, cost|
|
200
|
+
if instances[name]
|
201
|
+
# For main instances, use ONLY token costs, not cumulative costs
|
202
|
+
instances[name][:cost] = cost
|
203
|
+
end
|
204
|
+
end
|
205
|
+
|
90
206
|
instances
|
91
207
|
end
|
92
208
|
end
|
@@ -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.10
|
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
|
@@ -126,6 +140,7 @@ executables:
|
|
126
140
|
extensions: []
|
127
141
|
extra_rdoc_files: []
|
128
142
|
files:
|
143
|
+
- ".claude/commands/release.md"
|
129
144
|
- ".rubocop.yml"
|
130
145
|
- ".rubocop_todo.yml"
|
131
146
|
- ".ruby-version"
|
@@ -155,6 +170,7 @@ files:
|
|
155
170
|
- lib/claude_swarm/commands/ps.rb
|
156
171
|
- lib/claude_swarm/commands/show.rb
|
157
172
|
- lib/claude_swarm/configuration.rb
|
173
|
+
- lib/claude_swarm/hooks/session_start_hook.rb
|
158
174
|
- lib/claude_swarm/mcp_generator.rb
|
159
175
|
- lib/claude_swarm/openai/chat_completion.rb
|
160
176
|
- lib/claude_swarm/openai/executor.rb
|