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,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Earl
|
|
4
|
+
class TmuxMonitor
|
|
5
|
+
# Builds and posts state-change alerts (error, completed, stalled, tombstone)
|
|
6
|
+
# to Mattermost. Interactive states (questions, permissions) are delegated
|
|
7
|
+
# to the appropriate forwarder instead.
|
|
8
|
+
module AlertDispatcher
|
|
9
|
+
private
|
|
10
|
+
|
|
11
|
+
def dispatch_state_alert(state, **context)
|
|
12
|
+
name, output, info = context.values_at(:name, :output, :info)
|
|
13
|
+
forwarder = { asking_question: @deps.question_forwarder,
|
|
14
|
+
requesting_permission: @deps.permission_forwarder }[state]
|
|
15
|
+
if forwarder
|
|
16
|
+
forwarder.forward(name, output, info)
|
|
17
|
+
else
|
|
18
|
+
msg = passive_alert_message(state, name, output)
|
|
19
|
+
post_alert(info, msg) if msg
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def passive_alert_message(state, name, output)
|
|
24
|
+
{ errored: error_message(name, output),
|
|
25
|
+
completed: completed_message(name),
|
|
26
|
+
stalled: stalled_message(name) }[state]
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def error_message(name, output)
|
|
30
|
+
":x: Session `#{name}` encountered an error:\n```\n#{output.lines.last(10)&.join}\n```"
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def completed_message(name)
|
|
34
|
+
":white_check_mark: Session `#{name}` appears to have completed (shell prompt detected)."
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def stalled_message(name)
|
|
38
|
+
":hourglass: Session `#{name}` appears stalled (output unchanged for #{@poll_state.stall_threshold} polls)."
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def post_alert(info, message)
|
|
42
|
+
@deps.mattermost.create_post(
|
|
43
|
+
channel_id: info.channel_id,
|
|
44
|
+
message: message,
|
|
45
|
+
root_id: info.thread_id
|
|
46
|
+
)
|
|
47
|
+
rescue StandardError => error
|
|
48
|
+
log(:error, "TmuxMonitor: failed to post alert (#{error.class}): #{error.message}")
|
|
49
|
+
nil
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Earl
|
|
4
|
+
class TmuxMonitor
|
|
5
|
+
# Stateless output analysis that detects tmux session state from captured text.
|
|
6
|
+
module OutputAnalyzer
|
|
7
|
+
SHELL_PROMPT_PATTERN = /[❯#%]\s*\z|\$\s+\z/
|
|
8
|
+
|
|
9
|
+
module_function
|
|
10
|
+
|
|
11
|
+
def detect(output, name, poll_state)
|
|
12
|
+
all_lines = output.lines
|
|
13
|
+
return :completed if completed?(all_lines)
|
|
14
|
+
|
|
15
|
+
state_from_patterns(all_lines) || stall_or_running(name, output, poll_state)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def completed?(all_lines)
|
|
19
|
+
(all_lines.last(3)&.join || "").match?(SHELL_PROMPT_PATTERN)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def state_from_patterns(all_lines)
|
|
23
|
+
recent = all_lines.last(15)&.join || ""
|
|
24
|
+
STATE_PATTERNS.each do |state, pattern|
|
|
25
|
+
return state if recent.match?(pattern)
|
|
26
|
+
end
|
|
27
|
+
nil
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def stall_or_running(name, output, poll_state)
|
|
31
|
+
poll_state.stalled?(name, output) ? :stalled : :running
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Earl
|
|
4
|
+
class TmuxMonitor
|
|
5
|
+
# Handles forwarding detected permission prompts from tmux panes to Mattermost
|
|
6
|
+
# and processing user reactions (approve/deny) back as tmux keyboard input.
|
|
7
|
+
class PermissionForwarder
|
|
8
|
+
include Logging
|
|
9
|
+
|
|
10
|
+
PERMISSION_EMOJIS = { "white_check_mark" => "y", "x" => "n" }.freeze
|
|
11
|
+
|
|
12
|
+
def initialize(mattermost:, tmux:, pending_interactions:, mutex:)
|
|
13
|
+
@mattermost = mattermost
|
|
14
|
+
@tmux = tmux
|
|
15
|
+
@pending_interactions = pending_interactions
|
|
16
|
+
@mutex = mutex
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def forward(name, output, info)
|
|
20
|
+
post = post_permission(name, output, info)
|
|
21
|
+
return unless post
|
|
22
|
+
|
|
23
|
+
register_interaction(post, name)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def handle_reaction(interaction, emoji_name, post_id)
|
|
27
|
+
answer = PERMISSION_EMOJIS[emoji_name]
|
|
28
|
+
return nil unless answer
|
|
29
|
+
|
|
30
|
+
send_answer(interaction, answer, post_id)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
def send_answer(interaction, answer, post_id)
|
|
36
|
+
@tmux.send_keys(interaction[:session_name], answer)
|
|
37
|
+
@mutex.synchronize { @pending_interactions.delete(post_id) }
|
|
38
|
+
true
|
|
39
|
+
rescue Tmux::Error => error
|
|
40
|
+
log(:error, "TmuxMonitor: failed to send permission answer: #{error.message}")
|
|
41
|
+
nil
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def post_permission(name, output, info)
|
|
45
|
+
context = output.lines.last(15)&.join || ""
|
|
46
|
+
message = build_permission_message(name, context)
|
|
47
|
+
@mattermost.create_post(
|
|
48
|
+
channel_id: info.channel_id,
|
|
49
|
+
message: message,
|
|
50
|
+
root_id: info.thread_id
|
|
51
|
+
)
|
|
52
|
+
rescue StandardError => error
|
|
53
|
+
log(:error, "TmuxMonitor: failed to post alert (#{error.class}): #{error.message}")
|
|
54
|
+
nil
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def build_permission_message(name, context)
|
|
58
|
+
[
|
|
59
|
+
":lock: **Tmux `#{name}`** is requesting permission:",
|
|
60
|
+
"```",
|
|
61
|
+
context,
|
|
62
|
+
"```",
|
|
63
|
+
":white_check_mark: Approve | :x: Deny"
|
|
64
|
+
].join("\n")
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def register_interaction(post, name)
|
|
68
|
+
post_id = post["id"]
|
|
69
|
+
return unless post_id
|
|
70
|
+
|
|
71
|
+
@mattermost.add_reaction(post_id: post_id, emoji_name: "white_check_mark")
|
|
72
|
+
@mattermost.add_reaction(post_id: post_id, emoji_name: "x")
|
|
73
|
+
|
|
74
|
+
@mutex.synchronize do
|
|
75
|
+
@pending_interactions[post_id] = { session_name: name, type: :permission }
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Earl
|
|
4
|
+
class TmuxMonitor
|
|
5
|
+
# Handles forwarding detected questions from tmux panes to Mattermost
|
|
6
|
+
# and processing user reactions back as tmux keyboard input.
|
|
7
|
+
class QuestionForwarder
|
|
8
|
+
include Logging
|
|
9
|
+
|
|
10
|
+
EMOJI_NUMBERS = QuestionHandler::EMOJI_NUMBERS
|
|
11
|
+
EMOJI_MAP = QuestionHandler::EMOJI_MAP
|
|
12
|
+
|
|
13
|
+
def initialize(mattermost:, tmux:, pending_interactions:, mutex:)
|
|
14
|
+
@mattermost = mattermost
|
|
15
|
+
@tmux = tmux
|
|
16
|
+
@pending_interactions = pending_interactions
|
|
17
|
+
@mutex = mutex
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def forward(name, output, info)
|
|
21
|
+
parsed = parse_question(output)
|
|
22
|
+
return unless parsed
|
|
23
|
+
|
|
24
|
+
post = post_question(name, parsed, info)
|
|
25
|
+
return unless post
|
|
26
|
+
|
|
27
|
+
register_interaction(post, name, parsed[:options])
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def handle_reaction(interaction, emoji_name, post_id)
|
|
31
|
+
answer_index = EMOJI_MAP[emoji_name]
|
|
32
|
+
return nil unless answer_index
|
|
33
|
+
return nil unless valid_option?(interaction, answer_index)
|
|
34
|
+
|
|
35
|
+
send_answer(interaction, answer_index, post_id)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def parse_question(output)
|
|
39
|
+
lines = output.lines.map(&:strip).reject(&:empty?)
|
|
40
|
+
question_idx = find_question_index(lines)
|
|
41
|
+
return nil unless question_idx
|
|
42
|
+
|
|
43
|
+
build_parsed(lines, question_idx)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
private
|
|
47
|
+
|
|
48
|
+
def valid_option?(interaction, answer_index)
|
|
49
|
+
answer_index < interaction[:options].size
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def find_question_index(lines)
|
|
53
|
+
lines.rindex { |line| line.include?("?") }
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def build_parsed(lines, question_idx)
|
|
57
|
+
options = gather_numbered_options(lines, question_idx).first(4)
|
|
58
|
+
return nil if options.empty?
|
|
59
|
+
|
|
60
|
+
{ text: lines[question_idx], options: options }
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def send_answer(interaction, answer_index, post_id)
|
|
64
|
+
@tmux.send_keys(interaction[:session_name], (answer_index + 1).to_s)
|
|
65
|
+
@mutex.synchronize { @pending_interactions.delete(post_id) }
|
|
66
|
+
true
|
|
67
|
+
rescue Tmux::Error => error
|
|
68
|
+
log(:error, "TmuxMonitor: failed to send question answer: #{error.message}")
|
|
69
|
+
nil
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def gather_numbered_options(lines, question_idx)
|
|
73
|
+
options = []
|
|
74
|
+
((question_idx + 1)...lines.size).each do |idx|
|
|
75
|
+
line = lines[idx]
|
|
76
|
+
options << line.sub(/\A\s*\d+[.)]\s*/, "") if line.match?(/\A\s*\d+[.)]\s/)
|
|
77
|
+
end
|
|
78
|
+
options
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def post_question(name, parsed, info)
|
|
82
|
+
message = build_question_message(name, parsed)
|
|
83
|
+
@mattermost.create_post(
|
|
84
|
+
channel_id: info.channel_id,
|
|
85
|
+
message: message,
|
|
86
|
+
root_id: info.thread_id
|
|
87
|
+
)
|
|
88
|
+
rescue StandardError => error
|
|
89
|
+
log(:error, "TmuxMonitor: failed to post alert (#{error.class}): #{error.message}")
|
|
90
|
+
nil
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def build_question_message(name, parsed)
|
|
94
|
+
lines = [":question: **Tmux `#{name}`** is asking:", "```", parsed[:text], "```"]
|
|
95
|
+
parsed[:options].each_with_index do |opt, idx|
|
|
96
|
+
emoji = EMOJI_NUMBERS[idx]
|
|
97
|
+
lines << ":#{emoji}: #{opt}" if emoji
|
|
98
|
+
end
|
|
99
|
+
lines.join("\n")
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def register_interaction(post, name, options)
|
|
103
|
+
post_id = post["id"]
|
|
104
|
+
return unless post_id
|
|
105
|
+
|
|
106
|
+
add_emoji_reactions(post_id, options.size)
|
|
107
|
+
@mutex.synchronize do
|
|
108
|
+
@pending_interactions[post_id] = {
|
|
109
|
+
session_name: name, type: :question, options: options
|
|
110
|
+
}
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def add_emoji_reactions(post_id, count)
|
|
115
|
+
[count, EMOJI_NUMBERS.size].min.times do |idx|
|
|
116
|
+
emoji = EMOJI_NUMBERS[idx]
|
|
117
|
+
@mattermost.add_reaction(post_id: post_id, emoji_name: emoji)
|
|
118
|
+
rescue StandardError => error
|
|
119
|
+
log(:warn, "TmuxMonitor: failed to add reaction :#{emoji}: (#{error.class}): #{error.message}")
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
end
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "tmux_monitor/alert_dispatcher"
|
|
4
|
+
require_relative "tmux_monitor/output_analyzer"
|
|
5
|
+
require_relative "tmux_monitor/question_forwarder"
|
|
6
|
+
require_relative "tmux_monitor/permission_forwarder"
|
|
7
|
+
|
|
8
|
+
module Earl
|
|
9
|
+
# Lightweight background poller that monitors EARL-spawned tmux sessions for
|
|
10
|
+
# state changes (questions, permission prompts, errors, completion, stalls)
|
|
11
|
+
# and posts alerts to Mattermost. Also handles forwarding user reactions
|
|
12
|
+
# back to tmux sessions as keyboard input.
|
|
13
|
+
class TmuxMonitor
|
|
14
|
+
include Logging
|
|
15
|
+
include AlertDispatcher
|
|
16
|
+
|
|
17
|
+
DEFAULT_POLL_INTERVAL = 45 # seconds
|
|
18
|
+
DEFAULT_STALL_THRESHOLD = 5 # consecutive unchanged polls
|
|
19
|
+
|
|
20
|
+
# Pattern matchers for detecting session state from captured output.
|
|
21
|
+
STATE_PATTERNS = {
|
|
22
|
+
asking_question: /\?\s*\n\s*(?:1[.)]\s|❯)/m,
|
|
23
|
+
requesting_permission: /(?:Allow|Deny|approve|permission|Do you want to allow)/i,
|
|
24
|
+
errored: /(?:Error:|error:|FAILED|panic:|Traceback|fatal:)/
|
|
25
|
+
}.freeze
|
|
26
|
+
|
|
27
|
+
INTERACTIVE_STATES = { asking_question: :question, requesting_permission: :permission }.freeze
|
|
28
|
+
|
|
29
|
+
# Bundles external service dependencies and forwarder collaborators.
|
|
30
|
+
Forwarders = Struct.new(:question, :permission, keyword_init: true)
|
|
31
|
+
|
|
32
|
+
def initialize(mattermost:, tmux_store:, tmux_adapter: Tmux)
|
|
33
|
+
@poll_state = PollState.new(stall_threshold: Integer(ENV.fetch("EARL_TMUX_STALL_THRESHOLD",
|
|
34
|
+
DEFAULT_STALL_THRESHOLD)))
|
|
35
|
+
@deps = Dependencies.new(mattermost, tmux_store, tmux_adapter, @poll_state)
|
|
36
|
+
@poll_interval = Integer(ENV.fetch("EARL_TMUX_POLL_INTERVAL", DEFAULT_POLL_INTERVAL))
|
|
37
|
+
@thread_ctl = ThreadControl.new
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def start
|
|
41
|
+
return if @thread_ctl.alive?
|
|
42
|
+
|
|
43
|
+
@thread_ctl.start { poll_loop }
|
|
44
|
+
log(:info, "TmuxMonitor started (interval: #{@poll_interval}s)")
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def stop
|
|
48
|
+
@thread_ctl.stop
|
|
49
|
+
log(:info, "TmuxMonitor stopped")
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Called by Runner when a user reacts to a forwarded question/permission post.
|
|
53
|
+
# Returns true if the reaction was handled, nil otherwise.
|
|
54
|
+
def handle_reaction(post_id:, emoji_name:)
|
|
55
|
+
interaction = @poll_state.pending_interaction(post_id)
|
|
56
|
+
return nil unless interaction
|
|
57
|
+
|
|
58
|
+
case interaction[:type]
|
|
59
|
+
when :question
|
|
60
|
+
@deps.question_forwarder.handle_reaction(interaction, emoji_name, post_id)
|
|
61
|
+
when :permission
|
|
62
|
+
@deps.permission_forwarder.handle_reaction(interaction, emoji_name, post_id)
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Delegate parse_question to QuestionForwarder for external callers.
|
|
67
|
+
def parse_question(output)
|
|
68
|
+
@deps.question_forwarder.parse_question(output)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
private
|
|
72
|
+
|
|
73
|
+
def poll_loop
|
|
74
|
+
loop do
|
|
75
|
+
sleep @poll_interval
|
|
76
|
+
break if @thread_ctl.shutdown?
|
|
77
|
+
|
|
78
|
+
poll_sessions
|
|
79
|
+
rescue StandardError => error
|
|
80
|
+
log(:error, "TmuxMonitor poll error: #{error.message}\n#{error.backtrace&.first(5)&.join("\n")}")
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def poll_sessions
|
|
85
|
+
sessions = @deps.tmux_store.all
|
|
86
|
+
cleanup_dead_sessions(sessions)
|
|
87
|
+
|
|
88
|
+
sessions.each do |name, info|
|
|
89
|
+
poll_single_session(name, info)
|
|
90
|
+
rescue StandardError => error
|
|
91
|
+
log(:error,
|
|
92
|
+
"TmuxMonitor: error polling session '#{name}': #{error.message}\n#{error.backtrace&.first(5)&.join("\n")}")
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def poll_single_session(name, info)
|
|
97
|
+
return unless @deps.tmux.session_exists?(name)
|
|
98
|
+
|
|
99
|
+
output = safe_capture(name)
|
|
100
|
+
return unless output
|
|
101
|
+
|
|
102
|
+
state = OutputAnalyzer.detect(output, name, @poll_state)
|
|
103
|
+
return unless @poll_state.transition(name, state)
|
|
104
|
+
|
|
105
|
+
dispatch_state_alert(state, name: name, output: output, info: info)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def safe_capture(name)
|
|
109
|
+
@deps.tmux.capture_pane(name, lines: 50)
|
|
110
|
+
rescue Tmux::Error => error
|
|
111
|
+
log(:warn, "TmuxMonitor: failed to capture '#{name}': #{error.message}")
|
|
112
|
+
nil
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def cleanup_dead_sessions(sessions)
|
|
116
|
+
sessions.each do |name, info|
|
|
117
|
+
next if @deps.tmux.session_exists?(name)
|
|
118
|
+
|
|
119
|
+
log(:info, "TmuxMonitor: session '#{name}' no longer exists, cleaning up")
|
|
120
|
+
post_alert(info, ":tombstone: Tmux session `#{name}` has ended.")
|
|
121
|
+
@deps.tmux_store.delete(name)
|
|
122
|
+
@poll_state.cleanup_session(name)
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# Holds external service references and forwarder pair.
|
|
127
|
+
class Dependencies
|
|
128
|
+
attr_reader :mattermost, :tmux_store, :tmux
|
|
129
|
+
|
|
130
|
+
def initialize(mattermost, tmux_store, tmux_adapter, poll_state)
|
|
131
|
+
@mattermost = mattermost
|
|
132
|
+
@tmux_store = tmux_store
|
|
133
|
+
@tmux = tmux_adapter
|
|
134
|
+
shared = { mattermost: mattermost, tmux: tmux_adapter,
|
|
135
|
+
pending_interactions: poll_state.pending_interactions, mutex: poll_state.mutex }
|
|
136
|
+
@forwarders = Forwarders.new(
|
|
137
|
+
question: QuestionForwarder.new(**shared),
|
|
138
|
+
permission: PermissionForwarder.new(**shared)
|
|
139
|
+
)
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def question_forwarder = @forwarders.question
|
|
143
|
+
def permission_forwarder = @forwarders.permission
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# Encapsulates mutable poll tracking state: last-seen states, output hashes
|
|
147
|
+
# for stall detection, and pending user interactions.
|
|
148
|
+
class PollState
|
|
149
|
+
# Tracks per-session poll state: last detected state, output hash for stall detection.
|
|
150
|
+
TrackingEntry = Struct.new(:last_state, :output_hash, :stall_count, keyword_init: true) do
|
|
151
|
+
def update_stall(current_hash, threshold)
|
|
152
|
+
if output_hash == current_hash
|
|
153
|
+
self.stall_count += 1
|
|
154
|
+
stall_count >= threshold
|
|
155
|
+
else
|
|
156
|
+
self.output_hash = current_hash
|
|
157
|
+
self.stall_count = 1
|
|
158
|
+
false
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
attr_reader :pending_interactions, :mutex, :stall_threshold
|
|
164
|
+
|
|
165
|
+
def initialize(stall_threshold: DEFAULT_STALL_THRESHOLD)
|
|
166
|
+
@tracking = {}
|
|
167
|
+
@pending_interactions = {}
|
|
168
|
+
@mutex = Mutex.new
|
|
169
|
+
@stall_threshold = stall_threshold
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def pending_interaction(post_id)
|
|
173
|
+
@mutex.synchronize { @pending_interactions[post_id] }
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
# Returns true if state changed (and records the new state), false otherwise.
|
|
177
|
+
def transition(name, state)
|
|
178
|
+
tracking = ensure_tracking(name)
|
|
179
|
+
last_state = tracking.last_state
|
|
180
|
+
changed = last_state != state || should_retrigger?(name, state)
|
|
181
|
+
return false unless changed
|
|
182
|
+
|
|
183
|
+
tracking.last_state = state
|
|
184
|
+
true
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
def stalled?(name, output)
|
|
188
|
+
ensure_tracking(name).update_stall(output.hash, @stall_threshold)
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
def cleanup_session(name)
|
|
192
|
+
@tracking.delete(name)
|
|
193
|
+
@mutex.synchronize do
|
|
194
|
+
@pending_interactions.delete_if { |_, interaction| interaction[:session_name] == name }
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
private
|
|
199
|
+
|
|
200
|
+
def should_retrigger?(name, state)
|
|
201
|
+
interaction_type = INTERACTIVE_STATES[state]
|
|
202
|
+
return false unless interaction_type
|
|
203
|
+
|
|
204
|
+
pending = pending_interactions_for(name)
|
|
205
|
+
pending.none? { |interaction| interaction[:type] == interaction_type }
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
def pending_interactions_for(session_name)
|
|
209
|
+
@mutex.synchronize do
|
|
210
|
+
@pending_interactions.values.select { |interaction| interaction[:session_name] == session_name }
|
|
211
|
+
end
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
def ensure_tracking(name)
|
|
215
|
+
@tracking[name] ||= TrackingEntry.new(last_state: nil, output_hash: nil, stall_count: 0)
|
|
216
|
+
end
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
# Simple background thread lifecycle wrapper.
|
|
220
|
+
class ThreadControl
|
|
221
|
+
def initialize
|
|
222
|
+
@thread = nil
|
|
223
|
+
@shutdown = false
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
def alive?
|
|
227
|
+
@thread&.alive?
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
def shutdown?
|
|
231
|
+
@shutdown
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
def start(&)
|
|
235
|
+
@shutdown = false
|
|
236
|
+
@thread = Thread.new(&)
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
def stop
|
|
240
|
+
@shutdown = true
|
|
241
|
+
return unless @thread
|
|
242
|
+
|
|
243
|
+
@thread.join(5)
|
|
244
|
+
@thread.kill if @thread.alive?
|
|
245
|
+
@thread = nil
|
|
246
|
+
end
|
|
247
|
+
end
|
|
248
|
+
end
|
|
249
|
+
end
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Earl
|
|
4
|
+
# Tracks EARL-spawned tmux sessions with metadata for monitoring and control.
|
|
5
|
+
# Persists to <config_root>/tmux_sessions.json with thread-safe atomic writes.
|
|
6
|
+
class TmuxSessionStore
|
|
7
|
+
include Logging
|
|
8
|
+
|
|
9
|
+
# Holds metadata for an EARL-spawned tmux session.
|
|
10
|
+
TmuxSessionInfo = Struct.new(:name, :channel_id, :thread_id, :working_dir,
|
|
11
|
+
:prompt, :created_at, keyword_init: true)
|
|
12
|
+
|
|
13
|
+
def self.default_path
|
|
14
|
+
@default_path ||= File.join(Earl.config_root, "tmux_sessions.json")
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def initialize(path: self.class.default_path)
|
|
18
|
+
@path = path
|
|
19
|
+
@mutex = Mutex.new
|
|
20
|
+
@cache = nil
|
|
21
|
+
@dirty = false
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def save(info)
|
|
25
|
+
@mutex.synchronize do
|
|
26
|
+
ensure_cache[info.name] = info
|
|
27
|
+
write_store(@cache)
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def get(name)
|
|
32
|
+
@mutex.synchronize { ensure_cache[name] }
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def all
|
|
36
|
+
@mutex.synchronize { ensure_cache.dup }
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def delete(name)
|
|
40
|
+
@mutex.synchronize do
|
|
41
|
+
ensure_cache.delete(name)
|
|
42
|
+
write_store(@cache)
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Returns names of dead sessions without modifying the store.
|
|
47
|
+
def cleanup
|
|
48
|
+
find_dead_sessions
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Removes entries for tmux sessions that no longer exist.
|
|
52
|
+
# Shell calls happen outside the mutex to avoid blocking other operations
|
|
53
|
+
# if tmux is slow or hung.
|
|
54
|
+
def cleanup!
|
|
55
|
+
dead = find_dead_sessions
|
|
56
|
+
return dead if dead.empty?
|
|
57
|
+
|
|
58
|
+
remove_dead_sessions(dead)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
private
|
|
62
|
+
|
|
63
|
+
def find_dead_sessions
|
|
64
|
+
names = @mutex.synchronize { ensure_cache.keys }
|
|
65
|
+
names.reject { |name| Tmux.session_exists?(name) }
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def remove_dead_sessions(dead)
|
|
69
|
+
@mutex.synchronize do
|
|
70
|
+
dead.each { |name| @cache&.delete(name) }
|
|
71
|
+
write_store(@cache) if @cache
|
|
72
|
+
dead
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def ensure_cache
|
|
77
|
+
@cache ||= read_store
|
|
78
|
+
write_store(@cache) if @dirty && @cache
|
|
79
|
+
@cache
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def read_store
|
|
83
|
+
return {} unless File.exist?(@path)
|
|
84
|
+
|
|
85
|
+
raw = JSON.parse(File.read(@path))
|
|
86
|
+
deserialize_entries(raw)
|
|
87
|
+
rescue JSON::ParserError, ArgumentError, Errno::ENOENT => error
|
|
88
|
+
file_missing = error.is_a?(Errno::ENOENT)
|
|
89
|
+
backup_corrupted_store unless file_missing
|
|
90
|
+
suffix = file_missing ? "" : " (backed up corrupted file)"
|
|
91
|
+
log(:warn, "Failed to read tmux session store: #{error.message}#{suffix}")
|
|
92
|
+
{}
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def deserialize_entries(raw)
|
|
96
|
+
valid_keys = TmuxSessionInfo.members.map(&:to_s)
|
|
97
|
+
raw.transform_values do |value|
|
|
98
|
+
filtered = value.slice(*valid_keys).transform_keys(&:to_sym)
|
|
99
|
+
TmuxSessionInfo.new(**filtered)
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def backup_corrupted_store
|
|
104
|
+
return unless File.exist?(@path)
|
|
105
|
+
|
|
106
|
+
backup_path = "#{@path}.corrupt.#{Time.now.strftime("%Y%m%d%H%M%S")}"
|
|
107
|
+
FileUtils.cp(@path, backup_path)
|
|
108
|
+
rescue StandardError => error
|
|
109
|
+
log(:warn, "Failed to back up corrupted store: #{error.message}")
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def write_store(data)
|
|
113
|
+
serialize_and_write(data)
|
|
114
|
+
@dirty = false
|
|
115
|
+
rescue StandardError => error
|
|
116
|
+
@dirty = true
|
|
117
|
+
log(:error, "Failed to write tmux session store: #{error.message} (will retry on next write)")
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def serialize_and_write(data)
|
|
121
|
+
dir = File.dirname(@path)
|
|
122
|
+
FileUtils.mkdir_p(dir)
|
|
123
|
+
|
|
124
|
+
serialized = data.transform_values(&:to_h)
|
|
125
|
+
tmp_path = "#{@path}.tmp.#{Process.pid}"
|
|
126
|
+
File.write(tmp_path, JSON.pretty_generate(serialized))
|
|
127
|
+
File.rename(tmp_path, @path)
|
|
128
|
+
rescue StandardError
|
|
129
|
+
FileUtils.rm_f(tmp_path) if tmp_path
|
|
130
|
+
raise
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|