swarm_memory 2.0.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 (189) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/lib/claude_swarm/base_executor.rb +133 -0
  4. data/lib/claude_swarm/claude_code_executor.rb +349 -0
  5. data/lib/claude_swarm/claude_mcp_server.rb +77 -0
  6. data/lib/claude_swarm/cli.rb +712 -0
  7. data/lib/claude_swarm/commands/ps.rb +216 -0
  8. data/lib/claude_swarm/commands/show.rb +139 -0
  9. data/lib/claude_swarm/configuration.rb +363 -0
  10. data/lib/claude_swarm/hooks/session_start_hook.rb +42 -0
  11. data/lib/claude_swarm/json_handler.rb +91 -0
  12. data/lib/claude_swarm/mcp_generator.rb +248 -0
  13. data/lib/claude_swarm/openai/chat_completion.rb +264 -0
  14. data/lib/claude_swarm/openai/executor.rb +254 -0
  15. data/lib/claude_swarm/openai/responses.rb +338 -0
  16. data/lib/claude_swarm/orchestrator.rb +879 -0
  17. data/lib/claude_swarm/process_tracker.rb +78 -0
  18. data/lib/claude_swarm/session_cost_calculator.rb +209 -0
  19. data/lib/claude_swarm/session_path.rb +42 -0
  20. data/lib/claude_swarm/settings_generator.rb +77 -0
  21. data/lib/claude_swarm/system_utils.rb +46 -0
  22. data/lib/claude_swarm/templates/generation_prompt.md.erb +230 -0
  23. data/lib/claude_swarm/tools/reset_session_tool.rb +24 -0
  24. data/lib/claude_swarm/tools/session_info_tool.rb +24 -0
  25. data/lib/claude_swarm/tools/task_tool.rb +63 -0
  26. data/lib/claude_swarm/version.rb +5 -0
  27. data/lib/claude_swarm/worktree_manager.rb +475 -0
  28. data/lib/claude_swarm/yaml_loader.rb +22 -0
  29. data/lib/claude_swarm.rb +69 -0
  30. data/lib/swarm_cli/cli.rb +201 -0
  31. data/lib/swarm_cli/command_registry.rb +61 -0
  32. data/lib/swarm_cli/commands/mcp_serve.rb +130 -0
  33. data/lib/swarm_cli/commands/mcp_tools.rb +148 -0
  34. data/lib/swarm_cli/commands/migrate.rb +55 -0
  35. data/lib/swarm_cli/commands/run.rb +173 -0
  36. data/lib/swarm_cli/config_loader.rb +97 -0
  37. data/lib/swarm_cli/formatters/human_formatter.rb +711 -0
  38. data/lib/swarm_cli/formatters/json_formatter.rb +51 -0
  39. data/lib/swarm_cli/interactive_repl.rb +918 -0
  40. data/lib/swarm_cli/mcp_serve_options.rb +44 -0
  41. data/lib/swarm_cli/mcp_tools_options.rb +59 -0
  42. data/lib/swarm_cli/migrate_options.rb +54 -0
  43. data/lib/swarm_cli/migrator.rb +132 -0
  44. data/lib/swarm_cli/options.rb +151 -0
  45. data/lib/swarm_cli/ui/components/agent_badge.rb +33 -0
  46. data/lib/swarm_cli/ui/components/content_block.rb +120 -0
  47. data/lib/swarm_cli/ui/components/divider.rb +57 -0
  48. data/lib/swarm_cli/ui/components/panel.rb +62 -0
  49. data/lib/swarm_cli/ui/components/usage_stats.rb +70 -0
  50. data/lib/swarm_cli/ui/formatters/cost.rb +49 -0
  51. data/lib/swarm_cli/ui/formatters/number.rb +58 -0
  52. data/lib/swarm_cli/ui/formatters/text.rb +77 -0
  53. data/lib/swarm_cli/ui/formatters/time.rb +73 -0
  54. data/lib/swarm_cli/ui/icons.rb +59 -0
  55. data/lib/swarm_cli/ui/renderers/event_renderer.rb +188 -0
  56. data/lib/swarm_cli/ui/state/agent_color_cache.rb +45 -0
  57. data/lib/swarm_cli/ui/state/depth_tracker.rb +40 -0
  58. data/lib/swarm_cli/ui/state/spinner_manager.rb +170 -0
  59. data/lib/swarm_cli/ui/state/usage_tracker.rb +62 -0
  60. data/lib/swarm_cli/version.rb +5 -0
  61. data/lib/swarm_cli.rb +45 -0
  62. data/lib/swarm_memory/adapters/base.rb +140 -0
  63. data/lib/swarm_memory/adapters/filesystem_adapter.rb +789 -0
  64. data/lib/swarm_memory/chat_extension.rb +34 -0
  65. data/lib/swarm_memory/cli/commands.rb +306 -0
  66. data/lib/swarm_memory/core/entry.rb +37 -0
  67. data/lib/swarm_memory/core/frontmatter_parser.rb +108 -0
  68. data/lib/swarm_memory/core/metadata_extractor.rb +68 -0
  69. data/lib/swarm_memory/core/path_normalizer.rb +75 -0
  70. data/lib/swarm_memory/core/semantic_index.rb +244 -0
  71. data/lib/swarm_memory/core/storage.rb +286 -0
  72. data/lib/swarm_memory/core/storage_read_tracker.rb +63 -0
  73. data/lib/swarm_memory/dsl/builder_extension.rb +40 -0
  74. data/lib/swarm_memory/dsl/memory_config.rb +113 -0
  75. data/lib/swarm_memory/embeddings/embedder.rb +36 -0
  76. data/lib/swarm_memory/embeddings/informers_embedder.rb +152 -0
  77. data/lib/swarm_memory/errors.rb +21 -0
  78. data/lib/swarm_memory/integration/cli_registration.rb +30 -0
  79. data/lib/swarm_memory/integration/configuration.rb +43 -0
  80. data/lib/swarm_memory/integration/registration.rb +31 -0
  81. data/lib/swarm_memory/integration/sdk_plugin.rb +531 -0
  82. data/lib/swarm_memory/optimization/analyzer.rb +244 -0
  83. data/lib/swarm_memory/optimization/defragmenter.rb +863 -0
  84. data/lib/swarm_memory/prompts/memory.md.erb +109 -0
  85. data/lib/swarm_memory/prompts/memory_assistant.md.erb +139 -0
  86. data/lib/swarm_memory/prompts/memory_researcher.md.erb +201 -0
  87. data/lib/swarm_memory/prompts/memory_retrieval.md.erb +76 -0
  88. data/lib/swarm_memory/search/semantic_search.rb +112 -0
  89. data/lib/swarm_memory/search/text_search.rb +40 -0
  90. data/lib/swarm_memory/search/text_similarity.rb +80 -0
  91. data/lib/swarm_memory/skills/meta/deep-learning.md +101 -0
  92. data/lib/swarm_memory/skills/meta/deep-learning.yml +14 -0
  93. data/lib/swarm_memory/tools/load_skill.rb +313 -0
  94. data/lib/swarm_memory/tools/memory_defrag.rb +382 -0
  95. data/lib/swarm_memory/tools/memory_delete.rb +99 -0
  96. data/lib/swarm_memory/tools/memory_edit.rb +185 -0
  97. data/lib/swarm_memory/tools/memory_glob.rb +145 -0
  98. data/lib/swarm_memory/tools/memory_grep.rb +209 -0
  99. data/lib/swarm_memory/tools/memory_multi_edit.rb +281 -0
  100. data/lib/swarm_memory/tools/memory_read.rb +123 -0
  101. data/lib/swarm_memory/tools/memory_write.rb +215 -0
  102. data/lib/swarm_memory/utils.rb +50 -0
  103. data/lib/swarm_memory/version.rb +5 -0
  104. data/lib/swarm_memory.rb +166 -0
  105. data/lib/swarm_sdk/agent/RETRY_LOGIC.md +127 -0
  106. data/lib/swarm_sdk/agent/builder.rb +461 -0
  107. data/lib/swarm_sdk/agent/chat/context_tracker.rb +314 -0
  108. data/lib/swarm_sdk/agent/chat/hook_integration.rb +372 -0
  109. data/lib/swarm_sdk/agent/chat/logging_helpers.rb +116 -0
  110. data/lib/swarm_sdk/agent/chat/system_reminder_injector.rb +152 -0
  111. data/lib/swarm_sdk/agent/chat.rb +1144 -0
  112. data/lib/swarm_sdk/agent/context.rb +112 -0
  113. data/lib/swarm_sdk/agent/context_manager.rb +309 -0
  114. data/lib/swarm_sdk/agent/definition.rb +556 -0
  115. data/lib/swarm_sdk/claude_code_agent_adapter.rb +205 -0
  116. data/lib/swarm_sdk/configuration.rb +296 -0
  117. data/lib/swarm_sdk/context_compactor/metrics.rb +147 -0
  118. data/lib/swarm_sdk/context_compactor/token_counter.rb +106 -0
  119. data/lib/swarm_sdk/context_compactor.rb +340 -0
  120. data/lib/swarm_sdk/hooks/adapter.rb +359 -0
  121. data/lib/swarm_sdk/hooks/context.rb +197 -0
  122. data/lib/swarm_sdk/hooks/definition.rb +80 -0
  123. data/lib/swarm_sdk/hooks/error.rb +29 -0
  124. data/lib/swarm_sdk/hooks/executor.rb +146 -0
  125. data/lib/swarm_sdk/hooks/registry.rb +147 -0
  126. data/lib/swarm_sdk/hooks/result.rb +150 -0
  127. data/lib/swarm_sdk/hooks/shell_executor.rb +254 -0
  128. data/lib/swarm_sdk/hooks/tool_call.rb +35 -0
  129. data/lib/swarm_sdk/hooks/tool_result.rb +62 -0
  130. data/lib/swarm_sdk/log_collector.rb +51 -0
  131. data/lib/swarm_sdk/log_stream.rb +69 -0
  132. data/lib/swarm_sdk/markdown_parser.rb +75 -0
  133. data/lib/swarm_sdk/model_aliases.json +5 -0
  134. data/lib/swarm_sdk/models.json +1 -0
  135. data/lib/swarm_sdk/models.rb +120 -0
  136. data/lib/swarm_sdk/node/agent_config.rb +49 -0
  137. data/lib/swarm_sdk/node/builder.rb +439 -0
  138. data/lib/swarm_sdk/node/transformer_executor.rb +248 -0
  139. data/lib/swarm_sdk/node_context.rb +170 -0
  140. data/lib/swarm_sdk/node_orchestrator.rb +384 -0
  141. data/lib/swarm_sdk/permissions/config.rb +239 -0
  142. data/lib/swarm_sdk/permissions/error_formatter.rb +121 -0
  143. data/lib/swarm_sdk/permissions/path_matcher.rb +35 -0
  144. data/lib/swarm_sdk/permissions/validator.rb +173 -0
  145. data/lib/swarm_sdk/permissions_builder.rb +122 -0
  146. data/lib/swarm_sdk/plugin.rb +147 -0
  147. data/lib/swarm_sdk/plugin_registry.rb +101 -0
  148. data/lib/swarm_sdk/prompts/base_system_prompt.md.erb +243 -0
  149. data/lib/swarm_sdk/providers/openai_with_responses.rb +582 -0
  150. data/lib/swarm_sdk/result.rb +97 -0
  151. data/lib/swarm_sdk/swarm/agent_initializer.rb +334 -0
  152. data/lib/swarm_sdk/swarm/all_agents_builder.rb +140 -0
  153. data/lib/swarm_sdk/swarm/builder.rb +586 -0
  154. data/lib/swarm_sdk/swarm/mcp_configurator.rb +151 -0
  155. data/lib/swarm_sdk/swarm/tool_configurator.rb +416 -0
  156. data/lib/swarm_sdk/swarm.rb +982 -0
  157. data/lib/swarm_sdk/tools/bash.rb +274 -0
  158. data/lib/swarm_sdk/tools/clock.rb +44 -0
  159. data/lib/swarm_sdk/tools/delegate.rb +164 -0
  160. data/lib/swarm_sdk/tools/document_converters/base_converter.rb +83 -0
  161. data/lib/swarm_sdk/tools/document_converters/docx_converter.rb +99 -0
  162. data/lib/swarm_sdk/tools/document_converters/html_converter.rb +101 -0
  163. data/lib/swarm_sdk/tools/document_converters/pdf_converter.rb +78 -0
  164. data/lib/swarm_sdk/tools/document_converters/xlsx_converter.rb +194 -0
  165. data/lib/swarm_sdk/tools/edit.rb +150 -0
  166. data/lib/swarm_sdk/tools/glob.rb +158 -0
  167. data/lib/swarm_sdk/tools/grep.rb +228 -0
  168. data/lib/swarm_sdk/tools/image_extractors/docx_image_extractor.rb +43 -0
  169. data/lib/swarm_sdk/tools/image_extractors/pdf_image_extractor.rb +163 -0
  170. data/lib/swarm_sdk/tools/image_formats/tiff_builder.rb +65 -0
  171. data/lib/swarm_sdk/tools/multi_edit.rb +232 -0
  172. data/lib/swarm_sdk/tools/path_resolver.rb +43 -0
  173. data/lib/swarm_sdk/tools/read.rb +251 -0
  174. data/lib/swarm_sdk/tools/registry.rb +93 -0
  175. data/lib/swarm_sdk/tools/scratchpad/scratchpad_list.rb +96 -0
  176. data/lib/swarm_sdk/tools/scratchpad/scratchpad_read.rb +76 -0
  177. data/lib/swarm_sdk/tools/scratchpad/scratchpad_write.rb +91 -0
  178. data/lib/swarm_sdk/tools/stores/read_tracker.rb +61 -0
  179. data/lib/swarm_sdk/tools/stores/scratchpad_storage.rb +224 -0
  180. data/lib/swarm_sdk/tools/stores/storage.rb +148 -0
  181. data/lib/swarm_sdk/tools/stores/todo_manager.rb +65 -0
  182. data/lib/swarm_sdk/tools/think.rb +95 -0
  183. data/lib/swarm_sdk/tools/todo_write.rb +216 -0
  184. data/lib/swarm_sdk/tools/web_fetch.rb +261 -0
  185. data/lib/swarm_sdk/tools/write.rb +117 -0
  186. data/lib/swarm_sdk/utils.rb +50 -0
  187. data/lib/swarm_sdk/version.rb +5 -0
  188. data/lib/swarm_sdk.rb +167 -0
  189. metadata +313 -0
@@ -0,0 +1,879 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ClaudeSwarm
4
+ class Orchestrator
5
+ include SystemUtils
6
+
7
+ attr_reader :config, :session_path, :session_log_path
8
+
9
+ def initialize(configuration, mcp_generator, vibe: false, prompt: nil, interactive_prompt: nil, stream_logs: false, debug: false,
10
+ restore_session_path: nil, worktree: nil, session_id: nil)
11
+ @config = configuration
12
+ @generator = mcp_generator
13
+ @vibe = vibe
14
+ @non_interactive_prompt = prompt
15
+ @interactive_prompt = interactive_prompt
16
+ @stream_logs = stream_logs
17
+ @debug = debug
18
+ @restore_session_path = restore_session_path
19
+ @provided_session_id = session_id
20
+ # Store worktree option for later use
21
+ @worktree_option = worktree
22
+ @needs_worktree_manager = worktree.is_a?(String) || worktree == "" ||
23
+ configuration.instances.values.any? { |inst| !inst[:worktree].nil? }
24
+ # Store modified instances after worktree setup
25
+ @modified_instances = nil
26
+ # Track start time for runtime calculation
27
+ @start_time = nil
28
+ # Track transcript tailing thread
29
+ @transcript_thread = nil
30
+
31
+ # Set environment variable for prompt mode to suppress output
32
+ ENV["CLAUDE_SWARM_PROMPT"] = "1" if @non_interactive_prompt
33
+
34
+ # Initialize session path
35
+ if @restore_session_path
36
+ # Use existing session path for restoration
37
+ @session_path = @restore_session_path
38
+ @session_log_path = File.join(@session_path, "session.log")
39
+ else
40
+ # Generate new session path
41
+ session_params = { working_dir: ClaudeSwarm.root_dir }
42
+ session_params[:session_id] = @provided_session_id if @provided_session_id
43
+ @session_path = SessionPath.generate(**session_params)
44
+ SessionPath.ensure_directory(@session_path)
45
+ @session_log_path = File.join(@session_path, "session.log")
46
+
47
+ # Extract session ID from path (the timestamp part)
48
+ @session_id = File.basename(@session_path)
49
+
50
+ end
51
+ ENV["CLAUDE_SWARM_SESSION_PATH"] = @session_path
52
+ ENV["CLAUDE_SWARM_ROOT_DIR"] = ClaudeSwarm.root_dir
53
+
54
+ # Initialize components that depend on session path
55
+ @process_tracker = ProcessTracker.new(@session_path)
56
+ @settings_generator = SettingsGenerator.new(@config)
57
+
58
+ # Initialize WorktreeManager if needed
59
+ if @needs_worktree_manager && !@restore_session_path
60
+ cli_option = @worktree_option.is_a?(String) && !@worktree_option.empty? ? @worktree_option : nil
61
+ @worktree_manager = WorktreeManager.new(cli_option, session_id: @session_id)
62
+ end
63
+ end
64
+
65
+ def start
66
+ # Track start time
67
+ @start_time = Time.now
68
+
69
+ # Setup signal handlers for graceful shutdown
70
+ setup_signal_handlers do
71
+ @signal_received = true
72
+ cleanup_all
73
+ end
74
+
75
+ begin
76
+ start_internal
77
+ rescue StandardError => e
78
+ # Ensure cleanup happens even on unexpected errors
79
+ cleanup_all
80
+ raise e
81
+ end
82
+ end
83
+
84
+ private
85
+
86
+ def cleanup_all
87
+ execute_after_commands_once
88
+ cleanup_processes
89
+ cleanup_run_symlink
90
+ cleanup_worktrees
91
+ end
92
+
93
+ def setup_signal_handlers(&cleanup_block)
94
+ ["INT", "TERM", "QUIT", "HUP"].each do |signal|
95
+ Signal.trap(signal) do
96
+ puts "\n🛑 Received #{signal} signal. Shutting down gracefully..."
97
+ cleanup_block&.call
98
+ exit(0)
99
+ end
100
+ end
101
+ end
102
+
103
+ def start_internal
104
+ if @restore_session_path
105
+ non_interactive_output do
106
+ puts "🔄 Restoring Claude Swarm: #{@config.swarm_name}"
107
+ puts "😎 Vibe mode ON" if @vibe
108
+ end
109
+
110
+ # Create run symlink for restored session
111
+ create_run_symlink
112
+
113
+ non_interactive_output do
114
+ puts "📝 Using existing session: #{@session_path}/"
115
+ end
116
+
117
+ # Check if the original session used worktrees
118
+ restore_worktrees_if_needed(@session_path)
119
+
120
+ # Regenerate MCP configurations with session IDs for restoration
121
+ @generator.generate_all
122
+ non_interactive_output do
123
+ puts "✓ Regenerated MCP configurations with session IDs"
124
+ end
125
+
126
+ # Generate settings files
127
+ @settings_generator.generate_all
128
+ non_interactive_output do
129
+ puts "✓ Generated settings files with hooks"
130
+ end
131
+ else
132
+ non_interactive_output do
133
+ puts "🐝 Starting Claude Swarm: #{@config.swarm_name}"
134
+ puts "😎 Vibe mode ON" if @vibe
135
+ end
136
+
137
+ # Create run symlink for new session
138
+ create_run_symlink
139
+
140
+ non_interactive_output do
141
+ puts "📝 Session files will be saved to: #{@session_path}/"
142
+ end
143
+
144
+ # Setup worktrees if needed
145
+ if @worktree_manager
146
+ begin
147
+ non_interactive_output { print("🌳 Setting up Git worktrees...") }
148
+
149
+ # Get all instances for worktree setup
150
+ # Note: instances.values already includes the main instance
151
+ all_instances = @config.instances.values
152
+
153
+ @worktree_manager.setup_worktrees(all_instances)
154
+
155
+ non_interactive_output do
156
+ puts "✓ Worktrees created with branch: #{@worktree_manager.worktree_name}"
157
+ end
158
+ rescue StandardError => e
159
+ non_interactive_output { print("❌ Failed to setup worktrees: #{e.message}") }
160
+ cleanup_all
161
+ raise
162
+ end
163
+ end
164
+
165
+ # Generate all MCP configuration files
166
+ @generator.generate_all
167
+ non_interactive_output do
168
+ puts "✓ Generated MCP configurations in session directory"
169
+ end
170
+
171
+ # Generate settings files
172
+ @settings_generator.generate_all
173
+ non_interactive_output do
174
+ puts "✓ Generated settings files with hooks"
175
+ end
176
+
177
+ # Save swarm config path for restoration
178
+ save_swarm_config_path(@session_path)
179
+ end
180
+
181
+ # Launch the main instance (fetch after worktree setup to get modified paths)
182
+ main_instance = @config.main_instance_config
183
+ non_interactive_output do
184
+ puts "🚀 Launching main instance: #{@config.main_instance}"
185
+ puts " Model: #{main_instance[:model]}"
186
+ if main_instance[:directories].size == 1
187
+ puts " Directory: #{main_instance[:directory]}"
188
+ else
189
+ puts " Directories:"
190
+ main_instance[:directories].each { |dir| puts " - #{dir}" }
191
+ end
192
+ puts " Allowed tools: #{main_instance[:allowed_tools].join(", ")}" if main_instance[:allowed_tools].any?
193
+ puts " Disallowed tools: #{main_instance[:disallowed_tools].join(", ")}" if main_instance[:disallowed_tools]&.any?
194
+ puts " Connections: #{main_instance[:connections].join(", ")}" if main_instance[:connections].any?
195
+ puts " 😎 Vibe mode ON for this instance" if main_instance[:vibe]
196
+ end
197
+
198
+ command = build_main_command(main_instance)
199
+ if @debug
200
+ non_interactive_output do
201
+ puts "🏃 Running: #{format_command_for_display(command)}"
202
+ end
203
+ end
204
+
205
+ # Start log streaming thread if in non-interactive mode with --stream-logs
206
+ log_thread = nil
207
+ log_thread = start_log_streaming if @non_interactive_prompt && @stream_logs
208
+
209
+ # Start transcript tailing thread for main instance
210
+ @transcript_thread = start_transcript_tailing
211
+
212
+ # Write the current process PID (orchestrator) to a file for easy access
213
+ main_pid_file = File.join(@session_path, "main_pid")
214
+ File.write(main_pid_file, Process.pid.to_s)
215
+
216
+ # Execute before commands if specified
217
+ # If the main instance directory exists, run in it for backward compatibility
218
+ # If it doesn't exist, run in the parent directory so before commands can create it
219
+ before_commands = @config.before_commands
220
+ if before_commands.any? && !@restore_session_path
221
+ non_interactive_output do
222
+ puts "⚙️ Executing before commands..."
223
+ end
224
+
225
+ # Determine where to run before commands
226
+ if File.exist?(main_instance[:directory])
227
+ # Directory exists, run commands in it (backward compatibility)
228
+ before_commands_dir = main_instance[:directory]
229
+ else
230
+ # Directory doesn't exist, run in parent directory
231
+ # This allows before commands to create the directory
232
+ parent_dir = File.dirname(File.expand_path(main_instance[:directory]))
233
+ # Ensure parent directory exists (important for worktrees)
234
+ FileUtils.mkdir_p(parent_dir)
235
+ before_commands_dir = parent_dir
236
+ end
237
+
238
+ Dir.chdir(before_commands_dir) do
239
+ success = execute_before_commands?(before_commands)
240
+ unless success
241
+ non_interactive_output { print("❌ Before commands failed. Aborting swarm launch.") }
242
+ cleanup_all
243
+ exit(1)
244
+ end
245
+ end
246
+
247
+ non_interactive_output do
248
+ puts "✓ Before commands completed successfully"
249
+ end
250
+
251
+ # Validate directories after before commands have run
252
+ begin
253
+ @config.validate_directories
254
+ non_interactive_output do
255
+ puts "✓ All directories validated successfully"
256
+ end
257
+ rescue ClaudeSwarm::Error => e
258
+ non_interactive_output { print("❌ Directory validation failed: #{e.message}") }
259
+ cleanup_all
260
+ exit(1)
261
+ end
262
+ end
263
+
264
+ # Execute the main instance - this will cascade to other instances via MCP
265
+ Dir.chdir(main_instance[:directory]) do
266
+ # Execute main Claude instance with unbundled environment to avoid bundler conflicts
267
+ # This ensures the main instance runs in a clean environment without inheriting
268
+ # Claude Swarm's BUNDLE_* environment variables
269
+ Bundler.with_unbundled_env do
270
+ if @non_interactive_prompt
271
+ stream_to_session_log(*command)
272
+ else
273
+ system_with_pid!(*command) do |pid|
274
+ @process_tracker.track_pid(pid, "claude_#{@config.main_instance}")
275
+ non_interactive_output do
276
+ puts "✓ Claude instance started with PID: #{pid}"
277
+ end
278
+ end
279
+ end
280
+ end
281
+ end
282
+
283
+ # Clean up log streaming thread
284
+ if log_thread
285
+ log_thread.terminate
286
+ log_thread.join
287
+ end
288
+
289
+ # Clean up transcript tailing thread
290
+ cleanup_transcript_thread
291
+
292
+ # Display runtime and cost summary
293
+ display_summary
294
+
295
+ # Execute after commands if specified
296
+ execute_after_commands_once
297
+
298
+ # Clean up child processes and run symlink
299
+ cleanup_all
300
+ end
301
+
302
+ def non_interactive_output
303
+ return if @non_interactive_prompt
304
+
305
+ yield
306
+ puts
307
+ end
308
+
309
+ def execute_before_commands?(commands)
310
+ execute_commands(commands, phase: "before", fail_fast: true)
311
+ end
312
+
313
+ def execute_after_commands?(commands)
314
+ execute_commands(commands, phase: "after", fail_fast: false)
315
+ end
316
+
317
+ def execute_after_commands_once
318
+ # Ensure after commands are only executed once
319
+ return if @after_commands_executed
320
+
321
+ @after_commands_executed = true
322
+
323
+ # Use the same logic as before commands for consistency
324
+ after_commands = @config.after_commands
325
+ return if after_commands.empty? || @restore_session_path
326
+
327
+ main_instance = @config.main_instance_config
328
+
329
+ # Determine where to run after commands (same logic as before commands)
330
+ if File.exist?(main_instance[:directory])
331
+ # Directory exists, run commands in it
332
+ after_commands_dir = main_instance[:directory]
333
+ else
334
+ # Directory doesn't exist (shouldn't happen after main instance runs, but be safe)
335
+ parent_dir = File.dirname(File.expand_path(main_instance[:directory]))
336
+ after_commands_dir = parent_dir
337
+ end
338
+
339
+ Dir.chdir(after_commands_dir) do
340
+ non_interactive_output do
341
+ print("⚙️ Executing after commands...")
342
+ end
343
+
344
+ success = execute_after_commands?(after_commands)
345
+ unless success
346
+ non_interactive_output do
347
+ puts "⚠️ Some after commands failed"
348
+ end
349
+ end
350
+ end
351
+ end
352
+
353
+ def save_swarm_config_path(session_path)
354
+ # Copy the YAML config file to the session directory
355
+ config_copy_path = File.join(session_path, "config.yml")
356
+ FileUtils.cp(@config.config_path, config_copy_path)
357
+
358
+ # Save the root directory
359
+ root_dir_file = File.join(session_path, "root_directory")
360
+ File.write(root_dir_file, ClaudeSwarm.root_dir)
361
+
362
+ # Save session metadata
363
+ metadata_file = File.join(session_path, "session_metadata.json")
364
+ JsonHandler.write_file!(metadata_file, build_session_metadata)
365
+ end
366
+
367
+ def build_session_metadata
368
+ {
369
+ "root_directory" => ClaudeSwarm.root_dir,
370
+ "timestamp" => Time.now.utc.iso8601,
371
+ "start_time" => @start_time.utc.iso8601,
372
+ "swarm_name" => @config.swarm_name,
373
+ "claude_swarm_version" => VERSION,
374
+ }.tap do |metadata|
375
+ # Add worktree info if applicable
376
+ metadata["worktree"] = @worktree_manager.session_metadata if @worktree_manager
377
+ end
378
+ end
379
+
380
+ def cleanup_processes
381
+ @process_tracker.cleanup_all
382
+ cleanup_transcript_thread
383
+ puts "✓ Cleanup complete"
384
+ rescue StandardError => e
385
+ puts "⚠️ Error during cleanup: #{e.message}"
386
+ end
387
+
388
+ def cleanup_worktrees
389
+ @worktree_manager&.cleanup_worktrees
390
+ rescue StandardError => e
391
+ puts "⚠️ Error during worktree cleanup: #{e.message}"
392
+ end
393
+
394
+ def display_summary
395
+ return unless @session_path && @start_time
396
+
397
+ end_time = Time.now
398
+ runtime_seconds = (end_time - @start_time).to_i
399
+
400
+ # Update session metadata with end time
401
+ update_session_end_time(end_time)
402
+
403
+ # Calculate total cost from session logs
404
+ total_cost = calculate_total_cost
405
+
406
+ puts
407
+ puts "=" * 50
408
+ puts "🏁 Claude Swarm Summary"
409
+ puts "=" * 50
410
+ puts "Runtime: #{format_duration(runtime_seconds)}"
411
+ puts "Total Cost: #{format_cost(total_cost)}"
412
+ puts "Session: #{File.basename(@session_path)}"
413
+ puts "=" * 50
414
+ end
415
+
416
+ def update_session_end_time(end_time)
417
+ metadata_file = File.join(@session_path, "session_metadata.json")
418
+ return unless File.exist?(metadata_file)
419
+
420
+ metadata = JsonHandler.parse_file!(metadata_file)
421
+ metadata["end_time"] = end_time.utc.iso8601
422
+ metadata["duration_seconds"] = (end_time - @start_time).to_i
423
+
424
+ JsonHandler.write_file!(metadata_file, metadata)
425
+ rescue StandardError => e
426
+ non_interactive_output { print("⚠️ Error updating session metadata: #{e.message}") }
427
+ end
428
+
429
+ def calculate_total_cost
430
+ log_file = File.join(@session_path, "session.log.json")
431
+ result = SessionCostCalculator.calculate_total_cost(log_file)
432
+
433
+ # Check if main instance has cost data
434
+ main_instance_name = @config.main_instance
435
+ @main_has_cost = result[:instances_with_cost].include?(main_instance_name)
436
+
437
+ result[:total_cost]
438
+ end
439
+
440
+ def format_duration(seconds)
441
+ hours = seconds / 3600
442
+ minutes = (seconds % 3600) / 60
443
+ secs = seconds % 60
444
+
445
+ parts = []
446
+ parts << "#{hours}h" if hours.positive?
447
+ parts << "#{minutes}m" if minutes.positive?
448
+ parts << "#{secs}s"
449
+
450
+ parts.join(" ")
451
+ end
452
+
453
+ def format_cost(cost)
454
+ cost_str = format("$%.4f", cost)
455
+ cost_str += " (excluding main instance)" unless @main_has_cost
456
+ cost_str
457
+ end
458
+
459
+ def create_run_symlink
460
+ return unless @session_path
461
+
462
+ run_dir = ClaudeSwarm.joined_run_dir
463
+ FileUtils.mkdir_p(run_dir)
464
+
465
+ # Session ID is the last part of the session path
466
+ session_id = File.basename(@session_path)
467
+ symlink_path = ClaudeSwarm.joined_run_dir(session_id)
468
+
469
+ # Remove stale symlink if exists
470
+ File.unlink(symlink_path) if File.symlink?(symlink_path)
471
+
472
+ # Create new symlink
473
+ File.symlink(@session_path, symlink_path)
474
+ rescue StandardError => e
475
+ # Don't fail the process if symlink creation fails
476
+ non_interactive_output { print("⚠️ Warning: Could not create run symlink: #{e.message}") }
477
+ end
478
+
479
+ def cleanup_run_symlink
480
+ return unless @session_path
481
+
482
+ session_id = File.basename(@session_path)
483
+ symlink_path = ClaudeSwarm.joined_run_dir(session_id)
484
+ File.unlink(symlink_path) if File.symlink?(symlink_path)
485
+ rescue StandardError
486
+ # Ignore errors during cleanup
487
+ end
488
+
489
+ def start_log_streaming
490
+ Thread.new do
491
+ # Wait for log file to be created
492
+ sleep(0.1) until File.exist?(@session_log_path)
493
+
494
+ # Open file and seek to end
495
+ File.open(@session_log_path, "r") do |file|
496
+ loop do
497
+ changes = file.read
498
+ if changes
499
+ print(changes)
500
+ $stdout.flush
501
+ else
502
+ sleep(0.1)
503
+ end
504
+ end
505
+ end
506
+ rescue StandardError
507
+ # Silently handle errors (file might be deleted, process might end, etc.)
508
+ end
509
+ end
510
+
511
+ def format_command_for_display(command)
512
+ command.map do |part|
513
+ if part.match?(/\s|'|"/)
514
+ "'#{part.gsub("'", "'\\\\''")}'"
515
+ else
516
+ part
517
+ end
518
+ end.join(" ")
519
+ end
520
+
521
+ def build_main_command(instance)
522
+ parts = ["claude"]
523
+
524
+ # Only add --model if ANTHROPIC_MODEL env var is not set
525
+ unless ENV["ANTHROPIC_MODEL"]
526
+ parts << "--model"
527
+ parts << instance[:model]
528
+ end
529
+
530
+ # Add resume flag if restoring session
531
+ if @restore_session_path
532
+ # Look for main instance state file
533
+ main_instance_name = @config.main_instance
534
+ state_files = Dir.glob(File.join(@restore_session_path, "state", "*.json"))
535
+
536
+ # Find the state file for the main instance
537
+ state_files.each do |state_file|
538
+ state_data = JsonHandler.parse_file!(state_file)
539
+ next unless state_data["instance_name"] == main_instance_name
540
+
541
+ claude_session_id = state_data["claude_session_id"]
542
+ if claude_session_id
543
+ parts << "--resume"
544
+ parts << claude_session_id
545
+ end
546
+ break
547
+ end
548
+ end
549
+
550
+ if @vibe || instance[:vibe]
551
+ parts << "--dangerously-skip-permissions"
552
+ else
553
+ # Build allowed tools list including MCP connections
554
+ allowed_tools = instance[:allowed_tools].dup
555
+
556
+ # Add mcp__instance_name for each connection
557
+ instance[:connections].each do |connection_name|
558
+ allowed_tools << "mcp__#{connection_name}"
559
+ end
560
+
561
+ # Add allowed tools if any
562
+ if allowed_tools.any?
563
+ tools_str = allowed_tools.join(",")
564
+ parts << "--allowedTools"
565
+ parts << tools_str
566
+ end
567
+
568
+ # Add disallowed tools if any
569
+ if instance[:disallowed_tools]&.any?
570
+ disallowed_tools_str = instance[:disallowed_tools].join(",")
571
+ parts << "--disallowedTools"
572
+ parts << disallowed_tools_str
573
+ end
574
+ end
575
+
576
+ # Always add instance prompt if it exists
577
+ if instance[:prompt]
578
+ parts << "--append-system-prompt"
579
+ parts << instance[:prompt]
580
+ end
581
+
582
+ parts << "--debug" if @debug
583
+
584
+ # Add additional directories with --add-dir
585
+ if instance[:directories].size > 1
586
+ instance[:directories][1..].each do |additional_dir|
587
+ parts << "--add-dir"
588
+ parts << additional_dir
589
+ end
590
+ end
591
+
592
+ mcp_config_path = @generator.mcp_config_path(@config.main_instance)
593
+ parts << "--mcp-config"
594
+ parts << mcp_config_path
595
+
596
+ # Add settings file if it exists for the main instance
597
+ settings_file = @settings_generator.settings_path(@config.main_instance)
598
+ if File.exist?(settings_file)
599
+ parts << "--settings"
600
+ parts << settings_file
601
+ end
602
+
603
+ # Handle different modes
604
+ if @non_interactive_prompt
605
+ # Non-interactive mode with -p
606
+ parts << "-p"
607
+ parts << @non_interactive_prompt
608
+ parts << "--verbose"
609
+ parts << "--output-format=stream-json"
610
+ elsif @interactive_prompt
611
+ # Interactive mode with initial prompt (no -p flag)
612
+ parts << @interactive_prompt
613
+ end
614
+ # else: Interactive mode without initial prompt - nothing to add
615
+
616
+ parts
617
+ end
618
+
619
+ def restore_worktrees_if_needed(session_path)
620
+ metadata_file = File.join(session_path, "session_metadata.json")
621
+ return unless File.exist?(metadata_file)
622
+
623
+ metadata = JsonHandler.parse_file!(metadata_file)
624
+ worktree_data = metadata["worktree"]
625
+ return unless worktree_data && worktree_data["enabled"]
626
+
627
+ non_interactive_output do
628
+ puts "🌳 Restoring Git worktrees..."
629
+ end
630
+
631
+ # Restore worktrees using the saved configuration
632
+ # Extract session ID from the session path
633
+ session_id = File.basename(session_path)
634
+ @worktree_manager = WorktreeManager.new(worktree_data["shared_name"], session_id: session_id)
635
+
636
+ # Get all instances and restore their worktree paths
637
+ all_instances = @config.instances.values
638
+ @worktree_manager.setup_worktrees(all_instances)
639
+
640
+ non_interactive_output do
641
+ puts "✓ Worktrees restored with branch: #{@worktree_manager.worktree_name}"
642
+ end
643
+ end
644
+
645
+ def stream_to_session_log(*command)
646
+ # Setup logger for session logging
647
+ logger = Logger.new(@session_log_path, level: :info, progname: @config.main_instance)
648
+
649
+ # Use Open3.popen2e to capture stdout and stderr merged for formatting
650
+ Open3.popen2e(*command) do |stdin, stdout_and_stderr, wait_thr|
651
+ stdin.close
652
+
653
+ # Read and process the merged output
654
+ stdout_and_stderr.each_line do |line|
655
+ logger.info do
656
+ chomped_line = line.chomp
657
+ json_data = JsonHandler.parse(chomped_line)
658
+ json_data == chomped_line ? chomped_line : JsonHandler.pretty_generate!(json_data)
659
+ end
660
+ end
661
+
662
+ wait_thr.value
663
+ end
664
+ end
665
+
666
+ def start_transcript_tailing
667
+ Thread.new do
668
+ path_file = File.join(@session_path, "main_instance_transcript.path")
669
+
670
+ # Wait for path file to exist (created by SessionStart hook)
671
+ sleep(0.5) until File.exist?(path_file)
672
+
673
+ # Read the transcript path
674
+ transcript_path = File.read(path_file).strip
675
+
676
+ # Wait for transcript file to exist
677
+ sleep(0.5) until File.exist?(transcript_path)
678
+
679
+ # Tail the transcript file continuously (like tail -f)
680
+ File.open(transcript_path, "r") do |file|
681
+ # Start from the beginning to capture all entries
682
+ file.seek(0, IO::SEEK_SET) # Start at beginning of file
683
+
684
+ loop do
685
+ line = file.gets
686
+ if line
687
+ begin
688
+ # Parse JSONL entry, silently skip unparseable lines
689
+ transcript_entry = JsonHandler.parse(line)
690
+
691
+ # Skip if parsing failed or if it's a summary entry
692
+ next if transcript_entry == line || transcript_entry["type"] == "summary"
693
+
694
+ # Convert to session.log.json format
695
+ session_entry = convert_transcript_to_session_format(transcript_entry)
696
+
697
+ # Only write if we got a valid conversion (skips summary and other non-relevant entries)
698
+ if session_entry
699
+ # Write with file locking (same pattern as BaseExecutor)
700
+ session_json_path = File.join(@session_path, "session.log.json")
701
+ File.open(session_json_path, File::WRONLY | File::APPEND | File::CREAT) do |log_file|
702
+ log_file.flock(File::LOCK_EX)
703
+ log_file.puts(session_entry.to_json)
704
+ end
705
+ end
706
+ rescue StandardError
707
+ # Silently handle other errors to keep thread running
708
+ end
709
+ else
710
+ # No new data, sleep briefly
711
+ sleep(0.1)
712
+ end
713
+ end
714
+ end
715
+ rescue StandardError
716
+ # Silently handle thread errors
717
+ end
718
+ end
719
+
720
+ def convert_transcript_to_session_format(transcript_entry)
721
+ # Skip if no type
722
+ return unless transcript_entry["type"]
723
+
724
+ instance_name = @config.main_instance
725
+ instance_id = "main"
726
+ timestamp = transcript_entry["timestamp"] || Time.now.iso8601
727
+
728
+ case transcript_entry["type"]
729
+ when "user"
730
+ # User message - format as request from user to main instance
731
+ message = transcript_entry["message"]
732
+
733
+ # Extract prompt text - message might be a string or an object
734
+ prompt_text = if message.is_a?(String)
735
+ message
736
+ elsif message.is_a?(Hash)
737
+ content = message["content"]
738
+ if content.is_a?(String)
739
+ content
740
+ elsif content.is_a?(Array)
741
+ # For tool results or complex content, extract text
742
+ extract_text_from_array(content)
743
+ else
744
+ ""
745
+ end
746
+ else
747
+ ""
748
+ end
749
+
750
+ {
751
+ instance: instance_name,
752
+ instance_id: instance_id,
753
+ timestamp: timestamp,
754
+ event: {
755
+ type: "request",
756
+ from_instance: "user",
757
+ from_instance_id: "user",
758
+ to_instance: instance_name,
759
+ to_instance_id: instance_id,
760
+ prompt: prompt_text,
761
+ timestamp: timestamp,
762
+ },
763
+ }
764
+ when "assistant"
765
+ # Assistant message - format as assistant response
766
+ message = transcript_entry["message"]
767
+
768
+ # Build a clean message structure without transcript-specific fields
769
+ clean_message = {
770
+ "type" => "message",
771
+ "role" => "assistant",
772
+ }
773
+
774
+ # Handle different message formats
775
+ if message.is_a?(String)
776
+ # Simple string message
777
+ clean_message["content"] = [{ "type" => "text", "text" => message }]
778
+ elsif message.is_a?(Hash)
779
+ # Only include the fields that other instances include
780
+ clean_message["content"] = message["content"] if message["content"]
781
+ clean_message["model"] = message["model"] if message["model"]
782
+ clean_message["usage"] = message["usage"] if message["usage"]
783
+ end
784
+
785
+ {
786
+ instance: instance_name,
787
+ instance_id: instance_id,
788
+ timestamp: timestamp,
789
+ event: {
790
+ type: "assistant",
791
+ message: clean_message,
792
+ session_id: transcript_entry["sessionId"],
793
+ },
794
+ }
795
+ end
796
+ # For other types (like summary), return nil to skip them
797
+ end
798
+
799
+ def extract_text_from_array(content)
800
+ content.map do |item|
801
+ if item.is_a?(Hash)
802
+ item["text"] || item["content"] || ""
803
+ else
804
+ item.to_s
805
+ end
806
+ end.join("\n")
807
+ end
808
+
809
+ def cleanup_transcript_thread
810
+ return unless @transcript_thread
811
+
812
+ @transcript_thread.terminate if @transcript_thread.alive?
813
+ @transcript_thread.join(1) # Wait up to 1 second for thread to finish
814
+ rescue StandardError => e
815
+ logger.error { "Error cleaning up transcript thread: #{e.message}" }
816
+ end
817
+
818
+ def logger
819
+ @logger ||= Logger.new(File.join(@session_path, "session.log"), level: :info, progname: "orchestrator")
820
+ end
821
+
822
+ def execute_commands(commands, phase:, fail_fast:)
823
+ all_succeeded = true
824
+
825
+ # Setup logger for session logging if we have a session path
826
+ log_dev = @signal_received ? nil : @session_log_path
827
+ logger = Logger.new(log_dev, level: :info)
828
+
829
+ commands.each_with_index do |command, index|
830
+ # Log the command execution to session log
831
+ logger.info { "Executing #{phase} command #{index + 1}/#{commands.size}: #{command}" }
832
+
833
+ # Execute the command and capture output
834
+ begin
835
+ if @debug
836
+ non_interactive_output do
837
+ debug_prefix = phase == "after" ? "after " : ""
838
+ print("Debug: Executing #{debug_prefix} command #{index + 1}/#{commands.size}: #{format_command_for_display(command)}")
839
+ end
840
+ end
841
+
842
+ output = %x(#{command} 2>&1)
843
+ success = $CHILD_STATUS.success?
844
+ output_separator = "-" * 80
845
+
846
+ logger.info { "Command output:" }
847
+ logger.info { output }
848
+ logger.info { "Exit status: #{$CHILD_STATUS.exitstatus}" }
849
+ logger.info { output_separator }
850
+
851
+ # Show output if in debug mode or if command failed
852
+ if @debug || !success
853
+ non_interactive_output do
854
+ output_prefix = phase == "after" ? "After command" : "Command"
855
+ puts "#{output_prefix} #{index + 1} output:"
856
+ puts output
857
+ print("Exit status: #{$CHILD_STATUS.exitstatus}")
858
+ end
859
+ end
860
+
861
+ unless success
862
+ error_prefix = phase.capitalize
863
+ non_interactive_output { print("❌ #{error_prefix} command #{index + 1} failed: #{command}") }
864
+ all_succeeded = false
865
+ return false if fail_fast
866
+ end
867
+ rescue StandardError => e
868
+ non_interactive_output { print("Error executing #{phase} command #{index + 1}: #{e.message}") }
869
+ logger.info { "Error: #{e.message}" }
870
+ logger.info { output_separator }
871
+ all_succeeded = false
872
+ return false if fail_fast
873
+ end
874
+ end
875
+
876
+ all_succeeded
877
+ end
878
+ end
879
+ end