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 +4 -4
- data/.claude/commands/release.md +27 -0
- data/CHANGELOG.md +25 -0
- data/README.md +2 -2
- data/lib/claude_swarm/commands/ps.rb +21 -2
- data/lib/claude_swarm/orchestrator.rb +94 -19
- data/lib/claude_swarm/session_cost_calculator.rb +124 -8
- data/lib/claude_swarm/version.rb +1 -1
- metadata +2 -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,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
|
-
|
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,
|
@@ -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
|
-
#
|
618
|
-
|
619
|
-
|
620
|
-
|
621
|
-
|
622
|
-
|
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
|
-
|
643
|
-
|
644
|
-
|
645
|
-
|
646
|
-
|
647
|
-
|
648
|
-
|
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
|
-
|
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
|
data/lib/claude_swarm/version.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
|
@@ -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"
|