earl-bot 0.1.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/.ruby-version +1 -0
- data/CHANGELOG.md +40 -0
- data/CLAUDE.md +260 -0
- data/Gemfile +9 -0
- data/Gemfile.lock +177 -0
- data/LICENSE +21 -0
- data/README.md +106 -0
- data/Rakefile +11 -0
- data/bin/README.md +21 -0
- data/bin/ci +49 -0
- data/bin/claude-context +155 -0
- data/bin/claude-usage +110 -0
- data/bin/coverage +221 -0
- data/bin/rubocop +10 -0
- data/bin/watch-ci +198 -0
- data/config/earl-claude-home/.claude/CLAUDE.md +10 -0
- data/config/earl-claude-home/.claude/settings.json +34 -0
- data/earl-bot.gemspec +42 -0
- data/exe/earl +51 -0
- data/exe/earl-install +129 -0
- data/exe/earl-permission-server +39 -0
- data/lib/earl/claude_session/stats.rb +76 -0
- data/lib/earl/claude_session.rb +468 -0
- data/lib/earl/command_executor/constants.rb +53 -0
- data/lib/earl/command_executor/heartbeat_display.rb +54 -0
- data/lib/earl/command_executor/lifecycle_handler.rb +61 -0
- data/lib/earl/command_executor/session_handler.rb +126 -0
- data/lib/earl/command_executor/spawn_handler.rb +99 -0
- data/lib/earl/command_executor/stats_formatter.rb +66 -0
- data/lib/earl/command_executor/usage_handler.rb +132 -0
- data/lib/earl/command_executor.rb +128 -0
- data/lib/earl/command_parser.rb +57 -0
- data/lib/earl/config.rb +94 -0
- data/lib/earl/cron_parser.rb +105 -0
- data/lib/earl/formatting.rb +14 -0
- data/lib/earl/heartbeat_config.rb +101 -0
- data/lib/earl/heartbeat_scheduler/config_reloading.rb +64 -0
- data/lib/earl/heartbeat_scheduler/execution.rb +105 -0
- data/lib/earl/heartbeat_scheduler/heartbeat_state.rb +41 -0
- data/lib/earl/heartbeat_scheduler/lifecycle.rb +75 -0
- data/lib/earl/heartbeat_scheduler.rb +131 -0
- data/lib/earl/logging.rb +12 -0
- data/lib/earl/mattermost/api_client.rb +85 -0
- data/lib/earl/mattermost.rb +261 -0
- data/lib/earl/mcp/approval_handler.rb +304 -0
- data/lib/earl/mcp/config.rb +62 -0
- data/lib/earl/mcp/github_pat_handler.rb +450 -0
- data/lib/earl/mcp/handler_base.rb +13 -0
- data/lib/earl/mcp/heartbeat_handler.rb +310 -0
- data/lib/earl/mcp/memory_handler.rb +89 -0
- data/lib/earl/mcp/server.rb +123 -0
- data/lib/earl/mcp/tmux_handler.rb +562 -0
- data/lib/earl/memory/prompt_builder.rb +40 -0
- data/lib/earl/memory/store.rb +125 -0
- data/lib/earl/message_queue.rb +56 -0
- data/lib/earl/permission_config.rb +22 -0
- data/lib/earl/question_handler/question_posting.rb +58 -0
- data/lib/earl/question_handler.rb +116 -0
- data/lib/earl/runner/idle_management.rb +44 -0
- data/lib/earl/runner/lifecycle.rb +73 -0
- data/lib/earl/runner/message_handling.rb +121 -0
- data/lib/earl/runner/reaction_handling.rb +42 -0
- data/lib/earl/runner/response_lifecycle.rb +96 -0
- data/lib/earl/runner/service_builder.rb +48 -0
- data/lib/earl/runner/startup.rb +73 -0
- data/lib/earl/runner/thread_context_builder.rb +43 -0
- data/lib/earl/runner.rb +70 -0
- data/lib/earl/safari_automation.rb +497 -0
- data/lib/earl/session_manager/persistence.rb +46 -0
- data/lib/earl/session_manager/session_creation.rb +108 -0
- data/lib/earl/session_manager.rb +92 -0
- data/lib/earl/session_store.rb +84 -0
- data/lib/earl/streaming_response.rb +219 -0
- data/lib/earl/tmux/parsing.rb +80 -0
- data/lib/earl/tmux/processes.rb +34 -0
- data/lib/earl/tmux/sessions.rb +41 -0
- data/lib/earl/tmux.rb +122 -0
- data/lib/earl/tmux_monitor/alert_dispatcher.rb +53 -0
- data/lib/earl/tmux_monitor/output_analyzer.rb +35 -0
- data/lib/earl/tmux_monitor/permission_forwarder.rb +80 -0
- data/lib/earl/tmux_monitor/question_forwarder.rb +124 -0
- data/lib/earl/tmux_monitor.rb +249 -0
- data/lib/earl/tmux_session_store.rb +133 -0
- data/lib/earl/tool_input_formatter.rb +44 -0
- data/lib/earl/version.rb +5 -0
- data/lib/earl.rb +87 -0
- data/lib/tasks/.keep +1 -0
- metadata +248 -0
|
@@ -0,0 +1,562 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "securerandom"
|
|
4
|
+
require "shellwords"
|
|
5
|
+
|
|
6
|
+
module Earl
|
|
7
|
+
module Mcp
|
|
8
|
+
# MCP handler exposing a manage_tmux_sessions tool to list, capture, control,
|
|
9
|
+
# spawn, and kill Claude sessions running in tmux panes.
|
|
10
|
+
# Conforms to the Server handler interface: tool_definitions, handles?, call.
|
|
11
|
+
class TmuxHandler
|
|
12
|
+
include Logging
|
|
13
|
+
include HandlerBase
|
|
14
|
+
|
|
15
|
+
TOOL_NAME = "manage_tmux_sessions"
|
|
16
|
+
TOOL_NAMES = [TOOL_NAME].freeze
|
|
17
|
+
VALID_ACTIONS = %w[list capture status approve deny send_input spawn kill].freeze
|
|
18
|
+
|
|
19
|
+
# Bundles spawn parameters that travel together through the confirmation and creation flow.
|
|
20
|
+
SpawnRequest = Data.define(:name, :prompt, :working_dir, :session)
|
|
21
|
+
|
|
22
|
+
# Reaction emojis and pane status labels for spawn confirmation flow.
|
|
23
|
+
module Reactions
|
|
24
|
+
APPROVE_EMOJIS = %w[+1 white_check_mark].freeze
|
|
25
|
+
DENY_EMOJIS = %w[-1].freeze
|
|
26
|
+
ALL = (APPROVE_EMOJIS + DENY_EMOJIS).freeze
|
|
27
|
+
|
|
28
|
+
PANE_STATUS_LABELS = {
|
|
29
|
+
active: "Active",
|
|
30
|
+
permission: "Waiting for permission",
|
|
31
|
+
idle: "Idle"
|
|
32
|
+
}.freeze
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def initialize(config:, api_client:, tmux_store:, tmux_adapter: Tmux)
|
|
36
|
+
@config = config
|
|
37
|
+
@api = api_client
|
|
38
|
+
@tmux_store = tmux_store
|
|
39
|
+
@tmux = tmux_adapter
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def tool_definitions
|
|
43
|
+
[tool_definition]
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
TARGET_REQUIRED_ACTIONS = %w[capture status approve deny send_input kill].freeze
|
|
47
|
+
|
|
48
|
+
def call(name, arguments)
|
|
49
|
+
return unless handles?(name)
|
|
50
|
+
|
|
51
|
+
error = validate_call_args(arguments)
|
|
52
|
+
return error if error
|
|
53
|
+
|
|
54
|
+
send("handle_#{arguments["action"]}", arguments)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
private
|
|
58
|
+
|
|
59
|
+
def validate_call_args(arguments)
|
|
60
|
+
action = arguments["action"]
|
|
61
|
+
valid_list = VALID_ACTIONS.join(", ")
|
|
62
|
+
return text_content("Error: action is required (#{valid_list})") unless action
|
|
63
|
+
unless VALID_ACTIONS.include?(action)
|
|
64
|
+
return text_content("Error: unknown action '#{action}'. Valid: #{valid_list}")
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
text_content("Error: target is required for #{action}") if target_required_but_missing?(action,
|
|
68
|
+
arguments)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def target_required_but_missing?(action, arguments)
|
|
72
|
+
TARGET_REQUIRED_ACTIONS.include?(action) && !arguments["target"]
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Action handlers for list, capture, status, approve, deny, send_input, spawn, kill.
|
|
76
|
+
module ActionHandlers
|
|
77
|
+
private
|
|
78
|
+
|
|
79
|
+
# --- list ---
|
|
80
|
+
|
|
81
|
+
def handle_list(_arguments)
|
|
82
|
+
return text_content("Error: tmux is not available") unless @tmux.available?
|
|
83
|
+
|
|
84
|
+
panes = @tmux.list_all_panes
|
|
85
|
+
claude_panes = select_claude_panes(panes)
|
|
86
|
+
format_pane_list(panes, claude_panes)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def select_claude_panes(panes)
|
|
90
|
+
panes.select { |pane| @tmux.claude_on_tty?(pane[:tty]) }
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def format_pane_list(panes, claude_panes)
|
|
94
|
+
return text_content("No tmux sessions running.") if panes.empty?
|
|
95
|
+
return text_content("No Claude sessions found across #{panes.size} tmux panes.") if claude_panes.empty?
|
|
96
|
+
|
|
97
|
+
lines = claude_panes.map { |pane| format_pane(pane) }
|
|
98
|
+
text_content("**Claude Sessions (#{claude_panes.size}):**\n\n#{lines.join("\n")}")
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# --- capture ---
|
|
102
|
+
|
|
103
|
+
def handle_capture(arguments)
|
|
104
|
+
target = arguments["target"]
|
|
105
|
+
lines = arguments.fetch("lines", 100).to_i
|
|
106
|
+
lines = [lines, 1].max
|
|
107
|
+
output = @tmux.capture_pane(target, lines: lines)
|
|
108
|
+
text_content("**`#{target}` output (last #{lines} lines):**\n```\n#{output}\n```")
|
|
109
|
+
rescue Tmux::NotFound
|
|
110
|
+
text_content("Error: session/pane '#{target}' not found")
|
|
111
|
+
rescue Tmux::Error => error
|
|
112
|
+
text_content("Error: #{error.message}")
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# --- status ---
|
|
116
|
+
|
|
117
|
+
def handle_status(arguments)
|
|
118
|
+
target = arguments["target"]
|
|
119
|
+
output = @tmux.capture_pane(target, lines: 200)
|
|
120
|
+
status_label = Reactions::PANE_STATUS_LABELS.fetch(classify_pane_output(output), "Idle")
|
|
121
|
+
text_content("**`#{target}` status: #{status_label}**\n```\n#{output}\n```")
|
|
122
|
+
rescue Tmux::NotFound
|
|
123
|
+
text_content("Error: session/pane '#{target}' not found")
|
|
124
|
+
rescue Tmux::Error => error
|
|
125
|
+
text_content("Error: #{error.message}")
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# --- approve ---
|
|
129
|
+
|
|
130
|
+
def handle_approve(arguments)
|
|
131
|
+
target = arguments["target"]
|
|
132
|
+
@tmux.send_keys_raw(target, "Enter")
|
|
133
|
+
text_content("Approved permission on `#{target}`.")
|
|
134
|
+
rescue Tmux::NotFound
|
|
135
|
+
text_content("Error: session/pane '#{target}' not found")
|
|
136
|
+
rescue Tmux::Error => error
|
|
137
|
+
text_content("Error: #{error.message}")
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# --- deny ---
|
|
141
|
+
|
|
142
|
+
def handle_deny(arguments)
|
|
143
|
+
target = arguments["target"]
|
|
144
|
+
@tmux.send_keys_raw(target, "Escape")
|
|
145
|
+
text_content("Denied permission on `#{target}`.")
|
|
146
|
+
rescue Tmux::NotFound
|
|
147
|
+
text_content("Error: session/pane '#{target}' not found")
|
|
148
|
+
rescue Tmux::Error => error
|
|
149
|
+
text_content("Error: #{error.message}")
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# --- send_input ---
|
|
153
|
+
|
|
154
|
+
def handle_send_input(arguments)
|
|
155
|
+
target = arguments["target"]
|
|
156
|
+
text = arguments["text"]
|
|
157
|
+
return text_content("Error: text is required for send_input") unless text
|
|
158
|
+
|
|
159
|
+
@tmux.send_keys(target, text)
|
|
160
|
+
text_content("Sent to `#{target}`: `#{text}`")
|
|
161
|
+
rescue Tmux::NotFound
|
|
162
|
+
text_content("Error: session/pane '#{target}' not found")
|
|
163
|
+
rescue Tmux::Error => error
|
|
164
|
+
text_content("Error: #{error.message}")
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
# --- spawn ---
|
|
168
|
+
|
|
169
|
+
def handle_spawn(arguments)
|
|
170
|
+
spawn_error = validate_spawn_args(arguments)
|
|
171
|
+
return spawn_error if spawn_error
|
|
172
|
+
|
|
173
|
+
request = build_spawn_request(arguments)
|
|
174
|
+
execute_spawn(request)
|
|
175
|
+
rescue Tmux::Error => error
|
|
176
|
+
text_content("Error: #{error.message}")
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
# --- kill ---
|
|
180
|
+
|
|
181
|
+
def handle_kill(arguments)
|
|
182
|
+
target = arguments["target"]
|
|
183
|
+
kill_tmux_session(target)
|
|
184
|
+
rescue Tmux::Error => error
|
|
185
|
+
text_content("Error: #{error.message}")
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
include ActionHandlers
|
|
190
|
+
|
|
191
|
+
# --- helpers ---
|
|
192
|
+
|
|
193
|
+
def text_content(text)
|
|
194
|
+
{ content: [{ type: "text", text: text }] }
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
# Pane formatting and status detection.
|
|
198
|
+
module PaneOperations
|
|
199
|
+
private
|
|
200
|
+
|
|
201
|
+
def format_pane(pane)
|
|
202
|
+
target, path = pane.values_at(:target, :path)
|
|
203
|
+
project = File.basename(path)
|
|
204
|
+
status = detect_pane_status(target)
|
|
205
|
+
label = Reactions::PANE_STATUS_LABELS.fetch(status, "Idle")
|
|
206
|
+
"- `#{target}` — #{project} (#{label})"
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
def detect_pane_status(target)
|
|
210
|
+
output = @tmux.capture_pane(target, lines: 20)
|
|
211
|
+
classify_pane_output(output)
|
|
212
|
+
rescue Tmux::Error => error
|
|
213
|
+
log(:debug, "detect_pane_status failed for #{target}: #{error.message}")
|
|
214
|
+
:idle
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
def classify_pane_output(output)
|
|
218
|
+
return :permission if output.include?("Do you want to proceed?")
|
|
219
|
+
return :active if output.include?("esc to interrupt")
|
|
220
|
+
|
|
221
|
+
:idle
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
def kill_tmux_session(target)
|
|
225
|
+
msg = begin
|
|
226
|
+
@tmux.kill_session(target)
|
|
227
|
+
"Killed tmux session `#{target}`."
|
|
228
|
+
rescue Tmux::NotFound
|
|
229
|
+
"Error: session '#{target}' not found (cleaned up store)"
|
|
230
|
+
end
|
|
231
|
+
@tmux_store.delete(target)
|
|
232
|
+
text_content(msg)
|
|
233
|
+
end
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
# Spawn argument validation and request building.
|
|
237
|
+
module SpawnValidation
|
|
238
|
+
private
|
|
239
|
+
|
|
240
|
+
def validate_spawn_args(arguments)
|
|
241
|
+
error = validate_prompt(arguments)
|
|
242
|
+
error ||= validate_working_dir(arguments)
|
|
243
|
+
error || validate_session_or_name(arguments)
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
def validate_prompt(arguments)
|
|
247
|
+
prompt = arguments["prompt"]
|
|
248
|
+
text_content("Error: prompt is required for spawn") unless prompt && !prompt.strip.empty?
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
def validate_working_dir(arguments)
|
|
252
|
+
working_dir = arguments["working_dir"]
|
|
253
|
+
text_content("Error: directory '#{working_dir}' not found") if working_dir && !Dir.exist?(working_dir)
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
def validate_session_or_name(arguments)
|
|
257
|
+
session = arguments["session"]
|
|
258
|
+
if session
|
|
259
|
+
validate_existing_session(session)
|
|
260
|
+
else
|
|
261
|
+
validate_new_session_name(arguments)
|
|
262
|
+
end
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
def validate_existing_session(session)
|
|
266
|
+
text_content("Error: session '#{session}' not found") unless @tmux.session_exists?(session)
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
def validate_new_session_name(arguments)
|
|
270
|
+
name = arguments["name"] || generate_session_name
|
|
271
|
+
if name.match?(/[.:]/)
|
|
272
|
+
return text_content("Error: session name '#{name}' cannot contain '.' or ':' (tmux target delimiters)")
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
text_content("Error: session '#{name}' already exists") if @tmux.session_exists?(name)
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
def generate_session_name
|
|
279
|
+
"earl-#{Time.now.strftime("%Y%m%d%H%M%S")}-#{SecureRandom.hex(2)}"
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
def build_spawn_request(arguments)
|
|
283
|
+
name, prompt, working_dir, session = arguments.values_at("name", "prompt", "working_dir", "session")
|
|
284
|
+
SpawnRequest.new(
|
|
285
|
+
name: name || generate_session_name, prompt: prompt,
|
|
286
|
+
working_dir: working_dir, session: session
|
|
287
|
+
)
|
|
288
|
+
end
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
# Spawn confirmation, session creation, and Mattermost messaging.
|
|
292
|
+
module SpawnConfirmation
|
|
293
|
+
private
|
|
294
|
+
|
|
295
|
+
def execute_spawn(request)
|
|
296
|
+
case request_spawn_confirmation(request)
|
|
297
|
+
when :approved then create_spawned_session(request)
|
|
298
|
+
when :error then text_content("Error: spawn confirmation failed (could not post or connect to Mattermost)")
|
|
299
|
+
else text_content("Spawn denied by user.")
|
|
300
|
+
end
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
def create_spawned_session(request)
|
|
304
|
+
name, prompt, working_dir, session = request.deconstruct
|
|
305
|
+
command = "claude #{Shellwords.shellescape(prompt)}"
|
|
306
|
+
if session
|
|
307
|
+
@tmux.create_window(session: session, name: name, command: command, working_dir: working_dir)
|
|
308
|
+
else
|
|
309
|
+
@tmux.create_session(name: name, command: command, working_dir: working_dir)
|
|
310
|
+
end
|
|
311
|
+
persist_session_info(name, working_dir, prompt)
|
|
312
|
+
mode = session ? "window in `#{session}`" : "session"
|
|
313
|
+
text_content("Spawned tmux #{mode} `#{name}`.\n- Prompt: #{prompt}\n- Dir: #{working_dir || Dir.pwd}")
|
|
314
|
+
end
|
|
315
|
+
|
|
316
|
+
def persist_session_info(name, working_dir, prompt)
|
|
317
|
+
info = TmuxSessionStore::TmuxSessionInfo.new(
|
|
318
|
+
name: name, channel_id: @config.platform_channel_id,
|
|
319
|
+
thread_id: @config.platform_thread_id,
|
|
320
|
+
working_dir: working_dir, prompt: prompt, created_at: Time.now.iso8601
|
|
321
|
+
)
|
|
322
|
+
@tmux_store.save(info)
|
|
323
|
+
end
|
|
324
|
+
|
|
325
|
+
def request_spawn_confirmation(request)
|
|
326
|
+
post_id = post_confirmation_request(request)
|
|
327
|
+
return :error unless post_id
|
|
328
|
+
|
|
329
|
+
add_reaction_options(post_id)
|
|
330
|
+
wait_for_confirmation(post_id)
|
|
331
|
+
ensure
|
|
332
|
+
delete_confirmation_post(post_id) if post_id
|
|
333
|
+
end
|
|
334
|
+
|
|
335
|
+
def post_confirmation_request(request)
|
|
336
|
+
message = build_confirmation_message(request)
|
|
337
|
+
post_to_channel(message)
|
|
338
|
+
rescue IOError, JSON::ParserError, Errno::ECONNREFUSED, Errno::ECONNRESET => error
|
|
339
|
+
log(:error, "Failed to post spawn confirmation: #{error.message}")
|
|
340
|
+
nil
|
|
341
|
+
end
|
|
342
|
+
|
|
343
|
+
def build_confirmation_message(request)
|
|
344
|
+
name, prompt, working_dir, session = request.deconstruct
|
|
345
|
+
dir_line = working_dir ? "\n- **Dir:** #{working_dir}" : ""
|
|
346
|
+
session_line = session ? "\n- **Session:** #{session} (new window)" : ""
|
|
347
|
+
":rocket: **Spawn Request**\n" \
|
|
348
|
+
"Claude wants to spawn #{session ? "window" : "session"} `#{name}`\n" \
|
|
349
|
+
"- **Prompt:** #{prompt}#{dir_line}#{session_line}\n" \
|
|
350
|
+
"React: :+1: approve | :-1: deny"
|
|
351
|
+
end
|
|
352
|
+
|
|
353
|
+
def post_to_channel(message)
|
|
354
|
+
response = @api.post("/posts", {
|
|
355
|
+
channel_id: @config.platform_channel_id,
|
|
356
|
+
message: message,
|
|
357
|
+
root_id: @config.platform_thread_id
|
|
358
|
+
})
|
|
359
|
+
|
|
360
|
+
return unless response.is_a?(Net::HTTPSuccess)
|
|
361
|
+
|
|
362
|
+
JSON.parse(response.body)["id"]
|
|
363
|
+
end
|
|
364
|
+
|
|
365
|
+
def add_reaction_options(post_id)
|
|
366
|
+
Reactions::ALL.each do |emoji|
|
|
367
|
+
response = @api.post("/reactions", {
|
|
368
|
+
user_id: @config.platform_bot_id,
|
|
369
|
+
post_id: post_id,
|
|
370
|
+
emoji_name: emoji
|
|
371
|
+
})
|
|
372
|
+
log(:warn, "Failed to add reaction #{emoji} to post #{post_id}") unless response.is_a?(Net::HTTPSuccess)
|
|
373
|
+
end
|
|
374
|
+
rescue IOError, Errno::ECONNREFUSED, Errno::ECONNRESET => error
|
|
375
|
+
log(:error, "Failed to add reaction options to post #{post_id}: #{error.message}")
|
|
376
|
+
end
|
|
377
|
+
end
|
|
378
|
+
|
|
379
|
+
# WebSocket-based polling for spawn confirmation reactions.
|
|
380
|
+
module SpawnPolling
|
|
381
|
+
private
|
|
382
|
+
|
|
383
|
+
def wait_for_confirmation(post_id)
|
|
384
|
+
timeout_sec = @config.permission_timeout_ms / 1000.0
|
|
385
|
+
deadline = Time.now + timeout_sec
|
|
386
|
+
|
|
387
|
+
websocket = connect_websocket
|
|
388
|
+
return :error unless websocket
|
|
389
|
+
|
|
390
|
+
poll_confirmation(websocket, post_id, deadline)
|
|
391
|
+
ensure
|
|
392
|
+
close_websocket(websocket)
|
|
393
|
+
end
|
|
394
|
+
|
|
395
|
+
def close_websocket(websocket)
|
|
396
|
+
websocket&.close
|
|
397
|
+
rescue IOError, Errno::ECONNRESET => error
|
|
398
|
+
log(:debug, "Failed to close spawn confirmation WebSocket: #{error.message}")
|
|
399
|
+
end
|
|
400
|
+
|
|
401
|
+
def connect_websocket
|
|
402
|
+
websocket = WebSocket::Client::Simple.connect(@config.websocket_url)
|
|
403
|
+
token = @config.platform_token
|
|
404
|
+
ws_ref = websocket
|
|
405
|
+
websocket.on(:open) do
|
|
406
|
+
ws_ref.send(JSON.generate({ seq: 1, action: "authentication_challenge", data: { token: token } }))
|
|
407
|
+
end
|
|
408
|
+
websocket
|
|
409
|
+
rescue IOError, SocketError, Errno::ECONNREFUSED, Errno::ECONNRESET, Errno::EHOSTUNREACH => error
|
|
410
|
+
log(:error, "Spawn confirmation WebSocket failed: #{error.message}")
|
|
411
|
+
nil
|
|
412
|
+
end
|
|
413
|
+
|
|
414
|
+
def poll_confirmation(websocket, post_id, deadline)
|
|
415
|
+
queue = setup_reaction_listener(websocket, post_id)
|
|
416
|
+
await_reaction(queue, deadline)
|
|
417
|
+
end
|
|
418
|
+
|
|
419
|
+
def setup_reaction_listener(websocket, post_id)
|
|
420
|
+
queue = Queue.new
|
|
421
|
+
websocket.on(:message) do |msg|
|
|
422
|
+
reaction_data = parse_reaction_event(msg)
|
|
423
|
+
next unless reaction_data
|
|
424
|
+
|
|
425
|
+
matches_post = reaction_data["post_id"] == post_id
|
|
426
|
+
queue.push(reaction_data) if matches_post
|
|
427
|
+
end
|
|
428
|
+
queue
|
|
429
|
+
end
|
|
430
|
+
|
|
431
|
+
def parse_reaction_event(msg)
|
|
432
|
+
raw = msg.data
|
|
433
|
+
return unless raw && !raw.empty?
|
|
434
|
+
|
|
435
|
+
parsed = JSON.parse(raw)
|
|
436
|
+
event_name, nested_data = parsed.values_at("event", "data")
|
|
437
|
+
return unless event_name == "reaction_added"
|
|
438
|
+
|
|
439
|
+
reaction_json = nested_data&.dig("reaction") || "{}"
|
|
440
|
+
JSON.parse(reaction_json)
|
|
441
|
+
rescue JSON::ParserError
|
|
442
|
+
log(:debug, "Spawn confirmation: skipped unparsable WebSocket message")
|
|
443
|
+
nil
|
|
444
|
+
end
|
|
445
|
+
|
|
446
|
+
def await_reaction(queue, deadline)
|
|
447
|
+
loop do
|
|
448
|
+
remaining = deadline - Time.now
|
|
449
|
+
return :denied if remaining <= 0
|
|
450
|
+
|
|
451
|
+
reaction = dequeue_reaction(queue)
|
|
452
|
+
next unless reaction
|
|
453
|
+
|
|
454
|
+
result = classify_reaction(reaction)
|
|
455
|
+
return result if result
|
|
456
|
+
end
|
|
457
|
+
end
|
|
458
|
+
|
|
459
|
+
def dequeue_reaction(queue)
|
|
460
|
+
queue.pop(true)
|
|
461
|
+
rescue ThreadError
|
|
462
|
+
sleep 0.5
|
|
463
|
+
nil
|
|
464
|
+
end
|
|
465
|
+
|
|
466
|
+
def classify_reaction(reaction)
|
|
467
|
+
user_id = reaction["user_id"]
|
|
468
|
+
return if user_id == @config.platform_bot_id
|
|
469
|
+
return unless allowed_reactor?(user_id)
|
|
470
|
+
|
|
471
|
+
emoji = reaction["emoji_name"]
|
|
472
|
+
return :approved if Reactions::APPROVE_EMOJIS.include?(emoji)
|
|
473
|
+
|
|
474
|
+
:denied if Reactions::DENY_EMOJIS.include?(emoji)
|
|
475
|
+
end
|
|
476
|
+
|
|
477
|
+
def allowed_reactor?(user_id)
|
|
478
|
+
allowed = @config.allowed_users
|
|
479
|
+
return true if allowed.empty?
|
|
480
|
+
|
|
481
|
+
response = @api.get("/users/#{user_id}")
|
|
482
|
+
return false unless response.is_a?(Net::HTTPSuccess)
|
|
483
|
+
|
|
484
|
+
user = JSON.parse(response.body)
|
|
485
|
+
allowed.include?(user["username"])
|
|
486
|
+
end
|
|
487
|
+
|
|
488
|
+
def delete_confirmation_post(post_id)
|
|
489
|
+
@api.delete("/posts/#{post_id}")
|
|
490
|
+
rescue StandardError => error
|
|
491
|
+
log(:warn, "Failed to delete spawn confirmation: #{error.message}")
|
|
492
|
+
end
|
|
493
|
+
end
|
|
494
|
+
|
|
495
|
+
# Tool definition building: splits the large schema into composable property groups.
|
|
496
|
+
module ToolDefinitionBuilder
|
|
497
|
+
private
|
|
498
|
+
|
|
499
|
+
def tool_definition
|
|
500
|
+
{
|
|
501
|
+
name: TOOL_NAME,
|
|
502
|
+
description: tmux_tool_description,
|
|
503
|
+
inputSchema: tmux_input_schema
|
|
504
|
+
}
|
|
505
|
+
end
|
|
506
|
+
|
|
507
|
+
def tmux_tool_description
|
|
508
|
+
"Manage Claude sessions running in tmux. " \
|
|
509
|
+
"List sessions, capture output, approve/deny permissions, " \
|
|
510
|
+
"send input, spawn new sessions, or kill sessions."
|
|
511
|
+
end
|
|
512
|
+
|
|
513
|
+
def tmux_input_schema
|
|
514
|
+
{ type: "object", properties: tmux_properties, required: %w[action] }
|
|
515
|
+
end
|
|
516
|
+
|
|
517
|
+
def tmux_properties
|
|
518
|
+
{}.merge(tmux_action_properties)
|
|
519
|
+
.merge(tmux_capture_properties)
|
|
520
|
+
.merge(tmux_spawn_properties)
|
|
521
|
+
end
|
|
522
|
+
|
|
523
|
+
def tmux_action_properties
|
|
524
|
+
{
|
|
525
|
+
action: { type: "string", enum: VALID_ACTIONS, description: "Action to perform" },
|
|
526
|
+
target: {
|
|
527
|
+
type: "string",
|
|
528
|
+
description: "Tmux pane target (e.g., 'session:window.pane'). " \
|
|
529
|
+
"Required for capture, status, approve, deny, send_input, kill."
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
end
|
|
533
|
+
|
|
534
|
+
def tmux_capture_properties
|
|
535
|
+
{
|
|
536
|
+
text: { type: "string", description: "Text to send (required for send_input)" },
|
|
537
|
+
lines: { type: "integer", description: "Number of lines to capture (default 100, for capture action)" }
|
|
538
|
+
}
|
|
539
|
+
end
|
|
540
|
+
|
|
541
|
+
def tmux_spawn_properties
|
|
542
|
+
{
|
|
543
|
+
prompt: { type: "string", description: "Prompt for new Claude session (required for spawn)" },
|
|
544
|
+
name: { type: "string", description: "Session name for spawn (auto-generated if omitted)" },
|
|
545
|
+
working_dir: { type: "string", description: "Working directory for spawn" },
|
|
546
|
+
session: {
|
|
547
|
+
type: "string",
|
|
548
|
+
description: "Existing tmux session to add a window to (for spawn). " \
|
|
549
|
+
"If omitted, creates a new session."
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
end
|
|
553
|
+
end
|
|
554
|
+
|
|
555
|
+
include PaneOperations
|
|
556
|
+
include SpawnValidation
|
|
557
|
+
include SpawnConfirmation
|
|
558
|
+
include SpawnPolling
|
|
559
|
+
include ToolDefinitionBuilder
|
|
560
|
+
end
|
|
561
|
+
end
|
|
562
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Earl
|
|
4
|
+
module Memory
|
|
5
|
+
# Builds the --append-system-prompt text from memory files.
|
|
6
|
+
# Combines SOUL.md, USER.md, and recent episodic memories into a single
|
|
7
|
+
# prompt string wrapped in <earl-memory> tags.
|
|
8
|
+
class PromptBuilder
|
|
9
|
+
def initialize(store:)
|
|
10
|
+
@store = store
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def build
|
|
14
|
+
body = build_sections.join("\n\n")
|
|
15
|
+
return nil if body.empty?
|
|
16
|
+
|
|
17
|
+
"<earl-memory>\n#{body}\n</earl-memory>\n\n" \
|
|
18
|
+
"You have persistent memory via save_memory and search_memory tools.\n" \
|
|
19
|
+
"Save important facts you learn. Search when you need to recall something."
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
private
|
|
23
|
+
|
|
24
|
+
def build_sections
|
|
25
|
+
sections = []
|
|
26
|
+
append_section(sections, "Core Identity", @store.soul)
|
|
27
|
+
append_section(sections, "User Notes", @store.users)
|
|
28
|
+
append_section(sections, "Recent Memories", @store.recent_memories)
|
|
29
|
+
sections
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def append_section(sections, heading, content)
|
|
33
|
+
stripped = content.to_s.strip
|
|
34
|
+
return if stripped.empty?
|
|
35
|
+
|
|
36
|
+
sections << "## #{heading}\n#{stripped}"
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|