claude_swarm 0.3.9 → 0.3.11

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: 55711fd9483c68d996c0a0e448c4d31135d7423269d1ed691bb3590db5487627
4
- data.tar.gz: a84c0b4e1165057cbbe22bcd4d6c02c8fcdaedb5abb2f18e150b667ee0083385
3
+ metadata.gz: 8e0bade0292291825648214e0a1a420241ec1d6ffff018831d131d5c0f532e45
4
+ data.tar.gz: 74d62b916f1dbde6b725e8990924839c6ace2b6de75cb812f43305d044def7b7
5
5
  SHA512:
6
- metadata.gz: cc2808da58a6cb2b2ac545cfbe4803382602d4339017337d3af84b92274baab241952186f7de4e72adfbd413d0fcb448b955fb4cb7ebb5ebc014b90895988247
7
- data.tar.gz: 2d58c3795f4ea80da550c6241ad7639ffa5d25fa868eb9af18bf58382961f8cad4fb930616c133197858193fb2f9627aead812bfd60a3565631ef38398351b16
6
+ metadata.gz: 0f7f0b3dad9bb034a5b1cf07417df7b3cef6dc7fa88d02d4c9617db354ff0f52d7a368f678f0d2502343fdb03112cf8672f68f2dce3a77a46d769f4cc8f0afa6
7
+ data.tar.gz: d2c81f31e2534e4f92f519ea1b218236d68ed2e08adbd3bfdb2d0569de1a5f80a2f5b2452e3c47b75e6e4441fb70575ca1fbf381d169c0b9ee2aca4b2072c7d6
@@ -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,50 @@
1
+ ## [Unreleased]
2
+
3
+ ## [0.3.11]
4
+
5
+ ### Added
6
+ - **Deferred directory validation for before commands**: Directories are now validated after `before` commands run, allowing them to create required directories
7
+ - Automatically skips initial directory validation when `before` commands are present in configuration
8
+ - Validates all directories after `before` commands complete successfully
9
+ - Enables dynamic directory creation workflows without pre-creating directory structures
10
+
11
+ ### Fixed
12
+ - **--root-dir parameter path resolution**: Fixed relative config file paths to be resolved relative to the --root-dir value instead of current directory
13
+ - Config paths are now expanded using the root directory as the base path
14
+ - Allows running claude-swarm from any location with consistent path resolution
15
+ - Absolute paths continue to work as expected regardless of --root-dir setting
16
+
17
+ ### Improved
18
+ - **Enhanced worktree cleanup on errors**: Improved error handling to ensure worktrees are always cleaned up properly
19
+ - Added comprehensive error handling with cleanup at all failure points
20
+ - Worktrees are now cleaned up when worktree setup fails, before commands fail, or directory validation fails
21
+ - Prevents orphaned worktrees that could clutter the system or cause issues with future runs
22
+
23
+ ## [0.3.10]
24
+
25
+ ### Added
26
+ - **Token-based cost calculation for main instance**: Main instance costs in interactive mode are now calculated from token usage using Claude model pricing
27
+ - Opus: $15/MTok input, $75/MTok output, $18.75/MTok cache write, $1.50/MTok cache read
28
+ - Sonnet: $3/MTok input, $15/MTok output, $3.75/MTok cache write, $0.30/MTok cache read
29
+ - Haiku: $0.80/MTok input, $4/MTok output, $1/MTok cache write, $0.08/MTok cache read
30
+ - Automatically extracts usage data from assistant messages in session logs
31
+
32
+ ### Changed
33
+ - **Simplified cost calculation**: Switched from cumulative `total_cost_usd` to per-request `cost_usd` for non-main instances
34
+ - Removed complex session reset detection logic
35
+ - Now uses simple summation of individual request costs
36
+ - More accurate and maintainable cost tracking
37
+ - **Improved ps command cost display**:
38
+ - Only shows cost warning when sessions are missing main instance data
39
+ - Adds asterisk (*) indicator to costs that exclude main instance
40
+ - Displays accurate total costs including main instance when available
41
+
42
+ ### Fixed
43
+ - **Main instance log format consistency**: Fixed transcript logs in interactive mode to match standard instance log format
44
+ - Converted transcript wrapper to request/assistant event structure
45
+ - Properly extracts text content from nested message arrays
46
+ - Ensures uniform log parsing across all instances
47
+
1
48
  ## [0.3.9]
2
49
 
3
50
  ### Added
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
@@ -42,16 +42,19 @@ module ClaudeSwarm
42
42
  type: :string,
43
43
  desc: "Root directory for resolving relative paths (defaults to current directory)"
44
44
  def start(config_file = nil)
45
+ # Set root directory early so it's available to all components
46
+ root_dir = options[:root_dir] || Dir.pwd
47
+ ENV["CLAUDE_SWARM_ROOT_DIR"] = File.expand_path(root_dir)
48
+
49
+ # Resolve config path relative to root directory
45
50
  config_path = config_file || "claude-swarm.yml"
51
+ config_path = File.expand_path(config_path, root_dir)
52
+
46
53
  unless File.exist?(config_path)
47
54
  error("Configuration file not found: #{config_path}")
48
55
  exit(1)
49
56
  end
50
57
 
51
- # Set root directory early so it's available to all components
52
- root_dir = options[:root_dir] || Dir.pwd
53
- ENV["CLAUDE_SWARM_ROOT_DIR"] = File.expand_path(root_dir)
54
-
55
58
  say("Starting Claude Swarm from #{config_path}...") unless options[:prompt]
56
59
 
57
60
  # Validate stream_logs option
@@ -331,7 +334,7 @@ module ClaudeSwarm
331
334
  system!("command -v claude > /dev/null 2>&1")
332
335
  rescue Error
333
336
  error("Claude CLI is not installed or not in PATH")
334
- say("To install Claude CLI, visit: https://docs.anthropic.com/en/docs/claude-code")
337
+ error("To install Claude CLI, visit: https://docs.anthropic.com/en/docs/claude-code")
335
338
  exit(1)
336
339
  end
337
340
 
@@ -508,7 +511,7 @@ module ClaudeSwarm
508
511
  private
509
512
 
510
513
  def error(message)
511
- say(message, :red)
514
+ $stderr.puts(Thor::Shell::Color.new.set_color(message, :red))
512
515
  end
513
516
 
514
517
  def restore_session(session_id)
@@ -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,
@@ -43,15 +43,30 @@ module ClaudeSwarm
43
43
  @swarm["after"] || []
44
44
  end
45
45
 
46
+ def validate_directories
47
+ @instances.each do |name, instance|
48
+ # Validate all directories in the directories array
49
+ instance[:directories].each do |directory|
50
+ raise Error, "Directory '#{directory}' for instance '#{name}' does not exist" unless File.directory?(directory)
51
+ end
52
+ end
53
+ end
54
+
46
55
  private
47
56
 
57
+ def has_before_commands?
58
+ @swarm && @swarm["before"] && !@swarm["before"].empty?
59
+ end
60
+
48
61
  def load_and_validate
49
62
  @config = YAML.load_file(@config_path)
50
63
  interpolate_env_vars!(@config)
51
64
  validate_version
52
65
  validate_swarm
53
66
  parse_swarm
54
- validate_directories
67
+ # Skip directory validation if before commands are present
68
+ # They might create the directories
69
+ validate_directories unless has_before_commands?
55
70
  rescue Errno::ENOENT
56
71
  raise Error, "Configuration file not found: #{@config_path}"
57
72
  rescue Psych::SyntaxError => e
@@ -273,15 +288,6 @@ module ClaudeSwarm
273
288
  visited.add(instance_name)
274
289
  end
275
290
 
276
- def validate_directories
277
- @instances.each do |name, instance|
278
- # Validate all directories in the directories array
279
- instance[:directories].each do |directory|
280
- raise Error, "Directory '#{directory}' for instance '#{name}' does not exist" unless File.directory?(directory)
281
- end
282
- end
283
- end
284
-
285
291
  def validate_tool_field(instance_name, config, field_name)
286
292
  return unless config.key?(field_name)
287
293
 
@@ -73,6 +73,20 @@ module ClaudeSwarm
73
73
  # Track start time
74
74
  @start_time = Time.now
75
75
 
76
+ begin
77
+ start_internal
78
+ rescue StandardError => e
79
+ # Ensure cleanup happens even on unexpected errors
80
+ cleanup_processes
81
+ cleanup_run_symlink
82
+ cleanup_worktrees
83
+ raise e
84
+ end
85
+ end
86
+
87
+ private
88
+
89
+ def start_internal
76
90
  if @restore_session_path
77
91
  non_interactive_output do
78
92
  puts "🔄 Restoring Claude Swarm: #{@config.swarm_name}"
@@ -115,16 +129,24 @@ module ClaudeSwarm
115
129
 
116
130
  # Setup worktrees if needed
117
131
  if @worktree_manager
118
- non_interactive_output { print("🌳 Setting up Git worktrees...") }
132
+ begin
133
+ non_interactive_output { print("🌳 Setting up Git worktrees...") }
119
134
 
120
- # Get all instances for worktree setup
121
- # Note: instances.values already includes the main instance
122
- all_instances = @config.instances.values
135
+ # Get all instances for worktree setup
136
+ # Note: instances.values already includes the main instance
137
+ all_instances = @config.instances.values
123
138
 
124
- @worktree_manager.setup_worktrees(all_instances)
139
+ @worktree_manager.setup_worktrees(all_instances)
125
140
 
126
- non_interactive_output do
127
- puts "✓ Worktrees created with branch: #{@worktree_manager.worktree_name}"
141
+ non_interactive_output do
142
+ puts "✓ Worktrees created with branch: #{@worktree_manager.worktree_name}"
143
+ end
144
+ rescue StandardError => e
145
+ non_interactive_output { print("❌ Failed to setup worktrees: #{e.message}") }
146
+ cleanup_processes
147
+ cleanup_run_symlink
148
+ cleanup_worktrees
149
+ raise
128
150
  end
129
151
  end
130
152
 
@@ -200,6 +222,20 @@ module ClaudeSwarm
200
222
  non_interactive_output do
201
223
  puts "✓ Before commands completed successfully"
202
224
  end
225
+
226
+ # Validate directories after before commands have run
227
+ begin
228
+ @config.validate_directories
229
+ non_interactive_output do
230
+ puts "✓ All directories validated successfully"
231
+ end
232
+ rescue ClaudeSwarm::Error => e
233
+ non_interactive_output { print("❌ Directory validation failed: #{e.message}") }
234
+ cleanup_processes
235
+ cleanup_run_symlink
236
+ cleanup_worktrees
237
+ exit(1)
238
+ end
203
239
  end
204
240
 
205
241
  # Execute main Claude instance with unbundled environment to avoid bundler conflicts
@@ -249,8 +285,6 @@ module ClaudeSwarm
249
285
  cleanup_worktrees
250
286
  end
251
287
 
252
- private
253
-
254
288
  def non_interactive_output
255
289
  return if @non_interactive_prompt
256
290
 
@@ -573,9 +607,6 @@ module ClaudeSwarm
573
607
  pretty_json = JSON.pretty_generate(json_data)
574
608
  logger.info { pretty_json }
575
609
  rescue JSON::ParserError
576
- # Warn about non-JSON output since we expect stream-json format
577
- warn("⚠️ Warning: Non-JSON output detected in stream-json mode: #{line.chomp}")
578
- # Log the line as-is
579
610
  logger.info { line.chomp }
580
611
  end
581
612
 
@@ -614,12 +645,14 @@ module ClaudeSwarm
614
645
  # Convert to session.log.json format
615
646
  session_entry = convert_transcript_to_session_format(transcript_entry)
616
647
 
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)
648
+ # Only write if we got a valid conversion (skips summary and other non-relevant entries)
649
+ if session_entry
650
+ # Write with file locking (same pattern as BaseExecutor)
651
+ session_json_path = File.join(@session_path, "session.log.json")
652
+ File.open(session_json_path, File::WRONLY | File::APPEND | File::CREAT) do |log_file|
653
+ log_file.flock(File::LOCK_EX)
654
+ log_file.puts(session_entry.to_json)
655
+ end
623
656
  end
624
657
  rescue JSON::ParserError
625
658
  # Silently skip unparseable lines
@@ -638,16 +671,92 @@ module ClaudeSwarm
638
671
  end
639
672
 
640
673
  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
- }
674
+ # Skip if no type
675
+ return unless transcript_entry["type"]
676
+
677
+ instance_name = @config.main_instance
678
+ instance_id = "main"
679
+ timestamp = transcript_entry["timestamp"] || Time.now.iso8601
680
+
681
+ case transcript_entry["type"]
682
+ when "user"
683
+ # User message - format as request from user to main instance
684
+ message = transcript_entry["message"]
685
+
686
+ # Extract prompt text - message might be a string or an object
687
+ prompt_text = if message.is_a?(String)
688
+ message
689
+ elsif message.is_a?(Hash)
690
+ content = message["content"]
691
+ if content.is_a?(String)
692
+ content
693
+ elsif content.is_a?(Array)
694
+ # For tool results or complex content, extract text
695
+ extract_text_from_array(content)
696
+ else
697
+ ""
698
+ end
699
+ else
700
+ ""
701
+ end
702
+
703
+ {
704
+ instance: instance_name,
705
+ instance_id: instance_id,
706
+ timestamp: timestamp,
707
+ event: {
708
+ type: "request",
709
+ from_instance: "user",
710
+ from_instance_id: "user",
711
+ to_instance: instance_name,
712
+ to_instance_id: instance_id,
713
+ prompt: prompt_text,
714
+ timestamp: timestamp,
715
+ },
716
+ }
717
+ when "assistant"
718
+ # Assistant message - format as assistant response
719
+ message = transcript_entry["message"]
720
+
721
+ # Build a clean message structure without transcript-specific fields
722
+ clean_message = {
723
+ "type" => "message",
724
+ "role" => "assistant",
725
+ }
726
+
727
+ # Handle different message formats
728
+ if message.is_a?(String)
729
+ # Simple string message
730
+ clean_message["content"] = [{ "type" => "text", "text" => message }]
731
+ elsif message.is_a?(Hash)
732
+ # Only include the fields that other instances include
733
+ clean_message["content"] = message["content"] if message["content"]
734
+ clean_message["model"] = message["model"] if message["model"]
735
+ clean_message["usage"] = message["usage"] if message["usage"]
736
+ end
737
+
738
+ {
739
+ instance: instance_name,
740
+ instance_id: instance_id,
741
+ timestamp: timestamp,
742
+ event: {
743
+ type: "assistant",
744
+ message: clean_message,
745
+ session_id: transcript_entry["sessionId"],
746
+ },
747
+ }
748
+ end
749
+ # For other types (like summary), return nil to skip them
750
+ end
751
+
752
+ def extract_text_from_array(content)
753
+ content.map do |item|
754
+ if item.is_a?(Hash)
755
+ item["text"] || item["content"] || ""
756
+ else
757
+ item.to_s
758
+ end
759
+ end.join("\n")
651
760
  end
652
761
 
653
762
  def cleanup_transcript_thread
@@ -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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ClaudeSwarm
4
- VERSION = "0.3.9"
4
+ VERSION = "0.3.11"
5
5
  end
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.9
4
+ version: 0.3.11
5
5
  platform: ruby
6
6
  authors:
7
7
  - Paulo Arruda
@@ -140,6 +140,7 @@ executables:
140
140
  extensions: []
141
141
  extra_rdoc_files: []
142
142
  files:
143
+ - ".claude/commands/release.md"
143
144
  - ".rubocop.yml"
144
145
  - ".rubocop_todo.yml"
145
146
  - ".ruby-version"