claude_swarm 0.1.20 → 0.2.0

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.
Files changed (39) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +9 -66
  3. data/.rubocop_todo.yml +11 -0
  4. data/CHANGELOG.md +93 -0
  5. data/CLAUDE.md +61 -0
  6. data/README.md +172 -15
  7. data/Rakefile +1 -1
  8. data/examples/mixed-provider-swarm.yml +23 -0
  9. data/lib/claude_swarm/claude_code_executor.rb +7 -12
  10. data/lib/claude_swarm/claude_mcp_server.rb +26 -12
  11. data/lib/claude_swarm/cli.rb +293 -165
  12. data/lib/claude_swarm/commands/ps.rb +22 -24
  13. data/lib/claude_swarm/commands/show.rb +45 -63
  14. data/lib/claude_swarm/configuration.rb +137 -8
  15. data/lib/claude_swarm/mcp_generator.rb +39 -14
  16. data/lib/claude_swarm/openai/chat_completion.rb +264 -0
  17. data/lib/claude_swarm/openai/executor.rb +301 -0
  18. data/lib/claude_swarm/openai/responses.rb +338 -0
  19. data/lib/claude_swarm/orchestrator.rb +205 -39
  20. data/lib/claude_swarm/process_tracker.rb +7 -7
  21. data/lib/claude_swarm/session_cost_calculator.rb +93 -0
  22. data/lib/claude_swarm/session_path.rb +3 -5
  23. data/lib/claude_swarm/system_utils.rb +1 -3
  24. data/lib/claude_swarm/tools/reset_session_tool.rb +24 -0
  25. data/lib/claude_swarm/tools/session_info_tool.rb +24 -0
  26. data/lib/claude_swarm/tools/task_tool.rb +43 -0
  27. data/lib/claude_swarm/version.rb +1 -1
  28. data/lib/claude_swarm/worktree_manager.rb +13 -20
  29. data/lib/claude_swarm.rb +23 -10
  30. data/single.yml +482 -6
  31. metadata +50 -16
  32. data/claude-swarm.yml +0 -64
  33. data/lib/claude_swarm/reset_session_tool.rb +0 -22
  34. data/lib/claude_swarm/session_info_tool.rb +0 -22
  35. data/lib/claude_swarm/task_tool.rb +0 -39
  36. /data/{example → examples}/claude-swarm.yml +0 -0
  37. /data/{example → examples}/microservices-team.yml +0 -0
  38. /data/{example → examples}/session-restoration-demo.yml +0 -0
  39. /data/{example → examples}/test-generation.yml +0 -0
@@ -1,9 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "yaml"
4
- require "json"
5
- require "time"
6
-
7
3
  module ClaudeSwarm
8
4
  module Commands
9
5
  class Ps
@@ -80,7 +76,7 @@ module ClaudeSwarm
80
76
 
81
77
  # Load config for swarm name and main directory
82
78
  config_file = File.join(session_dir, "config.yml")
83
- return nil unless File.exist?(config_file)
79
+ return unless File.exist?(config_file)
84
80
 
85
81
  config = YAML.load_file(config_file)
86
82
  swarm_name = config.dig("swarm", "name") || "Unknown"
@@ -94,10 +90,10 @@ module ClaudeSwarm
94
90
  # Get all directories - handle both string and array formats
95
91
  dir_config = config.dig("swarm", "instances", main_instance, "directory")
96
92
  directories = if dir_config.is_a?(Array)
97
- dir_config
98
- else
99
- [dir_config || "."]
100
- end
93
+ dir_config
94
+ else
95
+ [dir_config || "."]
96
+ end
101
97
 
102
98
  # Expand paths relative to the base directory
103
99
  expanded_directories = directories.map do |dir|
@@ -110,10 +106,11 @@ module ClaudeSwarm
110
106
  directories_str = expanded_directories.join(", ")
111
107
 
112
108
  # Calculate total cost from JSON log
113
- total_cost = calculate_total_cost(session_dir)
109
+ log_file = File.join(session_dir, "session.log.json")
110
+ total_cost = SessionCostCalculator.calculate_simple_total(log_file)
114
111
 
115
- # Get uptime from directory creation time
116
- start_time = File.stat(session_dir).ctime
112
+ # Get uptime from session metadata or fallback to directory creation time
113
+ start_time = get_start_time(session_dir)
117
114
  uptime = format_duration(Time.now - start_time)
118
115
 
119
116
  {
@@ -122,24 +119,25 @@ module ClaudeSwarm
122
119
  cost: total_cost,
123
120
  uptime: uptime,
124
121
  directory: directories_str,
125
- start_time: start_time
122
+ start_time: start_time,
126
123
  }
127
124
  rescue StandardError
128
125
  nil
129
126
  end
130
127
 
131
- def calculate_total_cost(session_dir)
132
- log_file = File.join(session_dir, "session.log.json")
133
- return 0.0 unless File.exist?(log_file)
134
-
135
- total = 0.0
136
- File.foreach(log_file) do |line|
137
- data = JSON.parse(line)
138
- total += data["event"]["total_cost_usd"] if data.dig("event", "type") == "result" && data.dig("event", "total_cost_usd")
139
- rescue JSON::ParserError
140
- next
128
+ def get_start_time(session_dir)
129
+ # Try to get from session metadata first
130
+ metadata_file = File.join(session_dir, "session_metadata.json")
131
+ if File.exist?(metadata_file)
132
+ metadata = JSON.parse(File.read(metadata_file))
133
+ return Time.parse(metadata["start_time"]) if metadata["start_time"]
141
134
  end
142
- total
135
+
136
+ # Fallback to directory creation time
137
+ File.stat(session_dir).ctime
138
+ rescue StandardError
139
+ # If anything fails, use directory creation time
140
+ File.stat(session_dir).ctime
143
141
  end
144
142
 
145
143
  def format_duration(seconds)
@@ -1,8 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "yaml"
4
- require "json"
5
-
6
3
  module ClaudeSwarm
7
4
  module Commands
8
5
  class Show
@@ -10,7 +7,7 @@ module ClaudeSwarm
10
7
  session_path = find_session_path(session_id)
11
8
  unless session_path
12
9
  puts "Session not found: #{session_id}"
13
- exit 1
10
+ exit(1)
14
11
  end
15
12
 
16
13
  # Load config to get main instance name
@@ -18,19 +15,26 @@ module ClaudeSwarm
18
15
  main_instance_name = config.dig("swarm", "main")
19
16
 
20
17
  # Parse all events to build instance data
21
- instances = parse_instance_hierarchy(session_path, main_instance_name)
18
+ log_file = File.join(session_path, "session.log.json")
19
+ instances = SessionCostCalculator.parse_instance_hierarchy(log_file)
22
20
 
23
21
  # Calculate total cost (excluding main if not available)
24
22
  total_cost = instances.values.sum { |i| i[:cost] }
25
23
  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
24
+ format("$%.4f", total_cost)
25
+ else
26
+ "#{format("$%.4f", total_cost)} (excluding main instance)"
27
+ end
30
28
 
31
29
  # Display session info
32
30
  puts "Session: #{session_id}"
31
+ puts "Session Path: #{session_path}"
33
32
  puts "Swarm: #{config.dig("swarm", "name")}"
33
+
34
+ # Display runtime if available
35
+ runtime_info = get_runtime_info(session_path)
36
+ puts "Runtime: #{runtime_info}" if runtime_info
37
+
34
38
  puts "Total Cost: #{cost_display}"
35
39
 
36
40
  # Try to read start directory
@@ -71,58 +75,36 @@ module ClaudeSwarm
71
75
  end
72
76
  end
73
77
 
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
78
+ def get_runtime_info(session_path)
79
+ metadata_file = File.join(session_path, "session_metadata.json")
80
+ return unless File.exist?(metadata_file)
81
+
82
+ metadata = JSON.parse(File.read(metadata_file))
83
+
84
+ if metadata["duration_seconds"]
85
+ # Session has completed
86
+ format_duration(metadata["duration_seconds"])
87
+ elsif metadata["start_time"]
88
+ # Session is still running or was interrupted
89
+ start_time = Time.parse(metadata["start_time"])
90
+ duration = (Time.now - start_time).to_i
91
+ "#{format_duration(duration)} (active)"
123
92
  end
93
+ rescue StandardError
94
+ nil
95
+ end
124
96
 
125
- instances
97
+ def format_duration(seconds)
98
+ hours = seconds / 3600
99
+ minutes = (seconds % 3600) / 60
100
+ secs = seconds % 60
101
+
102
+ parts = []
103
+ parts << "#{hours}h" if hours.positive?
104
+ parts << "#{minutes}m" if minutes.positive?
105
+ parts << "#{secs}s"
106
+
107
+ parts.join(" ")
126
108
  end
127
109
 
128
110
  def display_instance_tree(instance, all_instances, level, main_instance_name)
@@ -137,10 +119,10 @@ module ClaudeSwarm
137
119
 
138
120
  # Display cost - show n/a for main instance without cost data
139
121
  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
122
+ "n/a (interactive)"
123
+ else
124
+ format("$%.4f", instance[:cost])
125
+ end
144
126
 
145
127
  puts "#{indent} Cost: #{cost_display} | Calls: #{instance[:calls]}"
146
128
 
@@ -1,16 +1,25 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "yaml"
4
- require "pathname"
5
-
6
3
  module ClaudeSwarm
7
4
  class Configuration
5
+ # Frozen constants for validation
6
+ VALID_PROVIDERS = ["claude", "openai"].freeze
7
+ OPENAI_SPECIFIC_FIELDS = ["temperature", "api_version", "openai_token_env", "base_url", "reasoning_effort"].freeze
8
+ VALID_API_VERSIONS = ["chat_completion", "responses"].freeze
9
+ VALID_REASONING_EFFORTS = ["low", "medium", "high"].freeze
10
+
11
+ # Regex patterns
12
+ ENV_VAR_PATTERN = /\$\{([^}]+)\}/
13
+ ENV_VAR_WITH_DEFAULT_PATTERN = /\$\{([^:}]+)(:=([^}]*))?\}/
14
+ O_SERIES_MODEL_PATTERN = /^o\d+(\s+(Preview|preview))?(-pro|-mini|-deep-research|-mini-deep-research)?$/
15
+
8
16
  attr_reader :config, :config_path, :swarm, :swarm_name, :main_instance, :instances
9
17
 
10
- def initialize(config_path, base_dir: nil)
18
+ def initialize(config_path, base_dir: nil, options: {})
11
19
  @config_path = Pathname.new(config_path).expand_path
12
20
  @config_dir = @config_path.dirname
13
21
  @base_dir = base_dir || @config_dir
22
+ @options = options
14
23
  load_and_validate
15
24
  end
16
25
 
@@ -30,10 +39,15 @@ module ClaudeSwarm
30
39
  @swarm["before"] || []
31
40
  end
32
41
 
42
+ def after_commands
43
+ @swarm["after"] || []
44
+ end
45
+
33
46
  private
34
47
 
35
48
  def load_and_validate
36
49
  @config = YAML.load_file(@config_path)
50
+ interpolate_env_vars!(@config)
37
51
  validate_version
38
52
  validate_swarm
39
53
  parse_swarm
@@ -44,6 +58,35 @@ module ClaudeSwarm
44
58
  raise Error, "Invalid YAML syntax: #{e.message}"
45
59
  end
46
60
 
61
+ def interpolate_env_vars!(obj)
62
+ case obj
63
+ when String
64
+ interpolate_env_string(obj)
65
+ when Hash
66
+ obj.transform_values! { |v| interpolate_env_vars!(v) }
67
+ when Array
68
+ obj.map! { |v| interpolate_env_vars!(v) }
69
+ else
70
+ obj
71
+ end
72
+ end
73
+
74
+ def interpolate_env_string(str)
75
+ str.gsub(ENV_VAR_WITH_DEFAULT_PATTERN) do |_match|
76
+ env_var = Regexp.last_match(1)
77
+ has_default = Regexp.last_match(2)
78
+ default_value = Regexp.last_match(3)
79
+
80
+ if ENV.key?(env_var)
81
+ ENV[env_var]
82
+ elsif has_default
83
+ default_value || ""
84
+ else
85
+ raise Error, "Environment variable '#{env_var}' is not set"
86
+ end
87
+ end
88
+ end
89
+
47
90
  def validate_version
48
91
  version = @config["version"]
49
92
  raise Error, "Missing 'version' field in configuration" unless version
@@ -72,8 +115,10 @@ module ClaudeSwarm
72
115
  @swarm["instances"].each do |name, config|
73
116
  @instances[name] = parse_instance(name, config)
74
117
  end
118
+ validate_main_instance_provider
75
119
  validate_connections
76
120
  detect_circular_dependencies
121
+ validate_openai_env_vars
77
122
  end
78
123
 
79
124
  def parse_instance(name, config)
@@ -82,6 +127,52 @@ module ClaudeSwarm
82
127
  # Validate required fields
83
128
  raise Error, "Instance '#{name}' missing required 'description' field" unless config["description"]
84
129
 
130
+ # Parse provider (optional, defaults to claude)
131
+ provider = config["provider"]
132
+ model = config["model"]
133
+
134
+ # Validate provider value if specified
135
+ if provider && !VALID_PROVIDERS.include?(provider)
136
+ raise Error, "Instance '#{name}' has invalid provider '#{provider}'. Must be 'claude' or 'openai'"
137
+ end
138
+
139
+ # Validate reasoning_effort for OpenAI provider
140
+ if config["reasoning_effort"]
141
+ # Ensure it's only used with OpenAI provider
142
+ if provider != "openai"
143
+ raise Error, "Instance '#{name}' has reasoning_effort but provider is not 'openai'"
144
+ end
145
+
146
+ # Validate the value
147
+ unless VALID_REASONING_EFFORTS.include?(config["reasoning_effort"])
148
+ raise Error, "Instance '#{name}' has invalid reasoning_effort '#{config["reasoning_effort"]}'. Must be 'low', 'medium', or 'high'"
149
+ end
150
+
151
+ # Validate it's only used with o-series models
152
+ # Support patterns like: o1, o1-mini, o1-pro, o1 Preview, o3-deep-research, o4-mini-deep-research, etc.
153
+ unless model&.match?(O_SERIES_MODEL_PATTERN)
154
+ raise Error, "Instance '#{name}' has reasoning_effort but model '#{model}' is not an o-series model (o1, o1 Preview, o1-mini, o1-pro, o3, o3-mini, o3-pro, o3-deep-research, o4-mini, o4-mini-deep-research, etc.)"
155
+ end
156
+ end
157
+
158
+ # Validate temperature is not used with o-series models when provider is openai
159
+ if provider == "openai" && config["temperature"] && model&.match?(O_SERIES_MODEL_PATTERN)
160
+ raise Error, "Instance '#{name}' has temperature parameter but model '#{model}' is an o-series model. O-series models use deterministic reasoning and don't accept temperature settings"
161
+ end
162
+
163
+ # Validate OpenAI-specific fields only when provider is not "openai"
164
+ if provider != "openai"
165
+ invalid_fields = OPENAI_SPECIFIC_FIELDS & config.keys
166
+ unless invalid_fields.empty?
167
+ raise Error, "Instance '#{name}' has OpenAI-specific fields #{invalid_fields.join(", ")} but provider is not 'openai'"
168
+ end
169
+ end
170
+
171
+ # Validate api_version if specified
172
+ if config["api_version"] && !VALID_API_VERSIONS.include?(config["api_version"])
173
+ raise Error, "Instance '#{name}' has invalid api_version '#{config["api_version"]}'. Must be 'chat_completion' or 'responses'"
174
+ end
175
+
85
176
  # Validate tool fields are arrays if present
86
177
  validate_tool_field(name, config, "tools")
87
178
  validate_tool_field(name, config, "allowed_tools")
@@ -93,7 +184,7 @@ module ClaudeSwarm
93
184
  # Parse directory field - support both string and array
94
185
  directories = parse_directories(config["directory"])
95
186
 
96
- {
187
+ instance_config = {
97
188
  name: name,
98
189
  directory: directories.first, # Keep single directory for backward compatibility
99
190
  directories: directories, # New field with all directories
@@ -105,9 +196,26 @@ module ClaudeSwarm
105
196
  mcps: parse_mcps(config["mcps"] || []),
106
197
  prompt: config["prompt"],
107
198
  description: config["description"],
108
- vibe: config["vibe"] || false,
109
- worktree: parse_worktree_value(config["worktree"])
199
+ vibe: config["vibe"],
200
+ worktree: parse_worktree_value(config["worktree"]),
201
+ provider: provider, # nil means Claude (default)
110
202
  }
203
+
204
+ # Add OpenAI-specific fields only when provider is "openai"
205
+ if provider == "openai"
206
+ instance_config[:temperature] = config["temperature"] if config["temperature"]
207
+ instance_config[:api_version] = config["api_version"] || "chat_completion"
208
+ instance_config[:openai_token_env] = config["openai_token_env"] || "OPENAI_API_KEY"
209
+ instance_config[:base_url] = config["base_url"]
210
+ instance_config[:reasoning_effort] = config["reasoning_effort"] if config["reasoning_effort"]
211
+ # Default vibe to true for OpenAI instances if not specified
212
+ instance_config[:vibe] = true if config["vibe"].nil?
213
+ elsif config["vibe"].nil?
214
+ # Default vibe to false for Claude instances if not specified
215
+ instance_config[:vibe] = false
216
+ end
217
+
218
+ instance_config
111
219
  end
112
220
 
113
221
  def parse_mcps(mcps)
@@ -195,11 +303,32 @@ module ClaudeSwarm
195
303
  end
196
304
 
197
305
  def parse_worktree_value(value)
198
- return nil if value.nil? # Omitted means follow CLI behavior
306
+ return if value.nil? # Omitted means follow CLI behavior
199
307
  return value if value.is_a?(TrueClass) || value.is_a?(FalseClass)
200
308
  return value.to_s if value.is_a?(String) && !value.empty?
201
309
 
202
310
  raise Error, "Invalid worktree value: #{value.inspect}. Must be true, false, or a non-empty string"
203
311
  end
312
+
313
+ def validate_openai_env_vars
314
+ @instances.each_value do |instance|
315
+ next unless instance[:provider] == "openai"
316
+
317
+ env_var = instance[:openai_token_env]
318
+ unless ENV.key?(env_var) && !ENV[env_var].to_s.strip.empty?
319
+ raise Error, "Environment variable '#{env_var}' is not set. OpenAI provider instances require an API key."
320
+ end
321
+ end
322
+ end
323
+
324
+ def validate_main_instance_provider
325
+ # Only validate in interactive mode (when no prompt is provided)
326
+ return if @options[:prompt]
327
+
328
+ main_config = @instances[@main_instance]
329
+ if main_config[:provider]
330
+ raise Error, "Main instance '#{@main_instance}' cannot have a provider setting in interactive mode"
331
+ end
332
+ end
204
333
  end
205
334
  end
@@ -1,10 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "json"
4
- require "fileutils"
5
- require "shellwords"
6
- require "securerandom"
7
-
8
3
  module ClaudeSwarm
9
4
  class McpGenerator
10
5
  def initialize(configuration, vibe: false, restore_session_path: nil)
@@ -62,15 +57,20 @@ module ClaudeSwarm
62
57
  instance[:connections].each do |connection_name|
63
58
  connected_instance = @config.instances[connection_name]
64
59
  mcp_servers[connection_name] = build_instance_mcp_config(
65
- connection_name, connected_instance,
66
- calling_instance: name, calling_instance_id: @instance_ids[name]
60
+ connection_name,
61
+ connected_instance,
62
+ calling_instance: name,
63
+ calling_instance_id: @instance_ids[name],
67
64
  )
68
65
  end
69
66
 
67
+ # Add Claude tools MCP server for OpenAI instances
68
+ mcp_servers["claude_tools"] = build_claude_tools_mcp_config if instance[:provider] == "openai"
69
+
70
70
  config = {
71
71
  "instance_id" => @instance_ids[name],
72
72
  "instance_name" => name,
73
- "mcpServers" => mcp_servers
73
+ "mcpServers" => mcp_servers,
74
74
  }
75
75
 
76
76
  File.write(mcp_config_path(name), JSON.pretty_generate(config))
@@ -82,18 +82,26 @@ module ClaudeSwarm
82
82
  {
83
83
  "type" => "stdio",
84
84
  "command" => mcp["command"],
85
- "args" => mcp["args"] || []
85
+ "args" => mcp["args"] || [],
86
86
  }.tap do |config|
87
87
  config["env"] = mcp["env"] if mcp["env"]
88
88
  end
89
89
  when "sse"
90
90
  {
91
91
  "type" => "sse",
92
- "url" => mcp["url"]
92
+ "url" => mcp["url"],
93
93
  }
94
94
  end
95
95
  end
96
96
 
97
+ def build_claude_tools_mcp_config
98
+ {
99
+ "type" => "stdio",
100
+ "command" => "claude",
101
+ "args" => ["mcp", "serve"],
102
+ }
103
+ end
104
+
97
105
  def build_instance_mcp_config(name, instance, calling_instance:, calling_instance_id:)
98
106
  # Get the path to the claude-swarm executable
99
107
  exe_path = "claude-swarm"
@@ -101,9 +109,12 @@ module ClaudeSwarm
101
109
  # Build command-line arguments for Thor
102
110
  args = [
103
111
  "mcp-serve",
104
- "--name", name,
105
- "--directory", instance[:directory],
106
- "--model", instance[:model]
112
+ "--name",
113
+ name,
114
+ "--directory",
115
+ instance[:directory],
116
+ "--model",
117
+ instance[:model],
107
118
  ]
108
119
 
109
120
  # Add directories array if we have multiple directories
@@ -130,6 +141,20 @@ module ClaudeSwarm
130
141
 
131
142
  args.push("--vibe") if @vibe || instance[:vibe]
132
143
 
144
+ # Add provider-specific parameters
145
+ if instance[:provider]
146
+ args.push("--provider", instance[:provider])
147
+
148
+ # Add OpenAI-specific parameters
149
+ if instance[:provider] == "openai"
150
+ args.push("--reasoning-effort", instance[:reasoning_effort]) if instance[:reasoning_effort]
151
+ args.push("--temperature", instance[:temperature].to_s) if instance[:temperature]
152
+ args.push("--api-version", instance[:api_version]) if instance[:api_version]
153
+ args.push("--openai-token-env", instance[:openai_token_env]) if instance[:openai_token_env]
154
+ args.push("--base-url", instance[:base_url]) if instance[:base_url]
155
+ end
156
+ end
157
+
133
158
  # Add claude session ID if restoring
134
159
  if @restore_states[name.to_s]
135
160
  claude_session_id = @restore_states[name.to_s]["claude_session_id"]
@@ -139,7 +164,7 @@ module ClaudeSwarm
139
164
  {
140
165
  "type" => "stdio",
141
166
  "command" => exe_path,
142
- "args" => args
167
+ "args" => args,
143
168
  }
144
169
  end
145
170