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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a256a8d09a86290b06002437b38bc9092b7d83c1e4d1e0f4866b37cd1251bd99
4
- data.tar.gz: d5eeec5e3a350dac2a04860c64c00043a8618fd29d40b9e1acf77e86b2f3fd49
3
+ metadata.gz: a44d1b33e538b1efaeca7eb223f09bd617fcb4ba2288d7844773ae5908a10248
4
+ data.tar.gz: 28312f81eada550b05bbf568807be51c5077366c08d168b928a35a4d356148da
5
5
  SHA512:
6
- metadata.gz: 3bb8119400be33d056b795aaea4f7f3d045d85a29d830cc42fe8c3dba0fd406100553b68740fe884e2e769e9685c439983d3a38ee52f55730f9f1ff7b40ddbff
7
- data.tar.gz: a2dd8483358b673698e7ca3a71d1952fa0b2c85d0daf9cd2ff06c5f62d78f7ffebacc95a0e622cfb3f62f229e81c8177c5d6b263303b88c26c592ba53e7c7607
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
@@ -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
- ERB.new(template, trim_mode: "-").result(binding)
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
- puts "\n⚠️ \e[3mTotal cost does not include the cost of the main instance\e[0m\n\n"
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
- total_cost = SessionCostCalculator.calculate_simple_total(log_file)
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
- # Format response similar to ClaudeCodeExecutor
60
- response = {
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
- config = {
96
- access_token: ENV.fetch(token_env),
97
- log_errors: true,
98
- request_timeout: 1800, # 30 minutes
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
- # Create MCP client with all MCP servers from the config
136
- if mcp_data["mcpServers"] && !mcp_data["mcpServers"].empty?
137
- mcp_configs = []
138
-
139
- mcp_data["mcpServers"].each do |name, server_config|
140
- case server_config["type"]
141
- when "stdio"
142
- # Combine command and args into a single array
143
- command_array = [server_config["command"]]
144
- command_array.concat(server_config["args"] || [])
145
-
146
- stdio_config = MCPClient.stdio_config(
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
- if mcp_configs.any?
159
- # Create MCP client with unbundled environment to avoid bundler conflicts
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
- # Initialize process tracker
112
- @process_tracker = ProcessTracker.new(session_path)
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
- total_cost = 0.0
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
- if data.dig("event", "type") == "result" && (cost = data.dig("event", "total_cost_usd"))
20
- total_cost += cost
21
- instances_with_cost << data["instance"]
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
- # Track costs and calls
79
- if data.dig("event", "type") == "result"
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
- if (cost = data.dig("event", "total_cost_usd"))
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
- # In tests, use the session path from env if available, otherwise use a temp path
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 claude-swarm.yml file through an interactive conversation.
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: sonnet # or opus for complex tasks
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 avoid circular dependencies.
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ClaudeSwarm
4
- VERSION = "0.3.8"
4
+ VERSION = "0.3.10"
5
5
  end
data/lib/claude_swarm.rb CHANGED
@@ -23,9 +23,7 @@ require "yaml"
23
23
  # External dependencies
24
24
  require "claude_sdk"
25
25
  require "fast_mcp_annotations"
26
- require "faraday/retry"
27
26
  require "mcp_client"
28
- require "openai"
29
27
  require "thor"
30
28
 
31
29
  # Zeitwerk setup
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.8
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