claude_swarm 0.3.9 → 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: 55711fd9483c68d996c0a0e448c4d31135d7423269d1ed691bb3590db5487627
4
- data.tar.gz: a84c0b4e1165057cbbe22bcd4d6c02c8fcdaedb5abb2f18e150b667ee0083385
3
+ metadata.gz: a44d1b33e538b1efaeca7eb223f09bd617fcb4ba2288d7844773ae5908a10248
4
+ data.tar.gz: 28312f81eada550b05bbf568807be51c5077366c08d168b928a35a4d356148da
5
5
  SHA512:
6
- metadata.gz: cc2808da58a6cb2b2ac545cfbe4803382602d4339017337d3af84b92274baab241952186f7de4e72adfbd413d0fcb448b955fb4cb7ebb5ebc014b90895988247
7
- data.tar.gz: 2d58c3795f4ea80da550c6241ad7639ffa5d25fa868eb9af18bf58382961f8cad4fb930616c133197858193fb2f9627aead812bfd60a3565631ef38398351b16
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,28 @@
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
+
1
26
  ## [0.3.9]
2
27
 
3
28
  ### 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
@@ -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,
@@ -573,9 +573,6 @@ module ClaudeSwarm
573
573
  pretty_json = JSON.pretty_generate(json_data)
574
574
  logger.info { pretty_json }
575
575
  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
576
  logger.info { line.chomp }
580
577
  end
581
578
 
@@ -614,12 +611,14 @@ module ClaudeSwarm
614
611
  # Convert to session.log.json format
615
612
  session_entry = convert_transcript_to_session_format(transcript_entry)
616
613
 
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)
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
623
622
  end
624
623
  rescue JSON::ParserError
625
624
  # Silently skip unparseable lines
@@ -638,16 +637,92 @@ module ClaudeSwarm
638
637
  end
639
638
 
640
639
  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
- }
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")
651
726
  end
652
727
 
653
728
  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.10"
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.10
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"