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.
Files changed (89) hide show
  1. checksums.yaml +7 -0
  2. data/.ruby-version +1 -0
  3. data/CHANGELOG.md +40 -0
  4. data/CLAUDE.md +260 -0
  5. data/Gemfile +9 -0
  6. data/Gemfile.lock +177 -0
  7. data/LICENSE +21 -0
  8. data/README.md +106 -0
  9. data/Rakefile +11 -0
  10. data/bin/README.md +21 -0
  11. data/bin/ci +49 -0
  12. data/bin/claude-context +155 -0
  13. data/bin/claude-usage +110 -0
  14. data/bin/coverage +221 -0
  15. data/bin/rubocop +10 -0
  16. data/bin/watch-ci +198 -0
  17. data/config/earl-claude-home/.claude/CLAUDE.md +10 -0
  18. data/config/earl-claude-home/.claude/settings.json +34 -0
  19. data/earl-bot.gemspec +42 -0
  20. data/exe/earl +51 -0
  21. data/exe/earl-install +129 -0
  22. data/exe/earl-permission-server +39 -0
  23. data/lib/earl/claude_session/stats.rb +76 -0
  24. data/lib/earl/claude_session.rb +468 -0
  25. data/lib/earl/command_executor/constants.rb +53 -0
  26. data/lib/earl/command_executor/heartbeat_display.rb +54 -0
  27. data/lib/earl/command_executor/lifecycle_handler.rb +61 -0
  28. data/lib/earl/command_executor/session_handler.rb +126 -0
  29. data/lib/earl/command_executor/spawn_handler.rb +99 -0
  30. data/lib/earl/command_executor/stats_formatter.rb +66 -0
  31. data/lib/earl/command_executor/usage_handler.rb +132 -0
  32. data/lib/earl/command_executor.rb +128 -0
  33. data/lib/earl/command_parser.rb +57 -0
  34. data/lib/earl/config.rb +94 -0
  35. data/lib/earl/cron_parser.rb +105 -0
  36. data/lib/earl/formatting.rb +14 -0
  37. data/lib/earl/heartbeat_config.rb +101 -0
  38. data/lib/earl/heartbeat_scheduler/config_reloading.rb +64 -0
  39. data/lib/earl/heartbeat_scheduler/execution.rb +105 -0
  40. data/lib/earl/heartbeat_scheduler/heartbeat_state.rb +41 -0
  41. data/lib/earl/heartbeat_scheduler/lifecycle.rb +75 -0
  42. data/lib/earl/heartbeat_scheduler.rb +131 -0
  43. data/lib/earl/logging.rb +12 -0
  44. data/lib/earl/mattermost/api_client.rb +85 -0
  45. data/lib/earl/mattermost.rb +261 -0
  46. data/lib/earl/mcp/approval_handler.rb +304 -0
  47. data/lib/earl/mcp/config.rb +62 -0
  48. data/lib/earl/mcp/github_pat_handler.rb +450 -0
  49. data/lib/earl/mcp/handler_base.rb +13 -0
  50. data/lib/earl/mcp/heartbeat_handler.rb +310 -0
  51. data/lib/earl/mcp/memory_handler.rb +89 -0
  52. data/lib/earl/mcp/server.rb +123 -0
  53. data/lib/earl/mcp/tmux_handler.rb +562 -0
  54. data/lib/earl/memory/prompt_builder.rb +40 -0
  55. data/lib/earl/memory/store.rb +125 -0
  56. data/lib/earl/message_queue.rb +56 -0
  57. data/lib/earl/permission_config.rb +22 -0
  58. data/lib/earl/question_handler/question_posting.rb +58 -0
  59. data/lib/earl/question_handler.rb +116 -0
  60. data/lib/earl/runner/idle_management.rb +44 -0
  61. data/lib/earl/runner/lifecycle.rb +73 -0
  62. data/lib/earl/runner/message_handling.rb +121 -0
  63. data/lib/earl/runner/reaction_handling.rb +42 -0
  64. data/lib/earl/runner/response_lifecycle.rb +96 -0
  65. data/lib/earl/runner/service_builder.rb +48 -0
  66. data/lib/earl/runner/startup.rb +73 -0
  67. data/lib/earl/runner/thread_context_builder.rb +43 -0
  68. data/lib/earl/runner.rb +70 -0
  69. data/lib/earl/safari_automation.rb +497 -0
  70. data/lib/earl/session_manager/persistence.rb +46 -0
  71. data/lib/earl/session_manager/session_creation.rb +108 -0
  72. data/lib/earl/session_manager.rb +92 -0
  73. data/lib/earl/session_store.rb +84 -0
  74. data/lib/earl/streaming_response.rb +219 -0
  75. data/lib/earl/tmux/parsing.rb +80 -0
  76. data/lib/earl/tmux/processes.rb +34 -0
  77. data/lib/earl/tmux/sessions.rb +41 -0
  78. data/lib/earl/tmux.rb +122 -0
  79. data/lib/earl/tmux_monitor/alert_dispatcher.rb +53 -0
  80. data/lib/earl/tmux_monitor/output_analyzer.rb +35 -0
  81. data/lib/earl/tmux_monitor/permission_forwarder.rb +80 -0
  82. data/lib/earl/tmux_monitor/question_forwarder.rb +124 -0
  83. data/lib/earl/tmux_monitor.rb +249 -0
  84. data/lib/earl/tmux_session_store.rb +133 -0
  85. data/lib/earl/tool_input_formatter.rb +44 -0
  86. data/lib/earl/version.rb +5 -0
  87. data/lib/earl.rb +87 -0
  88. data/lib/tasks/.keep +1 -0
  89. metadata +248 -0
@@ -0,0 +1,468 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "claude_session/stats"
4
+
5
+ module Earl
6
+ # Manages a single Claude CLI subprocess, handling JSON-stream I/O
7
+ # and emitting text/completion callbacks as responses arrive.
8
+ class ClaudeSession
9
+ include Logging
10
+
11
+ def self.mcp_config_dir
12
+ @mcp_config_dir ||= File.join(Earl.config_root, "mcp")
13
+ end
14
+
15
+ def self.user_mcp_servers_path
16
+ @user_mcp_servers_path ||= File.join(Earl.config_root, "mcp_servers.json")
17
+ end
18
+
19
+ def process_pid
20
+ @runtime.process_state.process&.pid
21
+ end
22
+
23
+ # Tracks the Claude CLI subprocess and its I/O threads.
24
+ ProcessState = Struct.new(:process, :stdin, :reader_thread, :stderr_thread, :wait_thread, keyword_init: true) do
25
+ def write(payload)
26
+ stdin.write(payload)
27
+ stdin.flush
28
+ end
29
+
30
+ def join_io_threads
31
+ reader_thread&.join(3)
32
+ stderr_thread&.join(1)
33
+ end
34
+ end
35
+ # Holds text-streaming, tool-use, and completion callback procs.
36
+ Callbacks = Struct.new(:on_text, :on_complete, :on_tool_use, :on_system, keyword_init: true)
37
+ # Groups session launch options to keep instance variable count low.
38
+ Options = Struct.new(:permission_config, :resume, :working_dir, :username, :mcp_config_path, keyword_init: true)
39
+ # Groups mutable runtime state: subprocess, callbacks, and mutex.
40
+ RuntimeState = Struct.new(:process_state, :callbacks, :mutex, keyword_init: true)
41
+
42
+ # Removes MCP config files that don't match any active session ID.
43
+ def self.cleanup_mcp_configs(active_session_ids: [])
44
+ return unless Dir.exist?(mcp_config_dir)
45
+
46
+ active_set = Set.new(active_session_ids)
47
+ Dir.glob(File.join(mcp_config_dir, "earl-mcp-*.json")).each do |path|
48
+ session_id = File.basename(path).delete_prefix("earl-mcp-").delete_suffix(".json")
49
+ File.delete(path) unless active_set.include?(session_id)
50
+ end
51
+ end
52
+
53
+ def initialize(session_id: SecureRandom.uuid, permission_config: nil, mode: :new, working_dir: nil, username: nil)
54
+ @session_id = session_id
55
+ @options = Options.new(
56
+ permission_config: permission_config, resume: mode == :resume,
57
+ working_dir: working_dir, username: username
58
+ )
59
+ @runtime = RuntimeState.new(
60
+ process_state: ProcessState.new, callbacks: Callbacks.new, mutex: Mutex.new
61
+ )
62
+ @stats = default_stats
63
+ end
64
+
65
+ attr_reader :session_id, :stats
66
+
67
+ def on_text(&block)
68
+ @runtime.callbacks.on_text = block
69
+ end
70
+
71
+ def on_complete(&block)
72
+ @runtime.callbacks.on_complete = block
73
+ end
74
+
75
+ def on_tool_use(&block)
76
+ @runtime.callbacks.on_tool_use = block
77
+ end
78
+
79
+ def on_system(&block)
80
+ @runtime.callbacks.on_system = block
81
+ end
82
+
83
+ def send_message(text)
84
+ return warn_dead_session unless alive?
85
+
86
+ write_to_stdin(text)
87
+ @stats.begin_turn
88
+ log(:debug, "Sent message to Claude #{short_id}: #{text[0..60]}")
89
+ true
90
+ rescue IOError, Errno::EPIPE => error
91
+ log(:error, "Failed to write to Claude #{short_id}: #{error.message}")
92
+ false
93
+ end
94
+
95
+ private
96
+
97
+ def warn_dead_session
98
+ log(:warn, "Cannot send message to dead session #{short_id} — process not running")
99
+ false
100
+ end
101
+
102
+ def default_stats
103
+ Stats.new(
104
+ total_cost: 0.0, total_input_tokens: 0, total_output_tokens: 0,
105
+ turn_input_tokens: 0, turn_output_tokens: 0,
106
+ cache_read_tokens: 0, cache_creation_tokens: 0
107
+ )
108
+ end
109
+
110
+ def write_to_stdin(text)
111
+ payload = "#{JSON.generate({ type: "user", message: { role: "user", content: text } })}\n"
112
+ @runtime.mutex.synchronize { @runtime.process_state.write(payload) }
113
+ end
114
+
115
+ def short_id
116
+ @session_id[0..7]
117
+ end
118
+
119
+ # Process lifecycle management: start, kill, and signal handling.
120
+ module ProcessManagement
121
+ def start
122
+ stdin, stdout, stderr, wait_thread = open_process
123
+ @runtime.process_state = ProcessState.new(process: wait_thread, stdin: stdin, wait_thread: wait_thread)
124
+
125
+ log(:info, "Spawning Claude session #{@session_id} — resume with: claude --resume #{@session_id}")
126
+ spawn_io_threads(stdout, stderr)
127
+ end
128
+
129
+ def alive?
130
+ @runtime.process_state.process&.alive?
131
+ end
132
+
133
+ def kill
134
+ return unless (process = @runtime.process_state.process)
135
+
136
+ log(:info, "Killing Claude session #{short_id} (pid=#{process.pid})")
137
+ terminate_process
138
+ close_stdin
139
+ join_threads
140
+ remove_mcp_config
141
+ end
142
+
143
+ private
144
+
145
+ def open_process
146
+ working_dir = @options.working_dir || earl_project_dir
147
+ env = { "TMUX" => nil, "TMUX_PANE" => nil }
148
+ Open3.popen3(env, *cli_args, chdir: working_dir)
149
+ end
150
+
151
+ def earl_project_dir
152
+ ENV.fetch("EARL_CLAUDE_HOME", File.join(Earl.config_root, "claude-home"))
153
+ end
154
+
155
+ def join_threads
156
+ @runtime.process_state.join_io_threads
157
+ end
158
+
159
+ def terminate_process
160
+ pid = @runtime.process_state.process.pid
161
+ Process.kill("INT", pid)
162
+ sleep 0.1
163
+ escalate_signal(pid)
164
+ rescue Errno::ESRCH
165
+ # Process already gone
166
+ end
167
+
168
+ def escalate_signal(pid)
169
+ 2.times do
170
+ return unless @runtime.process_state.process.alive?
171
+
172
+ sleep 1
173
+ end
174
+ Process.kill("TERM", pid)
175
+ rescue Errno::ESRCH
176
+ # Process exited between alive? check and kill
177
+ end
178
+
179
+ def close_stdin
180
+ @runtime.process_state.stdin&.close
181
+ rescue IOError
182
+ # Already closed
183
+ end
184
+
185
+ def remove_mcp_config
186
+ path = File.join(self.class.mcp_config_dir, "earl-mcp-#{@session_id}.json")
187
+ FileUtils.rm_f(path)
188
+ end
189
+ end
190
+
191
+ # Builds the CLI argument list for spawning the Claude process.
192
+ module CliArgBuilder
193
+ private
194
+
195
+ def cli_args
196
+ ["claude", "--input-format", "stream-json", "--output-format", "stream-json", "--verbose",
197
+ *model_args, *session_args, *permission_args, *system_prompt_args]
198
+ end
199
+
200
+ def model_args
201
+ model = ENV.fetch("EARL_MODEL", nil)
202
+ model ? ["--model", model] : []
203
+ end
204
+
205
+ def session_args
206
+ @options.resume ? ["--resume", @session_id] : ["--session-id", @session_id]
207
+ end
208
+
209
+ def permission_args
210
+ return ["--dangerously-skip-permissions"] unless @options.permission_config
211
+
212
+ ["--permission-prompt-tool", "mcp__earl__permission_prompt",
213
+ "--mcp-config", mcp_config_path]
214
+ end
215
+
216
+ def system_prompt_args
217
+ prompt = Memory::PromptBuilder.new(store: Memory::Store.new).build
218
+ prompt ? ["--append-system-prompt", prompt] : []
219
+ end
220
+
221
+ def mcp_config_path
222
+ @options.mcp_config_path ||= write_mcp_config
223
+ end
224
+
225
+ def write_mcp_config
226
+ all_servers = load_user_mcp_servers.merge(build_earl_server_entry)
227
+ json = JSON.generate({ mcpServers: all_servers })
228
+ write_mcp_config_file(json)
229
+ end
230
+
231
+ def build_earl_server_entry
232
+ {
233
+ earl: {
234
+ command: File.expand_path("../../exe/earl-permission-server", __dir__),
235
+ args: [],
236
+ env: @options.permission_config.merge(
237
+ "EARL_CURRENT_USERNAME" => @options.username || ""
238
+ )
239
+ }
240
+ }
241
+ end
242
+
243
+ def load_user_mcp_servers
244
+ path = self.class.user_mcp_servers_path
245
+ return {} unless File.exist?(path)
246
+
247
+ parsed = JSON.parse(File.read(path))
248
+ symbolize_mcp_servers(parsed.fetch("mcpServers", nil))
249
+ rescue JSON::ParserError => error
250
+ log(:warn, "Malformed #{path}: #{error.message}")
251
+ {}
252
+ end
253
+
254
+ def symbolize_mcp_servers(servers)
255
+ servers.is_a?(Hash) ? servers.transform_keys(&:to_sym) : {}
256
+ end
257
+
258
+ def write_mcp_config_file(json)
259
+ path = mcp_config_file_path
260
+ FileUtils.mkdir_p(self.class.mcp_config_dir, mode: 0o700)
261
+ write_exclusive(path, json)
262
+ rescue Errno::EEXIST
263
+ write_overwrite(path, json)
264
+ end
265
+
266
+ def write_exclusive(path, content)
267
+ File.open(path, File::CREAT | File::EXCL | File::WRONLY, 0o600) { |file| file.write(content) }
268
+ path
269
+ end
270
+
271
+ def write_overwrite(path, content)
272
+ File.open(path, File::WRONLY | File::TRUNC, 0o600) { |file| file.write(content) }
273
+ path
274
+ end
275
+
276
+ def mcp_config_file_path
277
+ File.join(self.class.mcp_config_dir, "earl-mcp-#{@session_id}.json")
278
+ end
279
+ end
280
+
281
+ # Event/IO processing methods extracted to reduce class method count.
282
+ module EventProcessing
283
+ private
284
+
285
+ def spawn_io_threads(stdout, stderr)
286
+ ps = @runtime.process_state
287
+ ps.reader_thread = Thread.new { read_stdout(stdout) }
288
+ ps.stderr_thread = Thread.new { read_stderr(stderr) }
289
+ end
290
+
291
+ def read_stdout(stdout)
292
+ stdout.each_line do |line|
293
+ process_line(line.strip)
294
+ end
295
+ rescue IOError
296
+ log(:debug, "Claude stdout stream closed (session #{short_id})")
297
+ end
298
+
299
+ def process_line(line)
300
+ return if line.empty?
301
+
302
+ event = parse_json(line)
303
+ handle_event(event) if event
304
+ end
305
+
306
+ def parse_json(line)
307
+ JSON.parse(line)
308
+ rescue JSON::ParserError => error
309
+ log(:warn, "Unparsable Claude stdout (session #{short_id}): #{line[0..200]} — #{error.message}")
310
+ nil
311
+ end
312
+
313
+ def read_stderr(stderr)
314
+ stderr.each_line do |line|
315
+ log(:debug, "Claude stderr: #{line.strip}")
316
+ end
317
+ rescue IOError
318
+ log(:debug, "Claude stderr stream closed (session #{short_id})")
319
+ end
320
+
321
+ def handle_event(event)
322
+ case event["type"]
323
+ when "system" then handle_system_event(event)
324
+ when "assistant" then handle_assistant_event(event)
325
+ when "result" then handle_result_event(event)
326
+ end
327
+ end
328
+
329
+ def handle_system_event(event)
330
+ subtype = event["subtype"]
331
+ log(:debug, "Claude system: #{subtype}")
332
+ message = event["message"]
333
+ @runtime.callbacks.on_system&.call(subtype: subtype, message: message) if message
334
+ end
335
+
336
+ def handle_assistant_event(event)
337
+ content = event.dig("message", "content")
338
+ return unless content.is_a?(Array)
339
+
340
+ emit_text_content(content)
341
+ emit_tool_use_blocks(content)
342
+ end
343
+
344
+ def emit_text_content(content)
345
+ text = content.filter_map { |item| item["text"] if item["type"] == "text" }.join
346
+ return if text.empty?
347
+
348
+ @stats.first_token_at ||= Time.now
349
+ @runtime.callbacks.on_text&.call(text)
350
+ end
351
+
352
+ def emit_tool_use_blocks(content)
353
+ content.each do |item|
354
+ emit_single_tool_use(item) if item["type"] == "tool_use"
355
+ end
356
+ end
357
+
358
+ def emit_single_tool_use(item)
359
+ tool_id, tool_name, tool_input = item.values_at("id", "name", "input")
360
+ @runtime.callbacks.on_tool_use&.call(id: tool_id, name: tool_name, input: tool_input)
361
+ end
362
+ end
363
+
364
+ # Processes result events and updates session statistics.
365
+ module ResultProcessing
366
+ private
367
+
368
+ def handle_result_event(event)
369
+ @stats.complete_at = Time.now
370
+ update_stats_from_result(event)
371
+ log(:info, format_result_log)
372
+ @runtime.callbacks.on_complete&.call(self)
373
+ end
374
+
375
+ def update_stats_from_result(event)
376
+ cost = event["total_cost_usd"]
377
+ @stats.total_cost = cost if cost
378
+ extract_usage(event["usage"])
379
+ extract_model_usage(event["modelUsage"])
380
+ end
381
+
382
+ def extract_usage(usage)
383
+ return unless usage.is_a?(Hash)
384
+
385
+ input, output, cache_read, cache_create = usage.values_at(
386
+ "input_tokens", "output_tokens", "cache_read_input_tokens", "cache_creation_input_tokens"
387
+ )
388
+ @stats.turn_input_tokens = input || 0
389
+ @stats.turn_output_tokens = output || 0
390
+ @stats.cache_read_tokens = cache_read || 0
391
+ @stats.cache_creation_tokens = cache_create || 0
392
+ end
393
+
394
+ def extract_model_usage(model_usage)
395
+ return unless model_usage.is_a?(Hash)
396
+
397
+ apply_hash_entries(model_usage)
398
+ end
399
+
400
+ def apply_hash_entries(model_usage)
401
+ entries = model_usage.select { |_, val| val.is_a?(Hash) }
402
+ apply_primary_model_stats(entries) unless entries.empty?
403
+ end
404
+
405
+ def apply_primary_model_stats(entries)
406
+ primary_id, primary_data = entries.max_by { |_, data| data["contextWindow"] || 0 }
407
+ apply_model_stats(primary_id, primary_data, entries)
408
+ end
409
+
410
+ def apply_model_stats(model_id, primary_data, entries)
411
+ @stats.model_id = model_id
412
+ totals = entries.each_with_object({ input: 0, output: 0 }) do |(_, data), acc|
413
+ acc[:input] += data["inputTokens"] || 0
414
+ acc[:output] += data["outputTokens"] || 0
415
+ end
416
+ @stats.total_input_tokens = totals[:input]
417
+ @stats.total_output_tokens = totals[:output]
418
+ context = primary_data["contextWindow"]
419
+ @stats.context_window = context if context
420
+ end
421
+
422
+ def format_result_log
423
+ model = @stats.model_id
424
+ parts = [
425
+ "Claude result:",
426
+ format_token_counts,
427
+ format_context_usage,
428
+ format_timing,
429
+ format_cost,
430
+ ("model=#{model}" if model)
431
+ ]
432
+ parts.compact.join(" | ")
433
+ end
434
+
435
+ def format_token_counts
436
+ turn_in = @stats.turn_input_tokens
437
+ turn_out = @stats.turn_output_tokens
438
+ total = @stats.total_input_tokens + @stats.total_output_tokens
439
+ "#{total} total tokens (turn: in:#{turn_in} out:#{turn_out})"
440
+ end
441
+
442
+ def format_context_usage
443
+ pct = @stats.context_percent
444
+ return nil unless pct
445
+
446
+ format("%.0f%% context used", pct)
447
+ end
448
+
449
+ def format_timing
450
+ ttft = @stats.time_to_first_token
451
+ tps = @stats.tokens_per_second
452
+ parts = []
453
+ parts << format("TTFT: %.1fs", ttft) if ttft
454
+ parts << format("%.0f tok/s", tps) if tps
455
+ parts.empty? ? nil : parts.join(" ")
456
+ end
457
+
458
+ def format_cost
459
+ format("cost=$%.4f", @stats.total_cost)
460
+ end
461
+ end
462
+
463
+ include ProcessManagement
464
+ include CliArgBuilder
465
+ include EventProcessing
466
+ include ResultProcessing
467
+ end
468
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Earl
4
+ class CommandExecutor
5
+ # Constants shared across CommandExecutor: help table, dispatch map, scripts.
6
+ module Constants
7
+ HELP_TABLE = <<~HELP
8
+ | Command | Description |
9
+ |---------|-------------|
10
+ | `!help` | Show this help table |
11
+ | `!stats` | Show session stats (tokens, context, cost) |
12
+ | `!usage` | Show Claude subscription usage limits |
13
+ | `!context` | Show context window usage for current session |
14
+ | `!stop` | Kill current session |
15
+ | `!escape` | Send SIGINT to Claude (interrupt) |
16
+ | `!kill` | Force kill session |
17
+ | `!compact` | Compact Claude's context |
18
+ | `!cd <path>` | Set working directory for next session |
19
+ | `!permissions` | Show current permission mode |
20
+ | `!heartbeats` | Show heartbeat schedule status |
21
+ | `!sessions` | List all tmux sessions |
22
+ | `!session <name>` | Capture and show tmux pane output |
23
+ | `!session <name> status` | AI-summarize session state |
24
+ | `!session <name> kill` | Kill tmux session |
25
+ | `!session <name> nudge` | Send nudge message to session |
26
+ | `!session <name> approve` | Approve pending permission |
27
+ | `!session <name> deny` | Deny pending permission |
28
+ | `!session <name> "text"` | Send input to tmux session |
29
+ | `!update` | Pull latest code + bundle install, then restart |
30
+ | `!restart` | Restart EARL (pulls latest code in prod) |
31
+ | `!spawn "prompt" [--name N] [--dir D]` | Spawn Claude in a new tmux session |
32
+ HELP
33
+
34
+ PASSTHROUGH_COMMANDS = { compact: "/compact" }.freeze
35
+
36
+ DISPATCH = {
37
+ help: :handle_help, stats: :handle_stats, stop: :handle_stop,
38
+ escape: :handle_escape, kill: :handle_kill, cd: :handle_cd,
39
+ permissions: :handle_permissions, heartbeats: :handle_heartbeats,
40
+ usage: :handle_usage, context: :handle_context,
41
+ sessions: :handle_sessions, session_show: :handle_session_show,
42
+ session_status: :handle_session_status, session_kill: :handle_session_kill,
43
+ session_nudge: :handle_session_nudge, session_approve: :handle_session_approve,
44
+ session_deny: :handle_session_deny, session_input: :handle_session_input,
45
+ update: :handle_update, restart: :handle_restart,
46
+ spawn: :handle_spawn
47
+ }.freeze
48
+
49
+ USAGE_SCRIPT = File.expand_path("../../../bin/claude-usage", __dir__)
50
+ CONTEXT_SCRIPT = File.expand_path("../../../bin/claude-context", __dir__)
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Earl
4
+ class CommandExecutor
5
+ # Formats and displays heartbeat schedule status for the !heartbeats command.
6
+ module HeartbeatDisplay
7
+ private
8
+
9
+ def handle_heartbeats(ctx)
10
+ scheduler = @deps.heartbeat_scheduler
11
+ return reply(ctx, "Heartbeat scheduler not configured.") unless scheduler
12
+
13
+ statuses = scheduler.status
14
+ return reply(ctx, "No heartbeats configured.") if statuses.empty?
15
+
16
+ reply(ctx, format_heartbeats(statuses))
17
+ end
18
+
19
+ def format_heartbeats(statuses)
20
+ header = [
21
+ "#### \u{1FAC0} Heartbeat Status",
22
+ "| Name | Next Run | Last Run | Runs | Status |",
23
+ "|------|----------|----------|------|--------|"
24
+ ]
25
+ rows = statuses.map { |entry| format_heartbeat_row(entry) }
26
+ (header + rows).join("\n")
27
+ end
28
+
29
+ def format_heartbeat_row(status)
30
+ name, run_count, next_at, last_at = status.values_at(:name, :run_count, :next_run_at, :last_run_at)
31
+ next_run = format_time(next_at)
32
+ last_run = format_time(last_at)
33
+ state = heartbeat_status_label(status)
34
+ "| #{name} | #{next_run} | #{last_run} | #{run_count} | #{state} |"
35
+ end
36
+
37
+ def heartbeat_status_label(status)
38
+ if status[:running]
39
+ "\u{1F7E2} Running"
40
+ elsif status[:last_error]
41
+ "\u{1F534} Error"
42
+ else
43
+ "\u26AA Idle"
44
+ end
45
+ end
46
+
47
+ def format_time(time)
48
+ return "\u2014" unless time
49
+
50
+ time.strftime("%Y-%m-%d %H:%M")
51
+ end
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Earl
4
+ class CommandExecutor
5
+ # Handles session lifecycle commands: !stop, !escape, !kill, !cd.
6
+ module LifecycleHandler
7
+ private
8
+
9
+ def handle_stop(ctx)
10
+ @deps.session_manager.stop_session(ctx.thread_id)
11
+ reply(ctx, ":stop_sign: Session stopped.")
12
+ end
13
+
14
+ def handle_escape(ctx)
15
+ session = @deps.session_manager.get(ctx.thread_id)
16
+ if session&.process_pid
17
+ Process.kill("INT", session.process_pid)
18
+ reply(ctx, ":warning: Sent SIGINT to Claude.")
19
+ else
20
+ reply(ctx, "No active session to interrupt.")
21
+ end
22
+ rescue Errno::ESRCH
23
+ reply(ctx, "Process already exited.")
24
+ end
25
+
26
+ def handle_kill(ctx)
27
+ session = @deps.session_manager.get(ctx.thread_id)
28
+ if session&.process_pid
29
+ Process.kill("KILL", session.process_pid)
30
+ cleanup_and_reply(ctx, ":skull: Session force killed.")
31
+ else
32
+ reply(ctx, "No active session to kill.")
33
+ end
34
+ rescue Errno::ESRCH
35
+ cleanup_and_reply(ctx, "Process already exited, session cleaned up.")
36
+ end
37
+
38
+ def handle_cd(ctx)
39
+ cleaned = ctx.arg.to_s.strip
40
+ return reply(ctx, ":x: Usage: `!cd <path>`") if cleaned.empty?
41
+
42
+ expanded = File.expand_path(cleaned)
43
+ apply_working_dir(ctx, expanded)
44
+ end
45
+
46
+ def apply_working_dir(ctx, expanded)
47
+ if Dir.exist?(expanded)
48
+ @working_dirs[ctx.thread_id] = expanded
49
+ reply(ctx, ":file_folder: Working directory set to `#{expanded}` (applies to next new session)")
50
+ else
51
+ reply(ctx, ":x: Directory not found: `#{expanded}`")
52
+ end
53
+ end
54
+
55
+ def cleanup_and_reply(ctx, message)
56
+ @deps.session_manager.stop_session(ctx.thread_id)
57
+ reply(ctx, message)
58
+ end
59
+ end
60
+ end
61
+ end