claude_swarm 0.1.16 → 0.1.18

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.
@@ -26,6 +26,9 @@ module ClaudeSwarm
26
26
  desc: "Enable debug output"
27
27
  method_option :session_id, type: :string,
28
28
  desc: "Resume a previous session by ID or path"
29
+ method_option :worktree, type: :string, aliases: "-w",
30
+ desc: "Create instances in Git worktrees with the given name (auto-generated if true)",
31
+ banner: "[NAME]"
29
32
  def start(config_file = nil)
30
33
  # Handle session restoration
31
34
  if options[:session_id]
@@ -54,7 +57,8 @@ module ClaudeSwarm
54
57
  vibe: options[:vibe],
55
58
  prompt: options[:prompt],
56
59
  stream_logs: options[:stream_logs],
57
- debug: options[:debug])
60
+ debug: options[:debug],
61
+ worktree: options[:worktree])
58
62
  orchestrator.start
59
63
  rescue Error => e
60
64
  error e.message
@@ -71,6 +75,8 @@ module ClaudeSwarm
71
75
  desc: "Instance name"
72
76
  method_option :directory, aliases: "-d", type: :string, required: true,
73
77
  desc: "Working directory for the instance"
78
+ method_option :directories, type: :array,
79
+ desc: "All directories (including main directory) for the instance"
74
80
  method_option :model, aliases: "-m", type: :string, required: true,
75
81
  desc: "Claude model to use (e.g., opus, sonnet)"
76
82
  method_option :prompt, aliases: "-p", type: :string,
@@ -101,6 +107,7 @@ module ClaudeSwarm
101
107
  instance_config = {
102
108
  name: options[:name],
103
109
  directory: options[:directory],
110
+ directories: options[:directories] || [options[:directory]],
104
111
  model: options[:model],
105
112
  prompt: options[:prompt],
106
113
  description: options[:description],
@@ -108,7 +115,7 @@ module ClaudeSwarm
108
115
  disallowed_tools: options[:disallowed_tools] || [],
109
116
  connections: options[:connections] || [],
110
117
  mcp_config_path: options[:mcp_config_path],
111
- vibe: options[:vibe],
118
+ vibe: options[:vibe] || false,
112
119
  instance_id: options[:instance_id],
113
120
  claude_session_id: options[:claude_session_id]
114
121
  }
@@ -144,6 +151,10 @@ module ClaudeSwarm
144
151
  swarm:
145
152
  name: "Swarm Name"
146
153
  main: lead_developer
154
+ # before: # Optional: commands to run before launching swarm (executed in sequence)
155
+ # - "echo 'Setting up environment...'"
156
+ # - "npm install"
157
+ # - "docker-compose up -d"
147
158
  instances:
148
159
  lead_developer:
149
160
  description: "Lead developer who coordinates the team and makes architectural decisions"
@@ -194,6 +205,82 @@ module ClaudeSwarm
194
205
  say "Claude Swarm #{VERSION}"
195
206
  end
196
207
 
208
+ desc "ps", "List running Claude Swarm sessions"
209
+ def ps
210
+ require_relative "commands/ps"
211
+ Commands::Ps.new.execute
212
+ end
213
+
214
+ desc "show SESSION_ID", "Show detailed session information"
215
+ def show(session_id)
216
+ require_relative "commands/show"
217
+ Commands::Show.new.execute(session_id)
218
+ end
219
+
220
+ desc "clean", "Remove stale session symlinks"
221
+ method_option :days, aliases: "-d", type: :numeric, default: 7,
222
+ desc: "Remove sessions older than N days"
223
+ def clean
224
+ run_dir = File.expand_path("~/.claude-swarm/run")
225
+ unless Dir.exist?(run_dir)
226
+ say "No run directory found", :yellow
227
+ return
228
+ end
229
+
230
+ cleaned = 0
231
+ Dir.glob("#{run_dir}/*").each do |symlink|
232
+ next unless File.symlink?(symlink)
233
+
234
+ begin
235
+ # Remove if target doesn't exist (stale)
236
+ unless File.exist?(File.readlink(symlink))
237
+ File.unlink(symlink)
238
+ cleaned += 1
239
+ next
240
+ end
241
+
242
+ # Remove if older than specified days
243
+ if File.stat(symlink).mtime < Time.now - (options[:days] * 86_400)
244
+ File.unlink(symlink)
245
+ cleaned += 1
246
+ end
247
+ rescue StandardError
248
+ # Skip problematic symlinks
249
+ end
250
+ end
251
+
252
+ say "Cleaned #{cleaned} stale session#{"s" unless cleaned == 1}", :green
253
+ end
254
+
255
+ desc "watch SESSION_ID", "Watch session logs"
256
+ method_option :lines, aliases: "-n", type: :numeric, default: 100,
257
+ desc: "Number of lines to show initially"
258
+ def watch(session_id)
259
+ # Find session path
260
+ run_symlink = File.join(File.expand_path("~/.claude-swarm/run"), session_id)
261
+ session_path = if File.symlink?(run_symlink)
262
+ File.readlink(run_symlink)
263
+ else
264
+ # Search in sessions directory
265
+ Dir.glob(File.expand_path("~/.claude-swarm/sessions/*/*")).find do |path|
266
+ File.basename(path) == session_id
267
+ end
268
+ end
269
+
270
+ unless session_path && Dir.exist?(session_path)
271
+ error "Session not found: #{session_id}"
272
+ exit 1
273
+ end
274
+
275
+ log_file = File.join(session_path, "session.log")
276
+ unless File.exist?(log_file)
277
+ error "Log file not found for session: #{session_id}"
278
+ exit 1
279
+ end
280
+
281
+ exec("tail", "-f", "-n", options[:lines].to_s, log_file)
282
+ end
283
+
197
284
  desc "list-sessions", "List all available Claude Swarm sessions"
198
285
  method_option :limit, aliases: "-l", type: :numeric, default: 10,
199
286
  desc: "Maximum number of sessions to display"
@@ -316,6 +403,17 @@ module ClaudeSwarm
316
403
 
317
404
  config = Configuration.new(config_file, base_dir: Dir.pwd)
318
405
 
406
+ # Load session metadata if it exists to check for worktree info
407
+ session_metadata_file = File.join(session_path, "session_metadata.json")
408
+ worktree_name = nil
409
+ if File.exist?(session_metadata_file)
410
+ metadata = JSON.parse(File.read(session_metadata_file))
411
+ if metadata["worktree"] && metadata["worktree"]["enabled"]
412
+ worktree_name = metadata["worktree"]["name"]
413
+ say "Restoring with worktree: #{worktree_name}", :green unless options[:prompt]
414
+ end
415
+ end
416
+
319
417
  # Create orchestrator with restoration mode
320
418
  generator = McpGenerator.new(config, vibe: options[:vibe], restore_session_path: session_path)
321
419
  orchestrator = Orchestrator.new(config, generator,
@@ -323,7 +421,8 @@ module ClaudeSwarm
323
421
  prompt: options[:prompt],
324
422
  stream_logs: options[:stream_logs],
325
423
  debug: options[:debug],
326
- restore_session_path: session_path)
424
+ restore_session_path: session_path,
425
+ worktree: worktree_name)
327
426
  orchestrator.start
328
427
  rescue StandardError => e
329
428
  error "Failed to restore session: #{e.message}"
@@ -0,0 +1,148 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+ require "json"
5
+ require "time"
6
+
7
+ module ClaudeSwarm
8
+ module Commands
9
+ class Ps
10
+ RUN_DIR = File.expand_path("~/.claude-swarm/run")
11
+
12
+ def execute
13
+ unless Dir.exist?(RUN_DIR)
14
+ puts "No active sessions"
15
+ return
16
+ end
17
+
18
+ sessions = []
19
+
20
+ # Read all symlinks in run directory
21
+ Dir.glob("#{RUN_DIR}/*").each do |symlink|
22
+ next unless File.symlink?(symlink)
23
+
24
+ begin
25
+ session_dir = File.readlink(symlink)
26
+ # Skip if target doesn't exist (stale symlink)
27
+ next unless Dir.exist?(session_dir)
28
+
29
+ session_info = parse_session_info(session_dir)
30
+ sessions << session_info if session_info
31
+ rescue StandardError
32
+ # Skip problematic symlinks
33
+ end
34
+ end
35
+
36
+ if sessions.empty?
37
+ puts "No active sessions"
38
+ return
39
+ end
40
+
41
+ # Column widths
42
+ col_session = 15
43
+ col_swarm = 25
44
+ col_cost = 12
45
+ col_uptime = 10
46
+
47
+ # Display header with proper spacing
48
+ header = "#{
49
+ "SESSION_ID".ljust(col_session)
50
+ } #{
51
+ "SWARM_NAME".ljust(col_swarm)
52
+ } #{
53
+ "TOTAL_COST".ljust(col_cost)
54
+ } #{
55
+ "UPTIME".ljust(col_uptime)
56
+ } DIRECTORY"
57
+ puts "\n⚠️ \e[3mTotal cost does not include the cost of the main instance\e[0m\n\n"
58
+ puts header
59
+ puts "-" * header.length
60
+
61
+ # Display sessions sorted by start time (newest first)
62
+ sessions.sort_by { |s| s[:start_time] }.reverse.each do |session|
63
+ cost_str = format("$%.4f", session[:cost])
64
+ puts "#{
65
+ session[:id].ljust(col_session)
66
+ } #{
67
+ truncate(session[:name], col_swarm).ljust(col_swarm)
68
+ } #{
69
+ cost_str.ljust(col_cost)
70
+ } #{
71
+ session[:uptime].ljust(col_uptime)
72
+ } #{session[:directory]}"
73
+ end
74
+ end
75
+
76
+ private
77
+
78
+ def parse_session_info(session_dir)
79
+ session_id = File.basename(session_dir)
80
+
81
+ # Load config for swarm name and main directory
82
+ config_file = File.join(session_dir, "config.yml")
83
+ return nil unless File.exist?(config_file)
84
+
85
+ config = YAML.load_file(config_file)
86
+ swarm_name = config.dig("swarm", "name") || "Unknown"
87
+ main_instance = config.dig("swarm", "main")
88
+
89
+ # Get all directories - handle both string and array formats
90
+ dir_config = config.dig("swarm", "instances", main_instance, "directory")
91
+ directories = if dir_config.is_a?(Array)
92
+ dir_config
93
+ else
94
+ [dir_config || "."]
95
+ end
96
+ directories_str = directories.join(", ")
97
+
98
+ # Calculate total cost from JSON log
99
+ total_cost = calculate_total_cost(session_dir)
100
+
101
+ # Get uptime from directory creation time
102
+ start_time = File.stat(session_dir).ctime
103
+ uptime = format_duration(Time.now - start_time)
104
+
105
+ {
106
+ id: session_id,
107
+ name: swarm_name,
108
+ cost: total_cost,
109
+ uptime: uptime,
110
+ directory: directories_str,
111
+ start_time: start_time
112
+ }
113
+ rescue StandardError
114
+ nil
115
+ end
116
+
117
+ def calculate_total_cost(session_dir)
118
+ log_file = File.join(session_dir, "session.log.json")
119
+ return 0.0 unless File.exist?(log_file)
120
+
121
+ total = 0.0
122
+ File.foreach(log_file) do |line|
123
+ data = JSON.parse(line)
124
+ total += data["event"]["total_cost_usd"] if data.dig("event", "type") == "result" && data.dig("event", "total_cost_usd")
125
+ rescue JSON::ParserError
126
+ next
127
+ end
128
+ total
129
+ end
130
+
131
+ def format_duration(seconds)
132
+ if seconds < 60
133
+ "#{seconds.to_i}s"
134
+ elsif seconds < 3600
135
+ "#{(seconds / 60).to_i}m"
136
+ elsif seconds < 86_400
137
+ "#{(seconds / 3600).to_i}h"
138
+ else
139
+ "#{(seconds / 86_400).to_i}d"
140
+ end
141
+ end
142
+
143
+ def truncate(str, length)
144
+ str.length > length ? "#{str[0...length - 2]}.." : str
145
+ end
146
+ end
147
+ end
148
+ end
@@ -0,0 +1,158 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+ require "json"
5
+
6
+ module ClaudeSwarm
7
+ module Commands
8
+ class Show
9
+ def execute(session_id)
10
+ session_path = find_session_path(session_id)
11
+ unless session_path
12
+ puts "Session not found: #{session_id}"
13
+ exit 1
14
+ end
15
+
16
+ # Load config to get main instance name
17
+ config = YAML.load_file(File.join(session_path, "config.yml"))
18
+ main_instance_name = config.dig("swarm", "main")
19
+
20
+ # Parse all events to build instance data
21
+ instances = parse_instance_hierarchy(session_path, main_instance_name)
22
+
23
+ # Calculate total cost (excluding main if not available)
24
+ total_cost = instances.values.sum { |i| i[:cost] }
25
+ cost_display = if instances[main_instance_name] && instances[main_instance_name][:has_cost_data]
26
+ format("$%.4f", total_cost)
27
+ else
28
+ "#{format("$%.4f", total_cost)} (excluding main instance)"
29
+ end
30
+
31
+ # Display session info
32
+ puts "Session: #{session_id}"
33
+ puts "Swarm: #{config.dig("swarm", "name")}"
34
+ puts "Total Cost: #{cost_display}"
35
+
36
+ # Try to read start directory
37
+ start_dir_file = File.join(session_path, "start_directory")
38
+ puts "Start Directory: #{File.read(start_dir_file).strip}" if File.exist?(start_dir_file)
39
+
40
+ puts
41
+ puts "Instance Hierarchy:"
42
+ puts "-" * 50
43
+
44
+ # Find root instances
45
+ roots = instances.values.select { |i| i[:called_by].empty? }
46
+ roots.each do |instance|
47
+ display_instance_tree(instance, instances, 0, main_instance_name)
48
+ end
49
+
50
+ # Add note about interactive main instance
51
+ return if instances[main_instance_name]&.dig(:has_cost_data)
52
+
53
+ puts
54
+ puts "Note: Main instance (#{main_instance_name}) cost is not tracked in interactive mode."
55
+ puts " View costs directly in the Claude interface."
56
+ end
57
+
58
+ private
59
+
60
+ def find_session_path(session_id)
61
+ # First check the run directory
62
+ run_symlink = File.join(File.expand_path("~/.claude-swarm/run"), session_id)
63
+ if File.symlink?(run_symlink)
64
+ target = File.readlink(run_symlink)
65
+ return target if Dir.exist?(target)
66
+ end
67
+
68
+ # Fall back to searching all sessions
69
+ Dir.glob(File.expand_path("~/.claude-swarm/sessions/*/*")).find do |path|
70
+ File.basename(path) == session_id
71
+ end
72
+ end
73
+
74
+ def parse_instance_hierarchy(session_path, _main_instance_name)
75
+ log_file = File.join(session_path, "session.log.json")
76
+ instances = {}
77
+
78
+ return instances unless File.exist?(log_file)
79
+
80
+ File.foreach(log_file) do |line|
81
+ data = JSON.parse(line)
82
+ instance_name = data["instance"]
83
+ instance_id = data["instance_id"]
84
+ calling_instance = data["calling_instance"]
85
+
86
+ # Initialize instance data
87
+ instances[instance_name] ||= {
88
+ name: instance_name,
89
+ id: instance_id,
90
+ cost: 0.0,
91
+ calls: 0,
92
+ called_by: Set.new,
93
+ calls_to: Set.new,
94
+ has_cost_data: false
95
+ }
96
+
97
+ # Track relationships
98
+ if calling_instance && calling_instance != instance_name
99
+ instances[instance_name][:called_by] << calling_instance
100
+
101
+ instances[calling_instance] ||= {
102
+ name: calling_instance,
103
+ id: data["calling_instance_id"],
104
+ cost: 0.0,
105
+ calls: 0,
106
+ called_by: Set.new,
107
+ calls_to: Set.new,
108
+ has_cost_data: false
109
+ }
110
+ instances[calling_instance][:calls_to] << instance_name
111
+ end
112
+
113
+ # Track costs and calls
114
+ if data.dig("event", "type") == "result"
115
+ instances[instance_name][:calls] += 1
116
+ if (cost = data.dig("event", "total_cost_usd"))
117
+ instances[instance_name][:cost] += cost
118
+ instances[instance_name][:has_cost_data] = true
119
+ end
120
+ end
121
+ rescue JSON::ParserError
122
+ next
123
+ end
124
+
125
+ instances
126
+ end
127
+
128
+ def display_instance_tree(instance, all_instances, level, main_instance_name)
129
+ indent = " " * level
130
+ prefix = level.zero? ? "├─" : "└─"
131
+
132
+ # Display instance name with special marker for main
133
+ instance_display = instance[:name]
134
+ instance_display += " [main]" if instance[:name] == main_instance_name
135
+
136
+ puts "#{indent}#{prefix} #{instance_display} (#{instance[:id]})"
137
+
138
+ # Display cost - show n/a for main instance without cost data
139
+ cost_display = if instance[:name] == main_instance_name && !instance[:has_cost_data]
140
+ "n/a (interactive)"
141
+ else
142
+ format("$%.4f", instance[:cost])
143
+ end
144
+
145
+ puts "#{indent} Cost: #{cost_display} | Calls: #{instance[:calls]}"
146
+
147
+ # Display children
148
+ children = instance[:calls_to].map { |name| all_instances[name] }.compact
149
+ children.each do |child|
150
+ # Don't recurse if we've already shown this instance (avoid cycles)
151
+ next if level.positive? && child[:called_by].size > 1
152
+
153
+ display_instance_tree(child, all_instances, level + 1, main_instance_name)
154
+ end
155
+ end
156
+ end
157
+ end
158
+ end
@@ -26,6 +26,10 @@ module ClaudeSwarm
26
26
  instances[instance_name][:connections] || []
27
27
  end
28
28
 
29
+ def before_commands
30
+ @swarm["before"] || []
31
+ end
32
+
29
33
  private
30
34
 
31
35
  def load_and_validate
@@ -86,9 +90,13 @@ module ClaudeSwarm
86
90
  # Support both 'tools' (deprecated) and 'allowed_tools' for backward compatibility
87
91
  allowed_tools = config["allowed_tools"] || config["tools"] || []
88
92
 
93
+ # Parse directory field - support both string and array
94
+ directories = parse_directories(config["directory"])
95
+
89
96
  {
90
97
  name: name,
91
- directory: expand_path(config["directory"] || "."),
98
+ directory: directories.first, # Keep single directory for backward compatibility
99
+ directories: directories, # New field with all directories
92
100
  model: config["model"] || "sonnet",
93
101
  connections: Array(config["connections"]),
94
102
  tools: Array(allowed_tools), # Keep as 'tools' internally for compatibility
@@ -97,7 +105,8 @@ module ClaudeSwarm
97
105
  mcps: parse_mcps(config["mcps"] || []),
98
106
  prompt: config["prompt"],
99
107
  description: config["description"],
100
- vibe: config["vibe"] || false
108
+ vibe: config["vibe"] || false,
109
+ worktree: parse_worktree_value(config["worktree"])
101
110
  }
102
111
  end
103
112
 
@@ -156,8 +165,10 @@ module ClaudeSwarm
156
165
 
157
166
  def validate_directories
158
167
  @instances.each do |name, instance|
159
- directory = instance[:directory]
160
- raise Error, "Directory '#{directory}' for instance '#{name}' does not exist" unless File.directory?(directory)
168
+ # Validate all directories in the directories array
169
+ instance[:directories].each do |directory|
170
+ raise Error, "Directory '#{directory}' for instance '#{name}' does not exist" unless File.directory?(directory)
171
+ end
161
172
  end
162
173
  end
163
174
 
@@ -168,8 +179,27 @@ module ClaudeSwarm
168
179
  raise Error, "Instance '#{instance_name}' field '#{field_name}' must be an array, got #{field_value.class.name}" unless field_value.is_a?(Array)
169
180
  end
170
181
 
182
+ def parse_directories(directory_config)
183
+ # Default to current directory if not specified
184
+ directory_config ||= "."
185
+
186
+ # Convert to array and expand paths
187
+ directories = Array(directory_config).map { |dir| expand_path(dir) }
188
+
189
+ # Ensure at least one directory
190
+ directories.empty? ? [expand_path(".")] : directories
191
+ end
192
+
171
193
  def expand_path(path)
172
194
  Pathname.new(path).expand_path(@base_dir).to_s
173
195
  end
196
+
197
+ def parse_worktree_value(value)
198
+ return nil if value.nil? # Omitted means follow CLI behavior
199
+ return value if value.is_a?(TrueClass) || value.is_a?(FalseClass)
200
+ return value.to_s if value.is_a?(String) && !value.empty?
201
+
202
+ raise Error, "Invalid worktree value: #{value.inspect}. Must be true, false, or a non-empty string"
203
+ end
174
204
  end
175
205
  end
@@ -107,6 +107,9 @@ module ClaudeSwarm
107
107
  "--model", instance[:model]
108
108
  ]
109
109
 
110
+ # Add directories array if we have multiple directories
111
+ args.push("--directories", *instance[:directories]) if instance[:directories] && instance[:directories].size > 1
112
+
110
113
  # Add optional arguments
111
114
  args.push("--prompt", instance[:prompt]) if instance[:prompt]
112
115