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,92 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "session_manager/persistence"
4
+ require_relative "session_manager/session_creation"
5
+
6
+ module Earl
7
+ # Thread-safe registry of active Claude sessions, keyed by Mattermost
8
+ # thread ID, with lazy creation, coordinated shutdown, and optional persistence.
9
+ class SessionManager
10
+ include Logging
11
+ include PermissionConfig
12
+
13
+ # Bundles session creation parameters that travel together.
14
+ SessionConfig = Data.define(:channel_id, :working_dir, :username)
15
+
16
+ # Bundles thread identity with session config to eliminate data clump.
17
+ ThreadContext = Data.define(:thread_id, :short_id, :session_config)
18
+
19
+ # Bundles persistence parameters to reduce parameter list length.
20
+ PersistenceContext = Data.define(:channel_id, :working_dir, :paused)
21
+
22
+ # Bundles parameters for spawning a new Claude session.
23
+ SpawnParams = Data.define(:session_id, :thread_id, :channel_id, :working_dir, :username)
24
+
25
+ def initialize(config: nil, session_store: nil)
26
+ @config = config
27
+ @session_store = session_store
28
+ @sessions = {}
29
+ @mutex = Mutex.new
30
+ end
31
+
32
+ def get_or_create(thread_id, session_config)
33
+ ctx = ThreadContext.new(thread_id: thread_id, short_id: thread_id[0..7],
34
+ session_config: session_config)
35
+ @mutex.synchronize do
36
+ session = @sessions[thread_id]
37
+ return reuse_session(session, ctx.short_id) if session&.alive?
38
+
39
+ persisted = @session_store&.load&.dig(thread_id)
40
+ return resume_or_create(ctx, persisted) if persisted&.claude_session_id
41
+
42
+ create_session(ctx)
43
+ end
44
+ end
45
+
46
+ def get(thread_id)
47
+ @mutex.synchronize { @sessions[thread_id] }
48
+ end
49
+
50
+ def stop_session(thread_id)
51
+ @mutex.synchronize do
52
+ session = @sessions.delete(thread_id)
53
+ session&.kill
54
+ @session_store&.remove(thread_id)
55
+ end
56
+ end
57
+
58
+ def stop_all
59
+ @mutex.synchronize do
60
+ log(:info, "Stopping #{@sessions.size} session(s)...")
61
+ @sessions.each_value(&:kill)
62
+ @sessions.clear
63
+ end
64
+ end
65
+
66
+ def touch(thread_id)
67
+ @session_store&.touch(thread_id)
68
+ end
69
+
70
+ def resume_all
71
+ return unless @session_store
72
+
73
+ @session_store.load.each do |thread_id, persisted|
74
+ resume_session(thread_id, persisted) unless persisted.is_paused
75
+ end
76
+ end
77
+
78
+ def pause_all
79
+ @mutex.synchronize do
80
+ @sessions.each do |thread_id, session|
81
+ persist_ctx = PersistenceContext.new(channel_id: nil, working_dir: nil, paused: true)
82
+ @session_store&.save(thread_id, build_persisted(session, persist_ctx))
83
+ session.kill
84
+ end
85
+ @sessions.clear
86
+ end
87
+ end
88
+
89
+ include Persistence
90
+ include SessionCreation
91
+ end
92
+ end
@@ -0,0 +1,84 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Earl
4
+ # Persists session metadata to <config_root>/sessions.json for
5
+ # resuming sessions across EARL restarts. Uses an in-memory cache
6
+ # to avoid re-reading from disk on every save, preventing race
7
+ # conditions between concurrent save/touch calls.
8
+ class SessionStore
9
+ include Logging
10
+
11
+ # Snapshot of a Claude session's metadata for disk persistence and resume.
12
+ PersistedSession = Struct.new(:claude_session_id, :channel_id, :working_dir,
13
+ :started_at, :last_activity_at, :is_paused,
14
+ :message_count, :total_cost, :total_input_tokens,
15
+ :total_output_tokens, keyword_init: true)
16
+
17
+ def self.default_path
18
+ @default_path ||= File.join(Earl.config_root, "sessions.json")
19
+ end
20
+
21
+ def initialize(path: self.class.default_path)
22
+ @path = path
23
+ @mutex = Mutex.new
24
+ @cache = nil # Lazy-loaded from disk on first access
25
+ end
26
+
27
+ def load
28
+ @mutex.synchronize { cache.dup }
29
+ end
30
+
31
+ def save(thread_id, persisted_session)
32
+ @mutex.synchronize do
33
+ cache[thread_id] = persisted_session
34
+ write_store(@cache)
35
+ end
36
+ end
37
+
38
+ def remove(thread_id)
39
+ @mutex.synchronize do
40
+ cache.delete(thread_id)
41
+ write_store(@cache)
42
+ end
43
+ end
44
+
45
+ def touch(thread_id)
46
+ @mutex.synchronize do
47
+ session = cache[thread_id]
48
+ if session
49
+ session.last_activity_at = Time.now.iso8601
50
+ write_store(@cache)
51
+ end
52
+ end
53
+ end
54
+
55
+ private
56
+
57
+ def cache
58
+ @cache ||= read_store
59
+ end
60
+
61
+ def read_store
62
+ return {} unless File.exist?(@path)
63
+
64
+ raw = JSON.parse(File.read(@path))
65
+ raw.transform_values { |attrs| PersistedSession.new(**attrs.transform_keys(&:to_sym)) }
66
+ rescue JSON::ParserError, Errno::ENOENT => error
67
+ log(:warn, "Failed to read session store: #{error.message}")
68
+ {}
69
+ end
70
+
71
+ def write_store(data)
72
+ dir = File.dirname(@path)
73
+ FileUtils.mkdir_p(dir)
74
+
75
+ serialized = data.transform_values(&:to_h)
76
+ tmp_path = "#{@path}.tmp.#{Process.pid}"
77
+ File.write(tmp_path, JSON.pretty_generate(serialized))
78
+ File.rename(tmp_path, @path)
79
+ rescue Errno::ENOENT, Errno::EACCES, Errno::ENOSPC, IOError => error
80
+ log(:error, "Failed to write session store: #{error.message}")
81
+ FileUtils.rm_f(tmp_path) if tmp_path
82
+ end
83
+ end
84
+ end
@@ -0,0 +1,219 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Earl
4
+ # Manages the lifecycle of a single streamed response to Mattermost,
5
+ # including post creation, debounced updates, and typing indicators.
6
+ class StreamingResponse
7
+ include Logging
8
+ include ToolInputFormatter
9
+
10
+ DEBOUNCE_MS = 300
11
+ TOOL_PREFIXES = ToolInputFormatter::TOOL_ICONS.values.uniq.push("\u2699\uFE0F").freeze
12
+
13
+ # Holds the Mattermost thread and channel context for posting.
14
+ Context = Struct.new(:thread_id, :mattermost, :channel_id, keyword_init: true)
15
+ # Tracks the reply post lifecycle: ID, failure state, text, debounce timing, and typing thread.
16
+ PostState = Struct.new(:reply_post_id, :create_failed, :full_text, :last_update_at,
17
+ :debounce_timer, :typing_thread, keyword_init: true)
18
+
19
+ def initialize(thread_id:, mattermost:, channel_id:)
20
+ @context = Context.new(thread_id: thread_id, mattermost: mattermost, channel_id: channel_id)
21
+ @post_state = PostState.new(create_failed: false, full_text: "", last_update_at: Time.now)
22
+ @segments = []
23
+ @mutex = Mutex.new
24
+ end
25
+
26
+ def channel_id
27
+ @context.channel_id
28
+ end
29
+
30
+ def start_typing
31
+ @post_state.typing_thread = Thread.new { typing_loop }
32
+ end
33
+
34
+ def on_text(text)
35
+ @mutex.synchronize { handle_text(text) }
36
+ rescue StandardError => error
37
+ log(:error, "Streaming error (thread #{short_id}): #{error.class}: #{error.message}")
38
+ log(:error, error.backtrace&.first(5)&.join("\n"))
39
+ end
40
+
41
+ def on_tool_use(tool_use)
42
+ @mutex.synchronize { handle_tool_use_display(tool_use) }
43
+ rescue StandardError => error
44
+ log(:error, "Tool use display error (thread #{short_id}): #{error.class}: #{error.message}")
45
+ log(:error, error.backtrace&.first(5)&.join("\n"))
46
+ end
47
+
48
+ def on_complete(**)
49
+ @mutex.synchronize { finalize }
50
+ rescue StandardError => error
51
+ log(:error, "Completion error (thread #{short_id}): #{error.class}: #{error.message}")
52
+ log(:error, error.backtrace&.first(5)&.join("\n"))
53
+ end
54
+
55
+ def stop_typing
56
+ @post_state.typing_thread&.kill
57
+ @post_state.typing_thread = nil
58
+ end
59
+
60
+ private
61
+
62
+ def typing_loop
63
+ loop do
64
+ send_typing_indicator
65
+ sleep 3
66
+ end
67
+ rescue StandardError => error
68
+ log(:warn, "Typing error (thread #{short_id}): #{error.class}: #{error.message}")
69
+ end
70
+
71
+ def send_typing_indicator
72
+ @context.mattermost.send_typing(channel_id: @context.channel_id, parent_id: @context.thread_id)
73
+ end
74
+
75
+ def handle_text(text)
76
+ @segments << text
77
+ @post_state.full_text = @segments.join("\n\n")
78
+ stop_typing
79
+
80
+ return if @post_state.create_failed
81
+ return create_initial_post(@post_state.full_text) unless posted?
82
+
83
+ schedule_update
84
+ end
85
+
86
+ def posted?
87
+ !!@post_state.reply_post_id
88
+ end
89
+
90
+ # Post creation and update lifecycle.
91
+ module PostUpdating
92
+ private
93
+
94
+ def create_initial_post(text)
95
+ result = @context.mattermost.create_post(channel_id: @context.channel_id, message: text,
96
+ root_id: @context.thread_id)
97
+ post_id = result["id"]
98
+ return handle_create_failure unless post_id
99
+
100
+ @post_state.reply_post_id = post_id
101
+ @post_state.last_update_at = Time.now
102
+ end
103
+
104
+ def handle_create_failure
105
+ @post_state.create_failed = true
106
+ log(:error, "Failed to create post for thread #{short_id} \u2014 subsequent text will be dropped")
107
+ end
108
+
109
+ def schedule_update
110
+ elapsed_ms = (Time.now - @post_state.last_update_at) * 1000
111
+
112
+ if elapsed_ms >= DEBOUNCE_MS
113
+ update_post
114
+ else
115
+ start_debounce_timer
116
+ end
117
+ end
118
+
119
+ def start_debounce_timer
120
+ return if @post_state.debounce_timer
121
+
122
+ @post_state.debounce_timer = Thread.new do
123
+ sleep DEBOUNCE_MS / 1000.0
124
+ @mutex.synchronize { update_post }
125
+ end
126
+ end
127
+
128
+ def update_post
129
+ @post_state.debounce_timer = nil
130
+ @context.mattermost.update_post(post_id: @post_state.reply_post_id, message: @post_state.full_text)
131
+ @post_state.last_update_at = Time.now
132
+ end
133
+ end
134
+
135
+ # Finalization: completes the response and handles multi-segment posts.
136
+ module Finalization
137
+ private
138
+
139
+ def finalize
140
+ ps = @post_state
141
+ ps.debounce_timer&.join(1)
142
+ stop_typing
143
+ return if finalize_empty?(ps)
144
+
145
+ final_text = build_final_text
146
+ apply_final_text(ps, final_text)
147
+ end
148
+
149
+ def finalize_empty?(post_state)
150
+ post_state.full_text.empty? && !post_state.reply_post_id
151
+ end
152
+
153
+ def apply_final_text(post_state, final_text)
154
+ if only_text_segments?
155
+ post_state.full_text = final_text
156
+ update_post if post_state.reply_post_id
157
+ else
158
+ remove_last_text_from_streamed_post
159
+ create_notification_post(final_text)
160
+ end
161
+ end
162
+
163
+ def only_text_segments?
164
+ @segments.none? { |segment| tool_segment?(segment) }
165
+ end
166
+
167
+ def build_final_text
168
+ last_text = @segments.reverse.find { |segment| !tool_segment?(segment) }
169
+ last_text || @post_state.full_text
170
+ end
171
+
172
+ def remove_last_text_from_streamed_post
173
+ return unless posted?
174
+
175
+ last_text_index = @segments.rindex { |segment| !tool_segment?(segment) }
176
+ return unless last_text_index
177
+
178
+ @segments.delete_at(last_text_index)
179
+ @post_state.full_text = @segments.join("\n\n")
180
+ update_post unless @post_state.full_text.empty?
181
+ end
182
+
183
+ def create_notification_post(text)
184
+ @context.mattermost.create_post(
185
+ channel_id: @context.channel_id, message: text, root_id: @context.thread_id
186
+ )
187
+ end
188
+ end
189
+
190
+ include PostUpdating
191
+ include Finalization
192
+
193
+ def handle_tool_use_display(tool_use)
194
+ return if tool_use[:name] == "AskUserQuestion"
195
+
196
+ @segments << format_tool_use(tool_use)
197
+ @post_state.full_text = @segments.join("\n\n")
198
+ stop_typing
199
+
200
+ return if @post_state.create_failed
201
+ return create_initial_post(@post_state.full_text) unless posted?
202
+
203
+ schedule_update
204
+ end
205
+
206
+ def format_tool_use(tool_use)
207
+ name, input = tool_use.values_at(:name, :input)
208
+ format_tool_display(name, input)
209
+ end
210
+
211
+ def tool_segment?(segment)
212
+ TOOL_PREFIXES.any? { |prefix| segment.start_with?(prefix) }
213
+ end
214
+
215
+ def short_id
216
+ @context.thread_id[0..7]
217
+ end
218
+ end
219
+ end
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Earl
4
+ module Tmux
5
+ # Private parsing helpers for tmux output formats.
6
+ # Extracted to keep the main Tmux module under the line limit.
7
+ module Parsing
8
+ private
9
+
10
+ def build_format(fields)
11
+ fields.map { |field| "\#{#{field}}" }.join(FIELD_SEP)
12
+ end
13
+
14
+ def parse_session_line(line)
15
+ parts = line.strip.split(FIELD_SEP, 3)
16
+ return if parts.size < 3
17
+
18
+ { name: parts[0], attached: parts[1] != "0",
19
+ created_at: Time.at(parts[2].to_i).strftime("%c") }
20
+ end
21
+
22
+ def no_server_or_sessions?(error)
23
+ msg = error.message
24
+ msg.include?("no server running") || msg.include?("no sessions")
25
+ end
26
+
27
+ def parse_pane_lines(output, field_count)
28
+ output.each_line.filter_map do |line|
29
+ parts = line.strip.split(FIELD_SEP, field_count)
30
+ next if parts.size < field_count
31
+
32
+ { index: parts[0].to_i, command: parts[1], path: parts[2], pid: parts[3].to_i }
33
+ end
34
+ end
35
+
36
+ def parse_all_pane_line(line, field_count)
37
+ parts = line.strip.split(FIELD_SEP, field_count)
38
+ return if parts.size < field_count
39
+
40
+ build_all_pane_hash(parts)
41
+ end
42
+
43
+ def build_all_pane_hash(parts)
44
+ session, window, pane_idx, command, path, pid, tty = parts
45
+ { target: "#{session}:#{window}.#{pane_idx}", session: session,
46
+ window: window.to_i, pane_index: pane_idx.to_i,
47
+ command: command, path: path, pid: pid.to_i, tty: tty }
48
+ end
49
+
50
+ def build_create_window_args(options)
51
+ session = options.fetch(:session)
52
+ name = options[:name]
53
+ working_dir = options[:working_dir]
54
+ command = options[:command]
55
+ args = ["tmux", "new-window", "-t", session]
56
+ args.push("-n", name) if name
57
+ args.push("-c", working_dir) if working_dir
58
+ args.push(command) if command
59
+ args
60
+ end
61
+
62
+ def fetch_parent_command(pid_str)
63
+ output, _status = Open3.capture2e("ps", "-o", "comm=", "-p", pid_str)
64
+ output.strip
65
+ end
66
+
67
+ def fetch_child_commands
68
+ output, = Open3.capture2e("ps", "-eo", "pid=,ppid=,comm=")
69
+ parse_process_entries(output)
70
+ end
71
+
72
+ def parse_process_entries(output)
73
+ output.each_line.filter_map do |line|
74
+ parts = line.strip.split(/\s+/, 3)
75
+ { ppid: parts[1], comm: parts[2] } if parts.size >= 3
76
+ end
77
+ end
78
+ end
79
+ end
80
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Earl
4
+ module Tmux
5
+ # Process inspection: check for Claude on TTY, list child processes.
6
+ module Processes
7
+ def claude_on_tty?(tty)
8
+ check_tty_for_claude(tty)
9
+ end
10
+
11
+ def pane_child_commands(pid)
12
+ pid_str = pid.to_s
13
+ parent_comm = fetch_parent_command(pid_str)
14
+ all_entries = fetch_child_commands
15
+ child_comms = all_entries.filter_map { |entry| entry[:comm] if entry[:ppid] == pid_str }
16
+ ([parent_comm] + child_comms).reject(&:empty?)
17
+ rescue StandardError => error
18
+ Earl.logger.debug("Tmux.pane_child_commands failed for PID #{pid}: #{error.message}")
19
+ []
20
+ end
21
+
22
+ private
23
+
24
+ def check_tty_for_claude(tty)
25
+ tty_name = tty.sub(%r{\A/dev/}, "")
26
+ output, = Open3.capture2e("ps", "-t", tty_name, "-o", "command=") # nosemgrep
27
+ output.each_line.any? { |line| line.match?(%r{/claude\b|^claude\b}i) }
28
+ rescue StandardError => error
29
+ Earl.logger.debug("Tmux.claude_on_tty? failed for #{tty}: #{error.message}")
30
+ false
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Earl
4
+ module Tmux
5
+ # Session lifecycle: create, kill, check existence.
6
+ module Sessions
7
+ def create_session(name:, command: nil, working_dir: nil)
8
+ cmd = build_session_args(name, command, working_dir)
9
+ execute(*cmd)
10
+ end
11
+
12
+ def create_window(**options)
13
+ build_create_window_args(options).then { |args| execute(*args) }
14
+ end
15
+
16
+ def kill_session(name)
17
+ execute("tmux", "kill-session", "-t", name)
18
+ rescue Error => error
19
+ raise NotFound, "Session '#{name}' not found" if error.message.include?("can't find")
20
+
21
+ raise
22
+ end
23
+
24
+ def session_exists?(name)
25
+ execute("tmux", "has-session", "-t", name)
26
+ true
27
+ rescue Error
28
+ false
29
+ end
30
+
31
+ private
32
+
33
+ def build_session_args(name, command, working_dir)
34
+ args = ["tmux", "new-session", "-d", "-s", name]
35
+ args.push("-c", working_dir) if working_dir
36
+ args.push(command) if command
37
+ args
38
+ end
39
+ end
40
+ end
41
+ end
data/lib/earl/tmux.rb ADDED
@@ -0,0 +1,122 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+ require_relative "tmux/parsing"
5
+ require_relative "tmux/sessions"
6
+ require_relative "tmux/processes"
7
+
8
+ module Earl
9
+ # Shell wrapper module for interacting with tmux. Provides methods for
10
+ # listing sessions/panes, capturing output, sending keys, and managing
11
+ # sessions. All commands use Open3.capture2e for safe shell execution.
12
+ module Tmux
13
+ # Raised when a tmux command fails with a non-zero exit status.
14
+ class Error < StandardError; end
15
+
16
+ # Raised when a tmux session or pane cannot be found.
17
+ class NotFound < Error; end
18
+
19
+ SEND_KEYS_DELAY = 0.1
20
+ FIELD_SEP = "|||"
21
+ # Configuration for wait_for_text polling behavior.
22
+ WaitConfig = Data.define(:timeout, :interval, :lines)
23
+ WAIT_DEFAULTS = { timeout: 15, interval: 0.5, lines: 200 }.freeze
24
+ # Specification for creating a new tmux window.
25
+ WindowSpec = Data.define(:session, :name, :command, :working_dir)
26
+
27
+ PANE_FIELDS = %w[pane_index pane_current_command pane_current_path pane_pid].freeze
28
+ ALL_PANE_FIELDS = %w[session_name window_index pane_index pane_current_command
29
+ pane_current_path pane_pid pane_tty].freeze
30
+
31
+ module_function
32
+
33
+ def available?
34
+ _, status = Open3.capture2e("which", "tmux")
35
+ status.success?
36
+ end
37
+
38
+ def list_sessions
39
+ fmt = build_format(%w[session_name session_attached session_created])
40
+ output = execute("tmux", "list-sessions", "-F", fmt)
41
+ output.each_line.filter_map { |line| parse_session_line(line) }
42
+ rescue Error => error
43
+ return [] if no_server_or_sessions?(error)
44
+
45
+ raise
46
+ end
47
+
48
+ def list_panes(session)
49
+ fmt = build_format(PANE_FIELDS)
50
+ output = execute("tmux", "list-panes", "-t", session, "-F", fmt)
51
+ parse_pane_lines(output, PANE_FIELDS.size)
52
+ rescue Error => error
53
+ raise NotFound, "Session '#{session}' not found" if error.message.include?("can't find")
54
+
55
+ raise
56
+ end
57
+
58
+ def list_all_panes
59
+ field_count = ALL_PANE_FIELDS.size
60
+ fmt = build_format(ALL_PANE_FIELDS)
61
+ output = execute("tmux", "list-panes", "-a", "-F", fmt)
62
+ output.each_line.filter_map { |line| parse_all_pane_line(line, field_count) }
63
+ rescue Error => error
64
+ return [] if no_server_or_sessions?(error)
65
+
66
+ raise
67
+ end
68
+
69
+ def capture_pane(target, lines: 100)
70
+ execute("tmux", "capture-pane", "-t", target, "-p", "-J", "-S", "-#{lines}")
71
+ rescue Error => error
72
+ raise NotFound, "Target '#{target}' not found" if error.message.include?("can't find")
73
+
74
+ raise
75
+ end
76
+
77
+ def send_keys(target, text)
78
+ execute("tmux", "send-keys", "-t", target, "-l", "--", text)
79
+ sleep SEND_KEYS_DELAY
80
+ execute("tmux", "send-keys", "-t", target, "Enter")
81
+ end
82
+
83
+ def send_keys_raw(target, key)
84
+ execute("tmux", "send-keys", "-t", target, key)
85
+ end
86
+
87
+ def wait_for_text(target, pattern, **)
88
+ config = WaitConfig.new(**WAIT_DEFAULTS, **)
89
+ regex = pattern.is_a?(Regexp) ? pattern : Regexp.new(pattern)
90
+ poll_until_match(target, regex, config)
91
+ end
92
+
93
+ class << self
94
+ include Parsing
95
+ include Sessions
96
+ include Processes
97
+
98
+ private
99
+
100
+ def execute(*cmd)
101
+ output, status = Open3.capture2e(*cmd)
102
+ raise Error, "tmux command failed: #{cmd.join(" ")}: #{output.strip}" unless status.success?
103
+
104
+ output
105
+ end
106
+
107
+ def poll_until_match(target, regex, config)
108
+ remaining = config.timeout
109
+ poll_interval = config.interval
110
+
111
+ loop do
112
+ output = capture_pane(target, lines: config.lines)
113
+ return output if output.match?(regex)
114
+ return nil if remaining <= 0
115
+
116
+ sleep poll_interval
117
+ remaining -= poll_interval
118
+ end
119
+ end
120
+ end
121
+ end
122
+ end