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.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/lib/claude_swarm/base_executor.rb +133 -0
- data/lib/claude_swarm/claude_code_executor.rb +349 -0
- data/lib/claude_swarm/claude_mcp_server.rb +77 -0
- data/lib/claude_swarm/cli.rb +712 -0
- data/lib/claude_swarm/commands/ps.rb +216 -0
- data/lib/claude_swarm/commands/show.rb +139 -0
- data/lib/claude_swarm/configuration.rb +363 -0
- data/lib/claude_swarm/hooks/session_start_hook.rb +42 -0
- data/lib/claude_swarm/json_handler.rb +91 -0
- data/lib/claude_swarm/mcp_generator.rb +248 -0
- data/lib/claude_swarm/openai/chat_completion.rb +264 -0
- data/lib/claude_swarm/openai/executor.rb +254 -0
- data/lib/claude_swarm/openai/responses.rb +338 -0
- data/lib/claude_swarm/orchestrator.rb +879 -0
- data/lib/claude_swarm/process_tracker.rb +78 -0
- data/lib/claude_swarm/session_cost_calculator.rb +209 -0
- data/lib/claude_swarm/session_path.rb +42 -0
- data/lib/claude_swarm/settings_generator.rb +77 -0
- data/lib/claude_swarm/system_utils.rb +46 -0
- data/lib/claude_swarm/templates/generation_prompt.md.erb +230 -0
- data/lib/claude_swarm/tools/reset_session_tool.rb +24 -0
- data/lib/claude_swarm/tools/session_info_tool.rb +24 -0
- data/lib/claude_swarm/tools/task_tool.rb +63 -0
- data/lib/claude_swarm/version.rb +5 -0
- data/lib/claude_swarm/worktree_manager.rb +475 -0
- data/lib/claude_swarm/yaml_loader.rb +22 -0
- data/lib/claude_swarm.rb +69 -0
- data/lib/swarm_cli/cli.rb +201 -0
- data/lib/swarm_cli/command_registry.rb +61 -0
- data/lib/swarm_cli/commands/mcp_serve.rb +130 -0
- data/lib/swarm_cli/commands/mcp_tools.rb +148 -0
- data/lib/swarm_cli/commands/migrate.rb +55 -0
- data/lib/swarm_cli/commands/run.rb +173 -0
- data/lib/swarm_cli/config_loader.rb +97 -0
- data/lib/swarm_cli/formatters/human_formatter.rb +711 -0
- data/lib/swarm_cli/formatters/json_formatter.rb +51 -0
- data/lib/swarm_cli/interactive_repl.rb +918 -0
- data/lib/swarm_cli/mcp_serve_options.rb +44 -0
- data/lib/swarm_cli/mcp_tools_options.rb +59 -0
- data/lib/swarm_cli/migrate_options.rb +54 -0
- data/lib/swarm_cli/migrator.rb +132 -0
- data/lib/swarm_cli/options.rb +151 -0
- data/lib/swarm_cli/ui/components/agent_badge.rb +33 -0
- data/lib/swarm_cli/ui/components/content_block.rb +120 -0
- data/lib/swarm_cli/ui/components/divider.rb +57 -0
- data/lib/swarm_cli/ui/components/panel.rb +62 -0
- data/lib/swarm_cli/ui/components/usage_stats.rb +70 -0
- data/lib/swarm_cli/ui/formatters/cost.rb +49 -0
- data/lib/swarm_cli/ui/formatters/number.rb +58 -0
- data/lib/swarm_cli/ui/formatters/text.rb +77 -0
- data/lib/swarm_cli/ui/formatters/time.rb +73 -0
- data/lib/swarm_cli/ui/icons.rb +59 -0
- data/lib/swarm_cli/ui/renderers/event_renderer.rb +188 -0
- data/lib/swarm_cli/ui/state/agent_color_cache.rb +45 -0
- data/lib/swarm_cli/ui/state/depth_tracker.rb +40 -0
- data/lib/swarm_cli/ui/state/spinner_manager.rb +170 -0
- data/lib/swarm_cli/ui/state/usage_tracker.rb +62 -0
- data/lib/swarm_cli/version.rb +5 -0
- data/lib/swarm_cli.rb +45 -0
- data/lib/swarm_memory/adapters/base.rb +140 -0
- data/lib/swarm_memory/adapters/filesystem_adapter.rb +789 -0
- data/lib/swarm_memory/chat_extension.rb +34 -0
- data/lib/swarm_memory/cli/commands.rb +306 -0
- data/lib/swarm_memory/core/entry.rb +37 -0
- data/lib/swarm_memory/core/frontmatter_parser.rb +108 -0
- data/lib/swarm_memory/core/metadata_extractor.rb +68 -0
- data/lib/swarm_memory/core/path_normalizer.rb +75 -0
- data/lib/swarm_memory/core/semantic_index.rb +244 -0
- data/lib/swarm_memory/core/storage.rb +286 -0
- data/lib/swarm_memory/core/storage_read_tracker.rb +63 -0
- data/lib/swarm_memory/dsl/builder_extension.rb +40 -0
- data/lib/swarm_memory/dsl/memory_config.rb +113 -0
- data/lib/swarm_memory/embeddings/embedder.rb +36 -0
- data/lib/swarm_memory/embeddings/informers_embedder.rb +152 -0
- data/lib/swarm_memory/errors.rb +21 -0
- data/lib/swarm_memory/integration/cli_registration.rb +30 -0
- data/lib/swarm_memory/integration/configuration.rb +43 -0
- data/lib/swarm_memory/integration/registration.rb +31 -0
- data/lib/swarm_memory/integration/sdk_plugin.rb +531 -0
- data/lib/swarm_memory/optimization/analyzer.rb +244 -0
- data/lib/swarm_memory/optimization/defragmenter.rb +863 -0
- data/lib/swarm_memory/prompts/memory.md.erb +109 -0
- data/lib/swarm_memory/prompts/memory_assistant.md.erb +139 -0
- data/lib/swarm_memory/prompts/memory_researcher.md.erb +201 -0
- data/lib/swarm_memory/prompts/memory_retrieval.md.erb +76 -0
- data/lib/swarm_memory/search/semantic_search.rb +112 -0
- data/lib/swarm_memory/search/text_search.rb +40 -0
- data/lib/swarm_memory/search/text_similarity.rb +80 -0
- data/lib/swarm_memory/skills/meta/deep-learning.md +101 -0
- data/lib/swarm_memory/skills/meta/deep-learning.yml +14 -0
- data/lib/swarm_memory/tools/load_skill.rb +313 -0
- data/lib/swarm_memory/tools/memory_defrag.rb +382 -0
- data/lib/swarm_memory/tools/memory_delete.rb +99 -0
- data/lib/swarm_memory/tools/memory_edit.rb +185 -0
- data/lib/swarm_memory/tools/memory_glob.rb +145 -0
- data/lib/swarm_memory/tools/memory_grep.rb +209 -0
- data/lib/swarm_memory/tools/memory_multi_edit.rb +281 -0
- data/lib/swarm_memory/tools/memory_read.rb +123 -0
- data/lib/swarm_memory/tools/memory_write.rb +215 -0
- data/lib/swarm_memory/utils.rb +50 -0
- data/lib/swarm_memory/version.rb +5 -0
- data/lib/swarm_memory.rb +166 -0
- data/lib/swarm_sdk/agent/RETRY_LOGIC.md +127 -0
- data/lib/swarm_sdk/agent/builder.rb +461 -0
- data/lib/swarm_sdk/agent/chat/context_tracker.rb +314 -0
- data/lib/swarm_sdk/agent/chat/hook_integration.rb +372 -0
- data/lib/swarm_sdk/agent/chat/logging_helpers.rb +116 -0
- data/lib/swarm_sdk/agent/chat/system_reminder_injector.rb +152 -0
- data/lib/swarm_sdk/agent/chat.rb +1144 -0
- data/lib/swarm_sdk/agent/context.rb +112 -0
- data/lib/swarm_sdk/agent/context_manager.rb +309 -0
- data/lib/swarm_sdk/agent/definition.rb +556 -0
- data/lib/swarm_sdk/claude_code_agent_adapter.rb +205 -0
- data/lib/swarm_sdk/configuration.rb +296 -0
- data/lib/swarm_sdk/context_compactor/metrics.rb +147 -0
- data/lib/swarm_sdk/context_compactor/token_counter.rb +106 -0
- data/lib/swarm_sdk/context_compactor.rb +340 -0
- data/lib/swarm_sdk/hooks/adapter.rb +359 -0
- data/lib/swarm_sdk/hooks/context.rb +197 -0
- data/lib/swarm_sdk/hooks/definition.rb +80 -0
- data/lib/swarm_sdk/hooks/error.rb +29 -0
- data/lib/swarm_sdk/hooks/executor.rb +146 -0
- data/lib/swarm_sdk/hooks/registry.rb +147 -0
- data/lib/swarm_sdk/hooks/result.rb +150 -0
- data/lib/swarm_sdk/hooks/shell_executor.rb +254 -0
- data/lib/swarm_sdk/hooks/tool_call.rb +35 -0
- data/lib/swarm_sdk/hooks/tool_result.rb +62 -0
- data/lib/swarm_sdk/log_collector.rb +51 -0
- data/lib/swarm_sdk/log_stream.rb +69 -0
- data/lib/swarm_sdk/markdown_parser.rb +75 -0
- data/lib/swarm_sdk/model_aliases.json +5 -0
- data/lib/swarm_sdk/models.json +1 -0
- data/lib/swarm_sdk/models.rb +120 -0
- data/lib/swarm_sdk/node/agent_config.rb +49 -0
- data/lib/swarm_sdk/node/builder.rb +439 -0
- data/lib/swarm_sdk/node/transformer_executor.rb +248 -0
- data/lib/swarm_sdk/node_context.rb +170 -0
- data/lib/swarm_sdk/node_orchestrator.rb +384 -0
- data/lib/swarm_sdk/permissions/config.rb +239 -0
- data/lib/swarm_sdk/permissions/error_formatter.rb +121 -0
- data/lib/swarm_sdk/permissions/path_matcher.rb +35 -0
- data/lib/swarm_sdk/permissions/validator.rb +173 -0
- data/lib/swarm_sdk/permissions_builder.rb +122 -0
- data/lib/swarm_sdk/plugin.rb +147 -0
- data/lib/swarm_sdk/plugin_registry.rb +101 -0
- data/lib/swarm_sdk/prompts/base_system_prompt.md.erb +243 -0
- data/lib/swarm_sdk/providers/openai_with_responses.rb +582 -0
- data/lib/swarm_sdk/result.rb +97 -0
- data/lib/swarm_sdk/swarm/agent_initializer.rb +334 -0
- data/lib/swarm_sdk/swarm/all_agents_builder.rb +140 -0
- data/lib/swarm_sdk/swarm/builder.rb +586 -0
- data/lib/swarm_sdk/swarm/mcp_configurator.rb +151 -0
- data/lib/swarm_sdk/swarm/tool_configurator.rb +416 -0
- data/lib/swarm_sdk/swarm.rb +982 -0
- data/lib/swarm_sdk/tools/bash.rb +274 -0
- data/lib/swarm_sdk/tools/clock.rb +44 -0
- data/lib/swarm_sdk/tools/delegate.rb +164 -0
- data/lib/swarm_sdk/tools/document_converters/base_converter.rb +83 -0
- data/lib/swarm_sdk/tools/document_converters/docx_converter.rb +99 -0
- data/lib/swarm_sdk/tools/document_converters/html_converter.rb +101 -0
- data/lib/swarm_sdk/tools/document_converters/pdf_converter.rb +78 -0
- data/lib/swarm_sdk/tools/document_converters/xlsx_converter.rb +194 -0
- data/lib/swarm_sdk/tools/edit.rb +150 -0
- data/lib/swarm_sdk/tools/glob.rb +158 -0
- data/lib/swarm_sdk/tools/grep.rb +228 -0
- data/lib/swarm_sdk/tools/image_extractors/docx_image_extractor.rb +43 -0
- data/lib/swarm_sdk/tools/image_extractors/pdf_image_extractor.rb +163 -0
- data/lib/swarm_sdk/tools/image_formats/tiff_builder.rb +65 -0
- data/lib/swarm_sdk/tools/multi_edit.rb +232 -0
- data/lib/swarm_sdk/tools/path_resolver.rb +43 -0
- data/lib/swarm_sdk/tools/read.rb +251 -0
- data/lib/swarm_sdk/tools/registry.rb +93 -0
- data/lib/swarm_sdk/tools/scratchpad/scratchpad_list.rb +96 -0
- data/lib/swarm_sdk/tools/scratchpad/scratchpad_read.rb +76 -0
- data/lib/swarm_sdk/tools/scratchpad/scratchpad_write.rb +91 -0
- data/lib/swarm_sdk/tools/stores/read_tracker.rb +61 -0
- data/lib/swarm_sdk/tools/stores/scratchpad_storage.rb +224 -0
- data/lib/swarm_sdk/tools/stores/storage.rb +148 -0
- data/lib/swarm_sdk/tools/stores/todo_manager.rb +65 -0
- data/lib/swarm_sdk/tools/think.rb +95 -0
- data/lib/swarm_sdk/tools/todo_write.rb +216 -0
- data/lib/swarm_sdk/tools/web_fetch.rb +261 -0
- data/lib/swarm_sdk/tools/write.rb +117 -0
- data/lib/swarm_sdk/utils.rb +50 -0
- data/lib/swarm_sdk/version.rb +5 -0
- data/lib/swarm_sdk.rb +167 -0
- 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
|