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,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