swarm_memory 2.1.5 → 2.1.6

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