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