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 +4 -4
- data/.claude/commands/release.md +27 -0
- data/CHANGELOG.md +47 -0
- data/README.md +2 -2
- data/lib/claude_swarm/cli.rb +9 -6
- data/lib/claude_swarm/commands/ps.rb +21 -2
- data/lib/claude_swarm/configuration.rb +16 -10
- data/lib/claude_swarm/orchestrator.rb +137 -28
- 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: 8e0bade0292291825648214e0a1a420241ec1d6ffff018831d131d5c0f532e45
|
4
|
+
data.tar.gz: 74d62b916f1dbde6b725e8990924839c6ace2b6de75cb812f43305d044def7b7
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
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
|
data/lib/claude_swarm/cli.rb
CHANGED
@@ -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
|
-
|
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
|
-
|
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
|
-
|
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,
|
@@ -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
|
-
|
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
|
-
|
132
|
+
begin
|
133
|
+
non_interactive_output { print("🌳 Setting up Git worktrees...") }
|
119
134
|
|
120
|
-
|
121
|
-
|
122
|
-
|
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
|
-
|
139
|
+
@worktree_manager.setup_worktrees(all_instances)
|
125
140
|
|
126
|
-
|
127
|
-
|
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
|
-
#
|
618
|
-
|
619
|
-
|
620
|
-
|
621
|
-
|
622
|
-
|
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
|
-
|
643
|
-
|
644
|
-
|
645
|
-
|
646
|
-
|
647
|
-
|
648
|
-
|
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
|
-
|
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.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"
|