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,125 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Earl
|
|
4
|
+
module Memory
|
|
5
|
+
# Pure Ruby file I/O for reading, writing, and searching memory files.
|
|
6
|
+
# Manages markdown-based persistent memory in <config_root>/memory/
|
|
7
|
+
# with SOUL.md (personality), USER.md (user notes), and YYYY-MM-DD.md (daily episodic).
|
|
8
|
+
class Store
|
|
9
|
+
def self.default_dir
|
|
10
|
+
@default_dir ||= File.join(Earl.config_root, "memory")
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def initialize(dir: self.class.default_dir)
|
|
14
|
+
@dir = dir
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def soul
|
|
18
|
+
read_file("SOUL.md")
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def users
|
|
22
|
+
read_file("USER.md")
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def recent_memories(days: 7, limit: 50)
|
|
26
|
+
entries = collect_entries(days)
|
|
27
|
+
entries.last(limit).join("\n")
|
|
28
|
+
rescue Errno::ENOENT
|
|
29
|
+
""
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def save(username:, text:)
|
|
33
|
+
FileUtils.mkdir_p(@dir)
|
|
34
|
+
now = Time.now.utc
|
|
35
|
+
today = now.strftime("%Y-%m-%d")
|
|
36
|
+
path = File.join(@dir, "#{today}.md")
|
|
37
|
+
entry = "- **#{now.strftime("%H:%M UTC")}** | `@#{username}` | #{text}"
|
|
38
|
+
|
|
39
|
+
write_with_header(path, today, entry)
|
|
40
|
+
{ file: path, entry: entry }
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def search(query:, limit: 20)
|
|
44
|
+
pattern = Regexp.new(Regexp.escape(query), Regexp::IGNORECASE)
|
|
45
|
+
grep_files(pattern, limit)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
private
|
|
49
|
+
|
|
50
|
+
def collect_entries(days)
|
|
51
|
+
paths = date_files_descending(days)
|
|
52
|
+
paths.flat_map { |path| entries_from_file(path) }
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def entries_from_file(path)
|
|
56
|
+
File.readlines(path).filter_map do |line|
|
|
57
|
+
stripped = line.strip
|
|
58
|
+
stripped unless stripped.empty? || stripped.start_with?("#")
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def grep_files(pattern, limit)
|
|
63
|
+
search_files.each_with_object([]) do |path, matches|
|
|
64
|
+
file_matches = matches_in_file(path, pattern)
|
|
65
|
+
matches.concat(file_matches)
|
|
66
|
+
break matches if matches.size >= limit
|
|
67
|
+
rescue Errno::ENOENT
|
|
68
|
+
next
|
|
69
|
+
end.first(limit)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def matches_in_file(path, pattern)
|
|
73
|
+
basename = File.basename(path)
|
|
74
|
+
File.readlines(path).filter_map do |line|
|
|
75
|
+
{ file: basename, line: line.strip } if pattern.match?(line)
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def read_file(name)
|
|
80
|
+
path = file_at(name)
|
|
81
|
+
return "" unless path
|
|
82
|
+
|
|
83
|
+
File.read(path)
|
|
84
|
+
rescue Errno::ENOENT
|
|
85
|
+
""
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def file_at(name)
|
|
89
|
+
path = File.join(@dir, name)
|
|
90
|
+
path if File.exist?(path)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def date_files_descending(days)
|
|
94
|
+
today = Date.today
|
|
95
|
+
(0...days).filter_map do |offset|
|
|
96
|
+
file_at((today - offset).strftime("%Y-%m-%d.md"))
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def search_files
|
|
101
|
+
priority = %w[SOUL.md USER.md].filter_map { |name| file_at(name) }
|
|
102
|
+
date_files = Dir.glob(File.join(@dir, "????-??-??.md")).reverse
|
|
103
|
+
priority + date_files
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def write_with_header(path, today, entry)
|
|
107
|
+
content = build_entry_content(path, today, entry)
|
|
108
|
+
append_locked(path, content)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def build_entry_content(path, today, entry)
|
|
112
|
+
needs_header = !File.exist?(path) || File.empty?(path)
|
|
113
|
+
header = needs_header ? "# Memories for #{today}\n\n" : ""
|
|
114
|
+
"#{header}#{entry}\n"
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def append_locked(path, content)
|
|
118
|
+
File.open(path, "a") do |file|
|
|
119
|
+
file.flock(File::LOCK_EX)
|
|
120
|
+
file.write(content)
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Earl
|
|
4
|
+
# Thread-safe message queue that tracks which threads are actively
|
|
5
|
+
# processing and buffers messages for busy threads.
|
|
6
|
+
class MessageQueue
|
|
7
|
+
include Logging
|
|
8
|
+
|
|
9
|
+
def initialize
|
|
10
|
+
@processing_threads = Set.new
|
|
11
|
+
@pending_messages = {}
|
|
12
|
+
@mutex = Mutex.new
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def try_claim(thread_id)
|
|
16
|
+
@mutex.synchronize do
|
|
17
|
+
if @processing_threads.include?(thread_id)
|
|
18
|
+
false
|
|
19
|
+
else
|
|
20
|
+
@processing_threads << thread_id
|
|
21
|
+
true
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def enqueue(thread_id, text)
|
|
27
|
+
@mutex.synchronize do
|
|
28
|
+
queue = (@pending_messages[thread_id] ||= [])
|
|
29
|
+
queue << text
|
|
30
|
+
log(:debug, "Queued message for busy thread #{thread_id[0..7]}")
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def dequeue(thread_id)
|
|
35
|
+
@mutex.synchronize do
|
|
36
|
+
msgs = @pending_messages[thread_id]
|
|
37
|
+
if msgs && !msgs.empty?
|
|
38
|
+
msgs.shift
|
|
39
|
+
else
|
|
40
|
+
@processing_threads.delete(thread_id)
|
|
41
|
+
nil
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Unconditionally releases the processing claim for a thread,
|
|
47
|
+
# discarding any pending messages. Use when the session is dead
|
|
48
|
+
# and queued messages cannot be delivered.
|
|
49
|
+
def release(thread_id)
|
|
50
|
+
@mutex.synchronize do
|
|
51
|
+
@processing_threads.delete(thread_id)
|
|
52
|
+
@pending_messages.delete(thread_id)
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Earl
|
|
4
|
+
# Shared permission configuration builder for MCP permission server.
|
|
5
|
+
# Used by both SessionManager (user-initiated) and HeartbeatScheduler (automated).
|
|
6
|
+
module PermissionConfig
|
|
7
|
+
private
|
|
8
|
+
|
|
9
|
+
def build_permission_env(config, channel_id:, thread_id: "")
|
|
10
|
+
return nil if config.skip_permissions?
|
|
11
|
+
|
|
12
|
+
{
|
|
13
|
+
"PLATFORM_URL" => config.mattermost_url,
|
|
14
|
+
"PLATFORM_TOKEN" => config.bot_token,
|
|
15
|
+
"PLATFORM_CHANNEL_ID" => channel_id,
|
|
16
|
+
"PLATFORM_THREAD_ID" => thread_id,
|
|
17
|
+
"PLATFORM_BOT_ID" => config.bot_id,
|
|
18
|
+
"ALLOWED_USERS" => config.allowed_users.join(",")
|
|
19
|
+
}
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Earl
|
|
4
|
+
class QuestionHandler
|
|
5
|
+
# Handles building question messages, posting them, and managing reactions.
|
|
6
|
+
module QuestionPosting
|
|
7
|
+
private
|
|
8
|
+
|
|
9
|
+
def post_current_question(state)
|
|
10
|
+
question = state.current_question
|
|
11
|
+
message = build_question_message(question)
|
|
12
|
+
|
|
13
|
+
post_id = create_question_post(state.channel_id, state.thread_id, message)
|
|
14
|
+
register_question_post(state, post_id, (question["options"] || []).size)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def create_question_post(channel_id, thread_id, message)
|
|
18
|
+
result = @mattermost.create_post(channel_id: channel_id, message: message, root_id: thread_id)
|
|
19
|
+
result["id"]
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def build_question_message(question)
|
|
23
|
+
options = question["options"] || []
|
|
24
|
+
lines = [":question: **#{question["question"]}**"]
|
|
25
|
+
options.each_with_index do |opt, index|
|
|
26
|
+
emoji = EMOJI_NUMBERS[index]
|
|
27
|
+
label = opt["label"] || opt.to_s
|
|
28
|
+
desc = opt["description"]
|
|
29
|
+
lines << ":#{emoji}: #{label}#{" — #{desc}" if desc}"
|
|
30
|
+
end
|
|
31
|
+
lines.join("\n")
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def register_question_post(state, post_id, option_count)
|
|
35
|
+
if post_id
|
|
36
|
+
state.current_post_id = post_id
|
|
37
|
+
add_emoji_options(post_id, option_count)
|
|
38
|
+
@mutex.synchronize { @pending_questions[post_id] = state }
|
|
39
|
+
true
|
|
40
|
+
else
|
|
41
|
+
false
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def add_emoji_options(post_id, count)
|
|
46
|
+
count.times do |index|
|
|
47
|
+
@mattermost.add_reaction(post_id: post_id, emoji_name: EMOJI_NUMBERS[index])
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def delete_question_post(post_id)
|
|
52
|
+
@mattermost.delete_post(post_id: post_id)
|
|
53
|
+
rescue StandardError => error
|
|
54
|
+
log(:warn, "Failed to delete question post #{post_id}: #{error.message}")
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "question_handler/question_posting"
|
|
4
|
+
|
|
5
|
+
module Earl
|
|
6
|
+
# Handles AskUserQuestion tool_use events from Claude by posting numbered
|
|
7
|
+
# options to Mattermost and collecting answers via emoji reactions.
|
|
8
|
+
class QuestionHandler
|
|
9
|
+
include Logging
|
|
10
|
+
include QuestionPosting
|
|
11
|
+
|
|
12
|
+
EMOJI_NUMBERS = %w[one two three four].freeze
|
|
13
|
+
EMOJI_MAP = { "one" => 0, "two" => 1, "three" => 2, "four" => 3 }.freeze
|
|
14
|
+
|
|
15
|
+
# Tracks in-progress question flow: which tool_use triggered it, the list of
|
|
16
|
+
# questions, collected answers, and the Mattermost post/thread IDs.
|
|
17
|
+
QuestionState = Struct.new(:tool_use_id, :questions, :answers, :current_index,
|
|
18
|
+
:current_post_id, :thread_id, :channel_id, keyword_init: true) do
|
|
19
|
+
def current_question
|
|
20
|
+
questions[current_index]
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def all_questions_answered?
|
|
24
|
+
current_index >= questions.size
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def initialize(mattermost:)
|
|
29
|
+
@mattermost = mattermost
|
|
30
|
+
@pending_questions = {} # post_id -> QuestionState
|
|
31
|
+
@mutex = Mutex.new
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def handle_tool_use(thread_id:, tool_use:, channel_id: nil)
|
|
35
|
+
name, input, tool_use_id = tool_use.values_at(:name, :input, :id)
|
|
36
|
+
return nil unless name == "AskUserQuestion"
|
|
37
|
+
|
|
38
|
+
questions = input["questions"] || []
|
|
39
|
+
return nil if questions.empty?
|
|
40
|
+
|
|
41
|
+
state = QuestionState.new(
|
|
42
|
+
tool_use_id: tool_use_id, questions: questions, answers: {},
|
|
43
|
+
current_index: 0, thread_id: thread_id, channel_id: channel_id
|
|
44
|
+
)
|
|
45
|
+
start_question_flow(state, tool_use_id)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def handle_reaction(post_id:, emoji_name:)
|
|
49
|
+
state = fetch_pending(post_id)
|
|
50
|
+
return nil unless state
|
|
51
|
+
|
|
52
|
+
selected = resolve_selected_option(state, emoji_name)
|
|
53
|
+
return nil unless selected
|
|
54
|
+
|
|
55
|
+
accept_answer(state, post_id, selected)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
private
|
|
59
|
+
|
|
60
|
+
def start_question_flow(state, tool_use_id)
|
|
61
|
+
unless post_current_question(state)
|
|
62
|
+
log(:error, "Failed to post question for tool_use #{tool_use_id}, returning error answer")
|
|
63
|
+
return { tool_use_id: tool_use_id, answer_text: "Failed to post question to chat" }
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
{ tool_use_id: tool_use_id }
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def fetch_pending(post_id)
|
|
70
|
+
@mutex.synchronize { @pending_questions[post_id] }
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def release_pending(post_id)
|
|
74
|
+
@mutex.synchronize { @pending_questions.delete(post_id) }
|
|
75
|
+
delete_question_post(post_id)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def accept_answer(state, post_id, selected)
|
|
79
|
+
index = state.current_index
|
|
80
|
+
record_answer(state, state.questions[index], selected)
|
|
81
|
+
release_pending(post_id)
|
|
82
|
+
advance_question(state, index)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def advance_question(state, index)
|
|
86
|
+
next_index = index + 1
|
|
87
|
+
state.current_index = next_index
|
|
88
|
+
return build_answer_json(state) unless next_index < state.questions.size
|
|
89
|
+
|
|
90
|
+
post_current_question(state)
|
|
91
|
+
nil
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def resolve_selected_option(state, emoji_name)
|
|
95
|
+
answer_index = EMOJI_MAP[emoji_name]
|
|
96
|
+
return nil unless answer_index
|
|
97
|
+
|
|
98
|
+
options = state.questions[state.current_index]["options"] || []
|
|
99
|
+
answer_index < options.size ? options[answer_index] : nil
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def record_answer(state, question, selected_option)
|
|
103
|
+
state.answers[question["question"]] = selected_option["label"] || selected_option.to_s
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def build_answer_json(state)
|
|
107
|
+
answers = state.questions.map.with_index do |question, index|
|
|
108
|
+
q_text = question["question"]
|
|
109
|
+
answer = state.answers[q_text]
|
|
110
|
+
"Question #{index + 1}: #{q_text}\nAnswer: #{answer}"
|
|
111
|
+
end.join("\n\n")
|
|
112
|
+
|
|
113
|
+
{ tool_use_id: state.tool_use_id, answer_text: answers }
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Earl
|
|
4
|
+
class Runner
|
|
5
|
+
# Idle session management.
|
|
6
|
+
module IdleManagement
|
|
7
|
+
private
|
|
8
|
+
|
|
9
|
+
def start_idle_checker
|
|
10
|
+
@app_state.idle_checker_thread = Thread.new do
|
|
11
|
+
loop do
|
|
12
|
+
sleep IDLE_CHECK_INTERVAL
|
|
13
|
+
check_idle_sessions
|
|
14
|
+
rescue StandardError => error
|
|
15
|
+
log(:error, "Idle checker error: #{error.message}")
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def check_idle_sessions
|
|
21
|
+
@services.session_store.load.each do |thread_id, persisted|
|
|
22
|
+
stop_if_idle(thread_id, persisted)
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def stop_if_idle(thread_id, persisted)
|
|
27
|
+
return if persisted.is_paused
|
|
28
|
+
|
|
29
|
+
idle_seconds = seconds_since_activity(persisted.last_activity_at)
|
|
30
|
+
return unless idle_seconds
|
|
31
|
+
return unless idle_seconds > IDLE_TIMEOUT
|
|
32
|
+
|
|
33
|
+
log(:info, "Stopping idle session for thread #{thread_id[0..7]} (idle #{(idle_seconds / 60).round}min)")
|
|
34
|
+
@services.session_manager.stop_session(thread_id)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def seconds_since_activity(last_activity_at)
|
|
38
|
+
return nil unless last_activity_at
|
|
39
|
+
|
|
40
|
+
Time.now - Time.parse(last_activity_at)
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rbconfig"
|
|
4
|
+
|
|
5
|
+
module Earl
|
|
6
|
+
class Runner
|
|
7
|
+
# Shutdown, restart, and process lifecycle management.
|
|
8
|
+
module Lifecycle
|
|
9
|
+
private
|
|
10
|
+
|
|
11
|
+
def begin_shutdown(&)
|
|
12
|
+
return if @app_state.shutting_down
|
|
13
|
+
|
|
14
|
+
@app_state.shutting_down = true
|
|
15
|
+
@app_state.shutdown_thread = Thread.new(&)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def handle_shutdown_signal
|
|
19
|
+
begin_shutdown { shutdown }
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def handle_restart_signal
|
|
23
|
+
@app_state.pending_restart = true
|
|
24
|
+
begin_shutdown { restart }
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def setup_close_handler
|
|
28
|
+
@services.mattermost.on_close { handle_shutdown_signal }
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def shutdown
|
|
32
|
+
log(:info, "Shutting down...")
|
|
33
|
+
@app_state.idle_checker_thread&.kill
|
|
34
|
+
@services.heartbeat_scheduler.stop
|
|
35
|
+
@services.tmux_monitor.stop
|
|
36
|
+
@services.session_manager.pause_all
|
|
37
|
+
log(:info, "Goodbye!")
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def restart
|
|
41
|
+
updating = @app_state.pending_update
|
|
42
|
+
log(:info, updating ? "Updating EARL..." : "Restarting EARL...")
|
|
43
|
+
pull_latest if updating || !Earl.development?
|
|
44
|
+
update_dependencies if updating
|
|
45
|
+
shutdown
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def wait_and_exec_restart
|
|
49
|
+
@app_state.shutdown_thread&.join
|
|
50
|
+
cmd = [RbConfig.ruby, $PROGRAM_NAME]
|
|
51
|
+
log(:info, "Exec: #{cmd.join(" ")}")
|
|
52
|
+
Bundler.with_unbundled_env { Kernel.exec(*cmd) }
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def pull_latest
|
|
56
|
+
run_in_repo("git pull --ff-only", "git", "pull", "--ff-only")
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def update_dependencies
|
|
60
|
+
run_in_repo("bundle install", { "RUBYOPT" => "-W0" }, "bundle", "install", "--quiet")
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def run_in_repo(label, *cmd)
|
|
64
|
+
Dir.chdir(File.dirname($PROGRAM_NAME)) do
|
|
65
|
+
result = system(*cmd) ? "succeeded" : "failed (continuing)"
|
|
66
|
+
log(result.start_with?("s") ? :info : :warn, "#{label} #{result}")
|
|
67
|
+
end
|
|
68
|
+
rescue StandardError => error
|
|
69
|
+
log(:warn, "#{label} failed: #{error.message}")
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Earl
|
|
4
|
+
class Runner
|
|
5
|
+
# Message routing: receives user messages, dispatches commands or enqueues for Claude.
|
|
6
|
+
module MessageHandling
|
|
7
|
+
private
|
|
8
|
+
|
|
9
|
+
def setup_message_handler
|
|
10
|
+
@services.mattermost.on_message do |sender_name:, thread_id:, text:, channel_id:, **_extra|
|
|
11
|
+
if allowed_user?(sender_name)
|
|
12
|
+
msg = UserMessage.new(thread_id: thread_id, text: text, channel_id: channel_id,
|
|
13
|
+
sender_name: sender_name)
|
|
14
|
+
handle_incoming_message(msg)
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def handle_incoming_message(msg)
|
|
20
|
+
if CommandParser.command?(msg.text)
|
|
21
|
+
dispatch_command(msg)
|
|
22
|
+
else
|
|
23
|
+
enqueue_message(msg)
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def dispatch_command(msg)
|
|
28
|
+
command = CommandParser.parse(msg.text)
|
|
29
|
+
return unless command
|
|
30
|
+
|
|
31
|
+
thread_id = msg.thread_id
|
|
32
|
+
result = @services.command_executor.execute(command, thread_id: thread_id, channel_id: msg.channel_id)
|
|
33
|
+
enqueue_passthrough(result, msg) if result&.dig(:passthrough)
|
|
34
|
+
stop_active_response(thread_id) if %i[stop kill].include?(command.name)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def enqueue_passthrough(result, msg)
|
|
38
|
+
msg_thread_id, _text, msg_channel_id, msg_sender = msg.deconstruct
|
|
39
|
+
passthrough_msg = UserMessage.new(
|
|
40
|
+
thread_id: msg_thread_id, text: result[:passthrough],
|
|
41
|
+
channel_id: msg_channel_id, sender_name: msg_sender
|
|
42
|
+
)
|
|
43
|
+
enqueue_message(passthrough_msg)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def enqueue_message(msg)
|
|
47
|
+
thread_id = msg.thread_id
|
|
48
|
+
queue = @app_state.message_queue
|
|
49
|
+
if queue.try_claim(thread_id)
|
|
50
|
+
process_message(msg)
|
|
51
|
+
else
|
|
52
|
+
queue.enqueue(thread_id, msg.text)
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def process_message(msg)
|
|
57
|
+
sent = false
|
|
58
|
+
thread_id = msg.thread_id
|
|
59
|
+
sent = process_message_send(msg, thread_id)
|
|
60
|
+
rescue StandardError => error
|
|
61
|
+
log_processing_error(thread_id, error)
|
|
62
|
+
ensure
|
|
63
|
+
cleanup_failed_send(thread_id) unless sent
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def process_message_send(msg, thread_id)
|
|
67
|
+
text = msg.text
|
|
68
|
+
effective_channel = msg.channel_id || @services.config.channel_id
|
|
69
|
+
existing_session, session = prepare_session(thread_id, effective_channel, msg.sender_name)
|
|
70
|
+
prepare_response(session, thread_id, effective_channel)
|
|
71
|
+
message = existing_session ? text : build_contextual_message(thread_id, text)
|
|
72
|
+
send_and_touch(session, thread_id, message)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def send_and_touch(session, thread_id, message)
|
|
76
|
+
sent = session.send_message(message)
|
|
77
|
+
@services.session_manager.touch(thread_id) if sent
|
|
78
|
+
sent
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def prepare_session(thread_id, channel_id, sender_name)
|
|
82
|
+
working_dir = resolve_working_dir(thread_id, channel_id)
|
|
83
|
+
manager = @services.session_manager
|
|
84
|
+
existing = manager.get(thread_id)
|
|
85
|
+
session_config = SessionManager::SessionConfig.new(
|
|
86
|
+
channel_id: channel_id, working_dir: working_dir, username: sender_name
|
|
87
|
+
)
|
|
88
|
+
session = manager.get_or_create(thread_id, session_config)
|
|
89
|
+
[existing, session]
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def resolve_working_dir(thread_id, channel_id)
|
|
93
|
+
@services.command_executor.working_dir_for(thread_id) || @services.config.channels[channel_id] || Dir.pwd
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def build_contextual_message(thread_id, text)
|
|
97
|
+
ThreadContextBuilder.new(mattermost: @services.mattermost).build(thread_id, text)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def process_next_queued(thread_id)
|
|
101
|
+
next_text = @app_state.message_queue.dequeue(thread_id)
|
|
102
|
+
return unless next_text
|
|
103
|
+
|
|
104
|
+
msg = UserMessage.new(thread_id: thread_id, text: next_text, channel_id: nil, sender_name: nil)
|
|
105
|
+
process_message(msg)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def allowed_user?(username)
|
|
109
|
+
allowed = @services.config.allowed_users
|
|
110
|
+
return true if allowed.empty?
|
|
111
|
+
|
|
112
|
+
unless allowed.include?(username)
|
|
113
|
+
log(:debug, "Ignoring message from non-allowed user: #{username}")
|
|
114
|
+
return false
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
true
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Earl
|
|
4
|
+
class Runner
|
|
5
|
+
# Emoji reaction handling: routes reactions to question handler or tmux monitor.
|
|
6
|
+
module ReactionHandling
|
|
7
|
+
private
|
|
8
|
+
|
|
9
|
+
def setup_reaction_handler
|
|
10
|
+
@services.mattermost.on_reaction do |user_id:, post_id:, emoji_name:|
|
|
11
|
+
handle_reaction(user_id: user_id, post_id: post_id, emoji_name: emoji_name)
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def handle_reaction(user_id:, post_id:, emoji_name:)
|
|
16
|
+
return unless allowed_reactor?(user_id)
|
|
17
|
+
|
|
18
|
+
result = @services.question_handler.handle_reaction(post_id: post_id, emoji_name: emoji_name)
|
|
19
|
+
if result
|
|
20
|
+
thread_id = @responses.question_threads[result[:tool_use_id]]
|
|
21
|
+
return unless thread_id
|
|
22
|
+
|
|
23
|
+
session = @services.session_manager.get(thread_id)
|
|
24
|
+
session&.send_message(result[:answer_text])
|
|
25
|
+
return
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
@services.tmux_monitor.handle_reaction(post_id: post_id, emoji_name: emoji_name)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def allowed_reactor?(user_id)
|
|
32
|
+
allowed = @services.config.allowed_users
|
|
33
|
+
return true if allowed.empty?
|
|
34
|
+
|
|
35
|
+
username = @services.mattermost.get_user(user_id: user_id)["username"]
|
|
36
|
+
return false unless username
|
|
37
|
+
|
|
38
|
+
allowed.include?(username)
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|