claude_swarm 0.1.15 → 0.1.17
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/CHANGELOG.md +43 -0
- data/CLAUDE.md +9 -5
- data/README.md +119 -37
- data/claude-swarm.yml +18 -34
- data/example/microservices-team.yml +17 -17
- data/example/test-generation.yml +5 -5
- data/examples/monitoring-demo.yml +26 -0
- data/examples/multi-directory.yml +26 -0
- data/lib/claude_swarm/claude_code_executor.rb +19 -7
- data/lib/claude_swarm/claude_mcp_server.rb +2 -1
- data/lib/claude_swarm/cli.rb +86 -21
- data/lib/claude_swarm/commands/ps.rb +148 -0
- data/lib/claude_swarm/commands/show.rb +158 -0
- data/lib/claude_swarm/configuration.rb +20 -3
- data/lib/claude_swarm/mcp_generator.rb +6 -22
- data/lib/claude_swarm/orchestrator.rb +68 -9
- data/lib/claude_swarm/task_tool.rb +5 -2
- data/lib/claude_swarm/version.rb +1 -1
- data/lib/claude_swarm.rb +0 -2
- data/llms.txt +4 -5
- metadata +6 -4
- data/lib/claude_swarm/permission_mcp_server.rb +0 -189
- data/lib/claude_swarm/permission_tool.rb +0 -201
- /data/{sdk-docs.md → claude-sdk-docs.md} +0 -0
@@ -0,0 +1,26 @@
|
|
1
|
+
version: 1
|
2
|
+
swarm:
|
3
|
+
name: "Multi-Directory Example"
|
4
|
+
main: fullstack_dev
|
5
|
+
instances:
|
6
|
+
fullstack_dev:
|
7
|
+
description: "Full-stack developer with access to multiple project directories"
|
8
|
+
directory: [./frontend, ./backend, ./shared, ./docs]
|
9
|
+
model: opus
|
10
|
+
connections: [frontend_specialist, backend_specialist]
|
11
|
+
allowed_tools: [Read, Edit, Write, Bash, WebSearch]
|
12
|
+
prompt: "You are a full-stack developer with access to frontend, backend, shared code, and documentation directories"
|
13
|
+
|
14
|
+
frontend_specialist:
|
15
|
+
description: "Frontend developer focused on React components"
|
16
|
+
directory: ./frontend
|
17
|
+
model: sonnet
|
18
|
+
allowed_tools: [Read, Edit, Write, Bash]
|
19
|
+
prompt: "You specialize in React and frontend development"
|
20
|
+
|
21
|
+
backend_specialist:
|
22
|
+
description: "Backend developer focused on API development"
|
23
|
+
directory: ./backend
|
24
|
+
model: sonnet
|
25
|
+
allowed_tools: [Read, Edit, Write, Bash]
|
26
|
+
prompt: "You specialize in backend API development"
|
@@ -12,8 +12,9 @@ module ClaudeSwarm
|
|
12
12
|
|
13
13
|
def initialize(working_directory: Dir.pwd, model: nil, mcp_config: nil, vibe: false,
|
14
14
|
instance_name: nil, instance_id: nil, calling_instance: nil, calling_instance_id: nil,
|
15
|
-
claude_session_id: nil)
|
15
|
+
claude_session_id: nil, additional_directories: [])
|
16
16
|
@working_directory = working_directory
|
17
|
+
@additional_directories = additional_directories
|
17
18
|
@model = model
|
18
19
|
@mcp_config = mcp_config
|
19
20
|
@vibe = vibe
|
@@ -259,6 +260,12 @@ module ClaudeSwarm
|
|
259
260
|
|
260
261
|
cmd_array << "--verbose"
|
261
262
|
|
263
|
+
# Add additional directories with --add-dir
|
264
|
+
cmd_array << "--add-dir" if @additional_directories.any?
|
265
|
+
@additional_directories.each do |additional_dir|
|
266
|
+
cmd_array << additional_dir
|
267
|
+
end
|
268
|
+
|
262
269
|
# Add MCP config if specified
|
263
270
|
cmd_array += ["--mcp-config", @mcp_config] if @mcp_config
|
264
271
|
|
@@ -278,10 +285,18 @@ module ClaudeSwarm
|
|
278
285
|
if @vibe
|
279
286
|
cmd_array << "--dangerously-skip-permissions"
|
280
287
|
else
|
288
|
+
# Build allowed tools list including MCP connections
|
289
|
+
allowed_tools = options[:allowed_tools] ? Array(options[:allowed_tools]).dup : []
|
290
|
+
|
291
|
+
# Add mcp__instance_name for each connection if we have instance info
|
292
|
+
options[:connections]&.each do |connection_name|
|
293
|
+
allowed_tools << "mcp__#{connection_name}"
|
294
|
+
end
|
295
|
+
|
281
296
|
# Add allowed tools if any
|
282
|
-
if
|
283
|
-
|
284
|
-
cmd_array += ["--allowedTools",
|
297
|
+
if allowed_tools.any?
|
298
|
+
tools_str = allowed_tools.join(",")
|
299
|
+
cmd_array += ["--allowedTools", tools_str]
|
285
300
|
end
|
286
301
|
|
287
302
|
# Add disallowed tools if any
|
@@ -289,9 +304,6 @@ module ClaudeSwarm
|
|
289
304
|
disallowed_tools = Array(options[:disallowed_tools]).join(",")
|
290
305
|
cmd_array += ["--disallowedTools", disallowed_tools]
|
291
306
|
end
|
292
|
-
|
293
|
-
# Add permission prompt tool if not in vibe mode
|
294
|
-
cmd_array += ["--permission-prompt-tool", "mcp__permissions__check_permission"]
|
295
307
|
end
|
296
308
|
|
297
309
|
cmd_array
|
@@ -28,7 +28,8 @@ module ClaudeSwarm
|
|
28
28
|
instance_id: instance_config[:instance_id],
|
29
29
|
calling_instance: calling_instance,
|
30
30
|
calling_instance_id: calling_instance_id,
|
31
|
-
claude_session_id: instance_config[:claude_session_id]
|
31
|
+
claude_session_id: instance_config[:claude_session_id],
|
32
|
+
additional_directories: instance_config[:directories][1..] || []
|
32
33
|
)
|
33
34
|
|
34
35
|
# Set class variables so tools can access them
|
data/lib/claude_swarm/cli.rb
CHANGED
@@ -6,7 +6,6 @@ require_relative "configuration"
|
|
6
6
|
require_relative "mcp_generator"
|
7
7
|
require_relative "orchestrator"
|
8
8
|
require_relative "claude_mcp_server"
|
9
|
-
require_relative "permission_mcp_server"
|
10
9
|
|
11
10
|
module ClaudeSwarm
|
12
11
|
class CLI < Thor
|
@@ -72,16 +71,20 @@ module ClaudeSwarm
|
|
72
71
|
desc: "Instance name"
|
73
72
|
method_option :directory, aliases: "-d", type: :string, required: true,
|
74
73
|
desc: "Working directory for the instance"
|
74
|
+
method_option :directories, type: :array,
|
75
|
+
desc: "All directories (including main directory) for the instance"
|
75
76
|
method_option :model, aliases: "-m", type: :string, required: true,
|
76
77
|
desc: "Claude model to use (e.g., opus, sonnet)"
|
77
78
|
method_option :prompt, aliases: "-p", type: :string,
|
78
79
|
desc: "System prompt for the instance"
|
79
80
|
method_option :description, type: :string,
|
80
81
|
desc: "Description of the instance's role"
|
81
|
-
method_option :
|
82
|
-
|
82
|
+
method_option :allowed_tools, aliases: "-t", type: :array,
|
83
|
+
desc: "Allowed tools for the instance"
|
83
84
|
method_option :disallowed_tools, type: :array,
|
84
85
|
desc: "Disallowed tools for the instance"
|
86
|
+
method_option :connections, type: :array,
|
87
|
+
desc: "Connections to other instances"
|
85
88
|
method_option :mcp_config_path, type: :string,
|
86
89
|
desc: "Path to MCP configuration file"
|
87
90
|
method_option :debug, type: :boolean, default: false,
|
@@ -100,13 +103,15 @@ module ClaudeSwarm
|
|
100
103
|
instance_config = {
|
101
104
|
name: options[:name],
|
102
105
|
directory: options[:directory],
|
106
|
+
directories: options[:directories] || [options[:directory]],
|
103
107
|
model: options[:model],
|
104
108
|
prompt: options[:prompt],
|
105
109
|
description: options[:description],
|
106
|
-
|
110
|
+
allowed_tools: options[:allowed_tools] || [],
|
107
111
|
disallowed_tools: options[:disallowed_tools] || [],
|
112
|
+
connections: options[:connections] || [],
|
108
113
|
mcp_config_path: options[:mcp_config_path],
|
109
|
-
vibe: options[:vibe],
|
114
|
+
vibe: options[:vibe] || false,
|
110
115
|
instance_id: options[:instance_id],
|
111
116
|
claude_session_id: options[:claude_session_id]
|
112
117
|
}
|
@@ -192,6 +197,82 @@ module ClaudeSwarm
|
|
192
197
|
say "Claude Swarm #{VERSION}"
|
193
198
|
end
|
194
199
|
|
200
|
+
desc "ps", "List running Claude Swarm sessions"
|
201
|
+
def ps
|
202
|
+
require_relative "commands/ps"
|
203
|
+
Commands::Ps.new.execute
|
204
|
+
end
|
205
|
+
|
206
|
+
desc "show SESSION_ID", "Show detailed session information"
|
207
|
+
def show(session_id)
|
208
|
+
require_relative "commands/show"
|
209
|
+
Commands::Show.new.execute(session_id)
|
210
|
+
end
|
211
|
+
|
212
|
+
desc "clean", "Remove stale session symlinks"
|
213
|
+
method_option :days, aliases: "-d", type: :numeric, default: 7,
|
214
|
+
desc: "Remove sessions older than N days"
|
215
|
+
def clean
|
216
|
+
run_dir = File.expand_path("~/.claude-swarm/run")
|
217
|
+
unless Dir.exist?(run_dir)
|
218
|
+
say "No run directory found", :yellow
|
219
|
+
return
|
220
|
+
end
|
221
|
+
|
222
|
+
cleaned = 0
|
223
|
+
Dir.glob("#{run_dir}/*").each do |symlink|
|
224
|
+
next unless File.symlink?(symlink)
|
225
|
+
|
226
|
+
begin
|
227
|
+
# Remove if target doesn't exist (stale)
|
228
|
+
unless File.exist?(File.readlink(symlink))
|
229
|
+
File.unlink(symlink)
|
230
|
+
cleaned += 1
|
231
|
+
next
|
232
|
+
end
|
233
|
+
|
234
|
+
# Remove if older than specified days
|
235
|
+
if File.stat(symlink).mtime < Time.now - (options[:days] * 86_400)
|
236
|
+
File.unlink(symlink)
|
237
|
+
cleaned += 1
|
238
|
+
end
|
239
|
+
rescue StandardError
|
240
|
+
# Skip problematic symlinks
|
241
|
+
end
|
242
|
+
end
|
243
|
+
|
244
|
+
say "Cleaned #{cleaned} stale session#{cleaned == 1 ? "" : "s"}", :green
|
245
|
+
end
|
246
|
+
|
247
|
+
desc "watch SESSION_ID", "Watch session logs"
|
248
|
+
method_option :lines, aliases: "-n", type: :numeric, default: 100,
|
249
|
+
desc: "Number of lines to show initially"
|
250
|
+
def watch(session_id)
|
251
|
+
# Find session path
|
252
|
+
run_symlink = File.join(File.expand_path("~/.claude-swarm/run"), session_id)
|
253
|
+
session_path = if File.symlink?(run_symlink)
|
254
|
+
File.readlink(run_symlink)
|
255
|
+
else
|
256
|
+
# Search in sessions directory
|
257
|
+
Dir.glob(File.expand_path("~/.claude-swarm/sessions/*/*")).find do |path|
|
258
|
+
File.basename(path) == session_id
|
259
|
+
end
|
260
|
+
end
|
261
|
+
|
262
|
+
unless session_path && Dir.exist?(session_path)
|
263
|
+
error "Session not found: #{session_id}"
|
264
|
+
exit 1
|
265
|
+
end
|
266
|
+
|
267
|
+
log_file = File.join(session_path, "session.log")
|
268
|
+
unless File.exist?(log_file)
|
269
|
+
error "Log file not found for session: #{session_id}"
|
270
|
+
exit 1
|
271
|
+
end
|
272
|
+
|
273
|
+
exec("tail", "-f", "-n", options[:lines].to_s, log_file)
|
274
|
+
end
|
275
|
+
|
195
276
|
desc "list-sessions", "List all available Claude Swarm sessions"
|
196
277
|
method_option :limit, aliases: "-l", type: :numeric, default: 10,
|
197
278
|
desc: "Maximum number of sessions to display"
|
@@ -265,22 +346,6 @@ module ClaudeSwarm
|
|
265
346
|
say " claude-swarm --session-id <session-id>", :cyan
|
266
347
|
end
|
267
348
|
|
268
|
-
desc "tools-mcp", "Start a permission management MCP server for tool access control"
|
269
|
-
method_option :allowed_tools, aliases: "-t", type: :string,
|
270
|
-
desc: "Comma-separated list of allowed tool patterns (supports wildcards)"
|
271
|
-
method_option :disallowed_tools, type: :string,
|
272
|
-
desc: "Comma-separated list of disallowed tool patterns (supports wildcards)"
|
273
|
-
method_option :debug, type: :boolean, default: false,
|
274
|
-
desc: "Enable debug output"
|
275
|
-
def tools_mcp
|
276
|
-
server = PermissionMcpServer.new(allowed_tools: options[:allowed_tools], disallowed_tools: options[:disallowed_tools])
|
277
|
-
server.start
|
278
|
-
rescue StandardError => e
|
279
|
-
error "Error starting permission MCP server: #{e.message}"
|
280
|
-
error e.backtrace.join("\n") if options[:debug]
|
281
|
-
exit 1
|
282
|
-
end
|
283
|
-
|
284
349
|
default_task :start
|
285
350
|
|
286
351
|
private
|
@@ -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
|
@@ -86,9 +86,13 @@ module ClaudeSwarm
|
|
86
86
|
# Support both 'tools' (deprecated) and 'allowed_tools' for backward compatibility
|
87
87
|
allowed_tools = config["allowed_tools"] || config["tools"] || []
|
88
88
|
|
89
|
+
# Parse directory field - support both string and array
|
90
|
+
directories = parse_directories(config["directory"])
|
91
|
+
|
89
92
|
{
|
90
93
|
name: name,
|
91
|
-
directory:
|
94
|
+
directory: directories.first, # Keep single directory for backward compatibility
|
95
|
+
directories: directories, # New field with all directories
|
92
96
|
model: config["model"] || "sonnet",
|
93
97
|
connections: Array(config["connections"]),
|
94
98
|
tools: Array(allowed_tools), # Keep as 'tools' internally for compatibility
|
@@ -156,8 +160,10 @@ module ClaudeSwarm
|
|
156
160
|
|
157
161
|
def validate_directories
|
158
162
|
@instances.each do |name, instance|
|
159
|
-
|
160
|
-
|
163
|
+
# Validate all directories in the directories array
|
164
|
+
instance[:directories].each do |directory|
|
165
|
+
raise Error, "Directory '#{directory}' for instance '#{name}' does not exist" unless File.directory?(directory)
|
166
|
+
end
|
161
167
|
end
|
162
168
|
end
|
163
169
|
|
@@ -168,6 +174,17 @@ module ClaudeSwarm
|
|
168
174
|
raise Error, "Instance '#{instance_name}' field '#{field_name}' must be an array, got #{field_value.class.name}" unless field_value.is_a?(Array)
|
169
175
|
end
|
170
176
|
|
177
|
+
def parse_directories(directory_config)
|
178
|
+
# Default to current directory if not specified
|
179
|
+
directory_config ||= "."
|
180
|
+
|
181
|
+
# Convert to array and expand paths
|
182
|
+
directories = Array(directory_config).map { |dir| expand_path(dir) }
|
183
|
+
|
184
|
+
# Ensure at least one directory
|
185
|
+
directories.empty? ? [expand_path(".")] : directories
|
186
|
+
end
|
187
|
+
|
171
188
|
def expand_path(path)
|
172
189
|
Pathname.new(path).expand_path(@base_dir).to_s
|
173
190
|
end
|
@@ -68,9 +68,6 @@ module ClaudeSwarm
|
|
68
68
|
)
|
69
69
|
end
|
70
70
|
|
71
|
-
# Add permission MCP server if not in vibe mode (global or instance-specific)
|
72
|
-
mcp_servers["permissions"] = build_permission_mcp_config(instance[:tools], instance[:disallowed_tools]) unless @vibe || instance[:vibe]
|
73
|
-
|
74
71
|
config = {
|
75
72
|
"instance_id" => @instance_ids[name],
|
76
73
|
"instance_name" => name,
|
@@ -110,15 +107,20 @@ module ClaudeSwarm
|
|
110
107
|
"--model", instance[:model]
|
111
108
|
]
|
112
109
|
|
110
|
+
# Add directories array if we have multiple directories
|
111
|
+
args.push("--directories", *instance[:directories]) if instance[:directories] && instance[:directories].size > 1
|
112
|
+
|
113
113
|
# Add optional arguments
|
114
114
|
args.push("--prompt", instance[:prompt]) if instance[:prompt]
|
115
115
|
|
116
116
|
args.push("--description", instance[:description]) if instance[:description]
|
117
117
|
|
118
|
-
args.push("--tools", instance[:
|
118
|
+
args.push("--allowed-tools", instance[:allowed_tools].join(",")) if instance[:allowed_tools] && !instance[:allowed_tools].empty?
|
119
119
|
|
120
120
|
args.push("--disallowed-tools", instance[:disallowed_tools].join(",")) if instance[:disallowed_tools] && !instance[:disallowed_tools].empty?
|
121
121
|
|
122
|
+
args.push("--connections", instance[:connections].join(",")) if instance[:connections] && !instance[:connections].empty?
|
123
|
+
|
122
124
|
args.push("--mcp-config-path", mcp_config_path(name))
|
123
125
|
|
124
126
|
args.push("--calling-instance", calling_instance) if calling_instance
|
@@ -162,23 +164,5 @@ module ClaudeSwarm
|
|
162
164
|
# Skip invalid state files
|
163
165
|
end
|
164
166
|
end
|
165
|
-
|
166
|
-
def build_permission_mcp_config(allowed_tools, disallowed_tools)
|
167
|
-
exe_path = "claude-swarm"
|
168
|
-
|
169
|
-
args = ["tools-mcp"]
|
170
|
-
|
171
|
-
# Add allowed tools if specified
|
172
|
-
args.push("--allowed-tools", allowed_tools.join(",")) if allowed_tools && !allowed_tools.empty?
|
173
|
-
|
174
|
-
# Add disallowed tools if specified
|
175
|
-
args.push("--disallowed-tools", disallowed_tools.join(",")) if disallowed_tools && !disallowed_tools.empty?
|
176
|
-
|
177
|
-
{
|
178
|
-
"type" => "stdio",
|
179
|
-
"command" => exe_path,
|
180
|
-
"args" => args
|
181
|
-
}
|
182
|
-
end
|
183
167
|
end
|
184
168
|
end
|