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,468 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "claude_session/stats"
|
|
4
|
+
|
|
5
|
+
module Earl
|
|
6
|
+
# Manages a single Claude CLI subprocess, handling JSON-stream I/O
|
|
7
|
+
# and emitting text/completion callbacks as responses arrive.
|
|
8
|
+
class ClaudeSession
|
|
9
|
+
include Logging
|
|
10
|
+
|
|
11
|
+
def self.mcp_config_dir
|
|
12
|
+
@mcp_config_dir ||= File.join(Earl.config_root, "mcp")
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def self.user_mcp_servers_path
|
|
16
|
+
@user_mcp_servers_path ||= File.join(Earl.config_root, "mcp_servers.json")
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def process_pid
|
|
20
|
+
@runtime.process_state.process&.pid
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Tracks the Claude CLI subprocess and its I/O threads.
|
|
24
|
+
ProcessState = Struct.new(:process, :stdin, :reader_thread, :stderr_thread, :wait_thread, keyword_init: true) do
|
|
25
|
+
def write(payload)
|
|
26
|
+
stdin.write(payload)
|
|
27
|
+
stdin.flush
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def join_io_threads
|
|
31
|
+
reader_thread&.join(3)
|
|
32
|
+
stderr_thread&.join(1)
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
# Holds text-streaming, tool-use, and completion callback procs.
|
|
36
|
+
Callbacks = Struct.new(:on_text, :on_complete, :on_tool_use, :on_system, keyword_init: true)
|
|
37
|
+
# Groups session launch options to keep instance variable count low.
|
|
38
|
+
Options = Struct.new(:permission_config, :resume, :working_dir, :username, :mcp_config_path, keyword_init: true)
|
|
39
|
+
# Groups mutable runtime state: subprocess, callbacks, and mutex.
|
|
40
|
+
RuntimeState = Struct.new(:process_state, :callbacks, :mutex, keyword_init: true)
|
|
41
|
+
|
|
42
|
+
# Removes MCP config files that don't match any active session ID.
|
|
43
|
+
def self.cleanup_mcp_configs(active_session_ids: [])
|
|
44
|
+
return unless Dir.exist?(mcp_config_dir)
|
|
45
|
+
|
|
46
|
+
active_set = Set.new(active_session_ids)
|
|
47
|
+
Dir.glob(File.join(mcp_config_dir, "earl-mcp-*.json")).each do |path|
|
|
48
|
+
session_id = File.basename(path).delete_prefix("earl-mcp-").delete_suffix(".json")
|
|
49
|
+
File.delete(path) unless active_set.include?(session_id)
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def initialize(session_id: SecureRandom.uuid, permission_config: nil, mode: :new, working_dir: nil, username: nil)
|
|
54
|
+
@session_id = session_id
|
|
55
|
+
@options = Options.new(
|
|
56
|
+
permission_config: permission_config, resume: mode == :resume,
|
|
57
|
+
working_dir: working_dir, username: username
|
|
58
|
+
)
|
|
59
|
+
@runtime = RuntimeState.new(
|
|
60
|
+
process_state: ProcessState.new, callbacks: Callbacks.new, mutex: Mutex.new
|
|
61
|
+
)
|
|
62
|
+
@stats = default_stats
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
attr_reader :session_id, :stats
|
|
66
|
+
|
|
67
|
+
def on_text(&block)
|
|
68
|
+
@runtime.callbacks.on_text = block
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def on_complete(&block)
|
|
72
|
+
@runtime.callbacks.on_complete = block
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def on_tool_use(&block)
|
|
76
|
+
@runtime.callbacks.on_tool_use = block
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def on_system(&block)
|
|
80
|
+
@runtime.callbacks.on_system = block
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def send_message(text)
|
|
84
|
+
return warn_dead_session unless alive?
|
|
85
|
+
|
|
86
|
+
write_to_stdin(text)
|
|
87
|
+
@stats.begin_turn
|
|
88
|
+
log(:debug, "Sent message to Claude #{short_id}: #{text[0..60]}")
|
|
89
|
+
true
|
|
90
|
+
rescue IOError, Errno::EPIPE => error
|
|
91
|
+
log(:error, "Failed to write to Claude #{short_id}: #{error.message}")
|
|
92
|
+
false
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
private
|
|
96
|
+
|
|
97
|
+
def warn_dead_session
|
|
98
|
+
log(:warn, "Cannot send message to dead session #{short_id} — process not running")
|
|
99
|
+
false
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def default_stats
|
|
103
|
+
Stats.new(
|
|
104
|
+
total_cost: 0.0, total_input_tokens: 0, total_output_tokens: 0,
|
|
105
|
+
turn_input_tokens: 0, turn_output_tokens: 0,
|
|
106
|
+
cache_read_tokens: 0, cache_creation_tokens: 0
|
|
107
|
+
)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def write_to_stdin(text)
|
|
111
|
+
payload = "#{JSON.generate({ type: "user", message: { role: "user", content: text } })}\n"
|
|
112
|
+
@runtime.mutex.synchronize { @runtime.process_state.write(payload) }
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def short_id
|
|
116
|
+
@session_id[0..7]
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# Process lifecycle management: start, kill, and signal handling.
|
|
120
|
+
module ProcessManagement
|
|
121
|
+
def start
|
|
122
|
+
stdin, stdout, stderr, wait_thread = open_process
|
|
123
|
+
@runtime.process_state = ProcessState.new(process: wait_thread, stdin: stdin, wait_thread: wait_thread)
|
|
124
|
+
|
|
125
|
+
log(:info, "Spawning Claude session #{@session_id} — resume with: claude --resume #{@session_id}")
|
|
126
|
+
spawn_io_threads(stdout, stderr)
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def alive?
|
|
130
|
+
@runtime.process_state.process&.alive?
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def kill
|
|
134
|
+
return unless (process = @runtime.process_state.process)
|
|
135
|
+
|
|
136
|
+
log(:info, "Killing Claude session #{short_id} (pid=#{process.pid})")
|
|
137
|
+
terminate_process
|
|
138
|
+
close_stdin
|
|
139
|
+
join_threads
|
|
140
|
+
remove_mcp_config
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
private
|
|
144
|
+
|
|
145
|
+
def open_process
|
|
146
|
+
working_dir = @options.working_dir || earl_project_dir
|
|
147
|
+
env = { "TMUX" => nil, "TMUX_PANE" => nil }
|
|
148
|
+
Open3.popen3(env, *cli_args, chdir: working_dir)
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def earl_project_dir
|
|
152
|
+
ENV.fetch("EARL_CLAUDE_HOME", File.join(Earl.config_root, "claude-home"))
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def join_threads
|
|
156
|
+
@runtime.process_state.join_io_threads
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def terminate_process
|
|
160
|
+
pid = @runtime.process_state.process.pid
|
|
161
|
+
Process.kill("INT", pid)
|
|
162
|
+
sleep 0.1
|
|
163
|
+
escalate_signal(pid)
|
|
164
|
+
rescue Errno::ESRCH
|
|
165
|
+
# Process already gone
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def escalate_signal(pid)
|
|
169
|
+
2.times do
|
|
170
|
+
return unless @runtime.process_state.process.alive?
|
|
171
|
+
|
|
172
|
+
sleep 1
|
|
173
|
+
end
|
|
174
|
+
Process.kill("TERM", pid)
|
|
175
|
+
rescue Errno::ESRCH
|
|
176
|
+
# Process exited between alive? check and kill
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def close_stdin
|
|
180
|
+
@runtime.process_state.stdin&.close
|
|
181
|
+
rescue IOError
|
|
182
|
+
# Already closed
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def remove_mcp_config
|
|
186
|
+
path = File.join(self.class.mcp_config_dir, "earl-mcp-#{@session_id}.json")
|
|
187
|
+
FileUtils.rm_f(path)
|
|
188
|
+
end
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
# Builds the CLI argument list for spawning the Claude process.
|
|
192
|
+
module CliArgBuilder
|
|
193
|
+
private
|
|
194
|
+
|
|
195
|
+
def cli_args
|
|
196
|
+
["claude", "--input-format", "stream-json", "--output-format", "stream-json", "--verbose",
|
|
197
|
+
*model_args, *session_args, *permission_args, *system_prompt_args]
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
def model_args
|
|
201
|
+
model = ENV.fetch("EARL_MODEL", nil)
|
|
202
|
+
model ? ["--model", model] : []
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
def session_args
|
|
206
|
+
@options.resume ? ["--resume", @session_id] : ["--session-id", @session_id]
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
def permission_args
|
|
210
|
+
return ["--dangerously-skip-permissions"] unless @options.permission_config
|
|
211
|
+
|
|
212
|
+
["--permission-prompt-tool", "mcp__earl__permission_prompt",
|
|
213
|
+
"--mcp-config", mcp_config_path]
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
def system_prompt_args
|
|
217
|
+
prompt = Memory::PromptBuilder.new(store: Memory::Store.new).build
|
|
218
|
+
prompt ? ["--append-system-prompt", prompt] : []
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
def mcp_config_path
|
|
222
|
+
@options.mcp_config_path ||= write_mcp_config
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
def write_mcp_config
|
|
226
|
+
all_servers = load_user_mcp_servers.merge(build_earl_server_entry)
|
|
227
|
+
json = JSON.generate({ mcpServers: all_servers })
|
|
228
|
+
write_mcp_config_file(json)
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
def build_earl_server_entry
|
|
232
|
+
{
|
|
233
|
+
earl: {
|
|
234
|
+
command: File.expand_path("../../exe/earl-permission-server", __dir__),
|
|
235
|
+
args: [],
|
|
236
|
+
env: @options.permission_config.merge(
|
|
237
|
+
"EARL_CURRENT_USERNAME" => @options.username || ""
|
|
238
|
+
)
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
def load_user_mcp_servers
|
|
244
|
+
path = self.class.user_mcp_servers_path
|
|
245
|
+
return {} unless File.exist?(path)
|
|
246
|
+
|
|
247
|
+
parsed = JSON.parse(File.read(path))
|
|
248
|
+
symbolize_mcp_servers(parsed.fetch("mcpServers", nil))
|
|
249
|
+
rescue JSON::ParserError => error
|
|
250
|
+
log(:warn, "Malformed #{path}: #{error.message}")
|
|
251
|
+
{}
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
def symbolize_mcp_servers(servers)
|
|
255
|
+
servers.is_a?(Hash) ? servers.transform_keys(&:to_sym) : {}
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
def write_mcp_config_file(json)
|
|
259
|
+
path = mcp_config_file_path
|
|
260
|
+
FileUtils.mkdir_p(self.class.mcp_config_dir, mode: 0o700)
|
|
261
|
+
write_exclusive(path, json)
|
|
262
|
+
rescue Errno::EEXIST
|
|
263
|
+
write_overwrite(path, json)
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
def write_exclusive(path, content)
|
|
267
|
+
File.open(path, File::CREAT | File::EXCL | File::WRONLY, 0o600) { |file| file.write(content) }
|
|
268
|
+
path
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
def write_overwrite(path, content)
|
|
272
|
+
File.open(path, File::WRONLY | File::TRUNC, 0o600) { |file| file.write(content) }
|
|
273
|
+
path
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
def mcp_config_file_path
|
|
277
|
+
File.join(self.class.mcp_config_dir, "earl-mcp-#{@session_id}.json")
|
|
278
|
+
end
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
# Event/IO processing methods extracted to reduce class method count.
|
|
282
|
+
module EventProcessing
|
|
283
|
+
private
|
|
284
|
+
|
|
285
|
+
def spawn_io_threads(stdout, stderr)
|
|
286
|
+
ps = @runtime.process_state
|
|
287
|
+
ps.reader_thread = Thread.new { read_stdout(stdout) }
|
|
288
|
+
ps.stderr_thread = Thread.new { read_stderr(stderr) }
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
def read_stdout(stdout)
|
|
292
|
+
stdout.each_line do |line|
|
|
293
|
+
process_line(line.strip)
|
|
294
|
+
end
|
|
295
|
+
rescue IOError
|
|
296
|
+
log(:debug, "Claude stdout stream closed (session #{short_id})")
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
def process_line(line)
|
|
300
|
+
return if line.empty?
|
|
301
|
+
|
|
302
|
+
event = parse_json(line)
|
|
303
|
+
handle_event(event) if event
|
|
304
|
+
end
|
|
305
|
+
|
|
306
|
+
def parse_json(line)
|
|
307
|
+
JSON.parse(line)
|
|
308
|
+
rescue JSON::ParserError => error
|
|
309
|
+
log(:warn, "Unparsable Claude stdout (session #{short_id}): #{line[0..200]} — #{error.message}")
|
|
310
|
+
nil
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
def read_stderr(stderr)
|
|
314
|
+
stderr.each_line do |line|
|
|
315
|
+
log(:debug, "Claude stderr: #{line.strip}")
|
|
316
|
+
end
|
|
317
|
+
rescue IOError
|
|
318
|
+
log(:debug, "Claude stderr stream closed (session #{short_id})")
|
|
319
|
+
end
|
|
320
|
+
|
|
321
|
+
def handle_event(event)
|
|
322
|
+
case event["type"]
|
|
323
|
+
when "system" then handle_system_event(event)
|
|
324
|
+
when "assistant" then handle_assistant_event(event)
|
|
325
|
+
when "result" then handle_result_event(event)
|
|
326
|
+
end
|
|
327
|
+
end
|
|
328
|
+
|
|
329
|
+
def handle_system_event(event)
|
|
330
|
+
subtype = event["subtype"]
|
|
331
|
+
log(:debug, "Claude system: #{subtype}")
|
|
332
|
+
message = event["message"]
|
|
333
|
+
@runtime.callbacks.on_system&.call(subtype: subtype, message: message) if message
|
|
334
|
+
end
|
|
335
|
+
|
|
336
|
+
def handle_assistant_event(event)
|
|
337
|
+
content = event.dig("message", "content")
|
|
338
|
+
return unless content.is_a?(Array)
|
|
339
|
+
|
|
340
|
+
emit_text_content(content)
|
|
341
|
+
emit_tool_use_blocks(content)
|
|
342
|
+
end
|
|
343
|
+
|
|
344
|
+
def emit_text_content(content)
|
|
345
|
+
text = content.filter_map { |item| item["text"] if item["type"] == "text" }.join
|
|
346
|
+
return if text.empty?
|
|
347
|
+
|
|
348
|
+
@stats.first_token_at ||= Time.now
|
|
349
|
+
@runtime.callbacks.on_text&.call(text)
|
|
350
|
+
end
|
|
351
|
+
|
|
352
|
+
def emit_tool_use_blocks(content)
|
|
353
|
+
content.each do |item|
|
|
354
|
+
emit_single_tool_use(item) if item["type"] == "tool_use"
|
|
355
|
+
end
|
|
356
|
+
end
|
|
357
|
+
|
|
358
|
+
def emit_single_tool_use(item)
|
|
359
|
+
tool_id, tool_name, tool_input = item.values_at("id", "name", "input")
|
|
360
|
+
@runtime.callbacks.on_tool_use&.call(id: tool_id, name: tool_name, input: tool_input)
|
|
361
|
+
end
|
|
362
|
+
end
|
|
363
|
+
|
|
364
|
+
# Processes result events and updates session statistics.
|
|
365
|
+
module ResultProcessing
|
|
366
|
+
private
|
|
367
|
+
|
|
368
|
+
def handle_result_event(event)
|
|
369
|
+
@stats.complete_at = Time.now
|
|
370
|
+
update_stats_from_result(event)
|
|
371
|
+
log(:info, format_result_log)
|
|
372
|
+
@runtime.callbacks.on_complete&.call(self)
|
|
373
|
+
end
|
|
374
|
+
|
|
375
|
+
def update_stats_from_result(event)
|
|
376
|
+
cost = event["total_cost_usd"]
|
|
377
|
+
@stats.total_cost = cost if cost
|
|
378
|
+
extract_usage(event["usage"])
|
|
379
|
+
extract_model_usage(event["modelUsage"])
|
|
380
|
+
end
|
|
381
|
+
|
|
382
|
+
def extract_usage(usage)
|
|
383
|
+
return unless usage.is_a?(Hash)
|
|
384
|
+
|
|
385
|
+
input, output, cache_read, cache_create = usage.values_at(
|
|
386
|
+
"input_tokens", "output_tokens", "cache_read_input_tokens", "cache_creation_input_tokens"
|
|
387
|
+
)
|
|
388
|
+
@stats.turn_input_tokens = input || 0
|
|
389
|
+
@stats.turn_output_tokens = output || 0
|
|
390
|
+
@stats.cache_read_tokens = cache_read || 0
|
|
391
|
+
@stats.cache_creation_tokens = cache_create || 0
|
|
392
|
+
end
|
|
393
|
+
|
|
394
|
+
def extract_model_usage(model_usage)
|
|
395
|
+
return unless model_usage.is_a?(Hash)
|
|
396
|
+
|
|
397
|
+
apply_hash_entries(model_usage)
|
|
398
|
+
end
|
|
399
|
+
|
|
400
|
+
def apply_hash_entries(model_usage)
|
|
401
|
+
entries = model_usage.select { |_, val| val.is_a?(Hash) }
|
|
402
|
+
apply_primary_model_stats(entries) unless entries.empty?
|
|
403
|
+
end
|
|
404
|
+
|
|
405
|
+
def apply_primary_model_stats(entries)
|
|
406
|
+
primary_id, primary_data = entries.max_by { |_, data| data["contextWindow"] || 0 }
|
|
407
|
+
apply_model_stats(primary_id, primary_data, entries)
|
|
408
|
+
end
|
|
409
|
+
|
|
410
|
+
def apply_model_stats(model_id, primary_data, entries)
|
|
411
|
+
@stats.model_id = model_id
|
|
412
|
+
totals = entries.each_with_object({ input: 0, output: 0 }) do |(_, data), acc|
|
|
413
|
+
acc[:input] += data["inputTokens"] || 0
|
|
414
|
+
acc[:output] += data["outputTokens"] || 0
|
|
415
|
+
end
|
|
416
|
+
@stats.total_input_tokens = totals[:input]
|
|
417
|
+
@stats.total_output_tokens = totals[:output]
|
|
418
|
+
context = primary_data["contextWindow"]
|
|
419
|
+
@stats.context_window = context if context
|
|
420
|
+
end
|
|
421
|
+
|
|
422
|
+
def format_result_log
|
|
423
|
+
model = @stats.model_id
|
|
424
|
+
parts = [
|
|
425
|
+
"Claude result:",
|
|
426
|
+
format_token_counts,
|
|
427
|
+
format_context_usage,
|
|
428
|
+
format_timing,
|
|
429
|
+
format_cost,
|
|
430
|
+
("model=#{model}" if model)
|
|
431
|
+
]
|
|
432
|
+
parts.compact.join(" | ")
|
|
433
|
+
end
|
|
434
|
+
|
|
435
|
+
def format_token_counts
|
|
436
|
+
turn_in = @stats.turn_input_tokens
|
|
437
|
+
turn_out = @stats.turn_output_tokens
|
|
438
|
+
total = @stats.total_input_tokens + @stats.total_output_tokens
|
|
439
|
+
"#{total} total tokens (turn: in:#{turn_in} out:#{turn_out})"
|
|
440
|
+
end
|
|
441
|
+
|
|
442
|
+
def format_context_usage
|
|
443
|
+
pct = @stats.context_percent
|
|
444
|
+
return nil unless pct
|
|
445
|
+
|
|
446
|
+
format("%.0f%% context used", pct)
|
|
447
|
+
end
|
|
448
|
+
|
|
449
|
+
def format_timing
|
|
450
|
+
ttft = @stats.time_to_first_token
|
|
451
|
+
tps = @stats.tokens_per_second
|
|
452
|
+
parts = []
|
|
453
|
+
parts << format("TTFT: %.1fs", ttft) if ttft
|
|
454
|
+
parts << format("%.0f tok/s", tps) if tps
|
|
455
|
+
parts.empty? ? nil : parts.join(" ")
|
|
456
|
+
end
|
|
457
|
+
|
|
458
|
+
def format_cost
|
|
459
|
+
format("cost=$%.4f", @stats.total_cost)
|
|
460
|
+
end
|
|
461
|
+
end
|
|
462
|
+
|
|
463
|
+
include ProcessManagement
|
|
464
|
+
include CliArgBuilder
|
|
465
|
+
include EventProcessing
|
|
466
|
+
include ResultProcessing
|
|
467
|
+
end
|
|
468
|
+
end
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Earl
|
|
4
|
+
class CommandExecutor
|
|
5
|
+
# Constants shared across CommandExecutor: help table, dispatch map, scripts.
|
|
6
|
+
module Constants
|
|
7
|
+
HELP_TABLE = <<~HELP
|
|
8
|
+
| Command | Description |
|
|
9
|
+
|---------|-------------|
|
|
10
|
+
| `!help` | Show this help table |
|
|
11
|
+
| `!stats` | Show session stats (tokens, context, cost) |
|
|
12
|
+
| `!usage` | Show Claude subscription usage limits |
|
|
13
|
+
| `!context` | Show context window usage for current session |
|
|
14
|
+
| `!stop` | Kill current session |
|
|
15
|
+
| `!escape` | Send SIGINT to Claude (interrupt) |
|
|
16
|
+
| `!kill` | Force kill session |
|
|
17
|
+
| `!compact` | Compact Claude's context |
|
|
18
|
+
| `!cd <path>` | Set working directory for next session |
|
|
19
|
+
| `!permissions` | Show current permission mode |
|
|
20
|
+
| `!heartbeats` | Show heartbeat schedule status |
|
|
21
|
+
| `!sessions` | List all tmux sessions |
|
|
22
|
+
| `!session <name>` | Capture and show tmux pane output |
|
|
23
|
+
| `!session <name> status` | AI-summarize session state |
|
|
24
|
+
| `!session <name> kill` | Kill tmux session |
|
|
25
|
+
| `!session <name> nudge` | Send nudge message to session |
|
|
26
|
+
| `!session <name> approve` | Approve pending permission |
|
|
27
|
+
| `!session <name> deny` | Deny pending permission |
|
|
28
|
+
| `!session <name> "text"` | Send input to tmux session |
|
|
29
|
+
| `!update` | Pull latest code + bundle install, then restart |
|
|
30
|
+
| `!restart` | Restart EARL (pulls latest code in prod) |
|
|
31
|
+
| `!spawn "prompt" [--name N] [--dir D]` | Spawn Claude in a new tmux session |
|
|
32
|
+
HELP
|
|
33
|
+
|
|
34
|
+
PASSTHROUGH_COMMANDS = { compact: "/compact" }.freeze
|
|
35
|
+
|
|
36
|
+
DISPATCH = {
|
|
37
|
+
help: :handle_help, stats: :handle_stats, stop: :handle_stop,
|
|
38
|
+
escape: :handle_escape, kill: :handle_kill, cd: :handle_cd,
|
|
39
|
+
permissions: :handle_permissions, heartbeats: :handle_heartbeats,
|
|
40
|
+
usage: :handle_usage, context: :handle_context,
|
|
41
|
+
sessions: :handle_sessions, session_show: :handle_session_show,
|
|
42
|
+
session_status: :handle_session_status, session_kill: :handle_session_kill,
|
|
43
|
+
session_nudge: :handle_session_nudge, session_approve: :handle_session_approve,
|
|
44
|
+
session_deny: :handle_session_deny, session_input: :handle_session_input,
|
|
45
|
+
update: :handle_update, restart: :handle_restart,
|
|
46
|
+
spawn: :handle_spawn
|
|
47
|
+
}.freeze
|
|
48
|
+
|
|
49
|
+
USAGE_SCRIPT = File.expand_path("../../../bin/claude-usage", __dir__)
|
|
50
|
+
CONTEXT_SCRIPT = File.expand_path("../../../bin/claude-context", __dir__)
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Earl
|
|
4
|
+
class CommandExecutor
|
|
5
|
+
# Formats and displays heartbeat schedule status for the !heartbeats command.
|
|
6
|
+
module HeartbeatDisplay
|
|
7
|
+
private
|
|
8
|
+
|
|
9
|
+
def handle_heartbeats(ctx)
|
|
10
|
+
scheduler = @deps.heartbeat_scheduler
|
|
11
|
+
return reply(ctx, "Heartbeat scheduler not configured.") unless scheduler
|
|
12
|
+
|
|
13
|
+
statuses = scheduler.status
|
|
14
|
+
return reply(ctx, "No heartbeats configured.") if statuses.empty?
|
|
15
|
+
|
|
16
|
+
reply(ctx, format_heartbeats(statuses))
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def format_heartbeats(statuses)
|
|
20
|
+
header = [
|
|
21
|
+
"#### \u{1FAC0} Heartbeat Status",
|
|
22
|
+
"| Name | Next Run | Last Run | Runs | Status |",
|
|
23
|
+
"|------|----------|----------|------|--------|"
|
|
24
|
+
]
|
|
25
|
+
rows = statuses.map { |entry| format_heartbeat_row(entry) }
|
|
26
|
+
(header + rows).join("\n")
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def format_heartbeat_row(status)
|
|
30
|
+
name, run_count, next_at, last_at = status.values_at(:name, :run_count, :next_run_at, :last_run_at)
|
|
31
|
+
next_run = format_time(next_at)
|
|
32
|
+
last_run = format_time(last_at)
|
|
33
|
+
state = heartbeat_status_label(status)
|
|
34
|
+
"| #{name} | #{next_run} | #{last_run} | #{run_count} | #{state} |"
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def heartbeat_status_label(status)
|
|
38
|
+
if status[:running]
|
|
39
|
+
"\u{1F7E2} Running"
|
|
40
|
+
elsif status[:last_error]
|
|
41
|
+
"\u{1F534} Error"
|
|
42
|
+
else
|
|
43
|
+
"\u26AA Idle"
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def format_time(time)
|
|
48
|
+
return "\u2014" unless time
|
|
49
|
+
|
|
50
|
+
time.strftime("%Y-%m-%d %H:%M")
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Earl
|
|
4
|
+
class CommandExecutor
|
|
5
|
+
# Handles session lifecycle commands: !stop, !escape, !kill, !cd.
|
|
6
|
+
module LifecycleHandler
|
|
7
|
+
private
|
|
8
|
+
|
|
9
|
+
def handle_stop(ctx)
|
|
10
|
+
@deps.session_manager.stop_session(ctx.thread_id)
|
|
11
|
+
reply(ctx, ":stop_sign: Session stopped.")
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def handle_escape(ctx)
|
|
15
|
+
session = @deps.session_manager.get(ctx.thread_id)
|
|
16
|
+
if session&.process_pid
|
|
17
|
+
Process.kill("INT", session.process_pid)
|
|
18
|
+
reply(ctx, ":warning: Sent SIGINT to Claude.")
|
|
19
|
+
else
|
|
20
|
+
reply(ctx, "No active session to interrupt.")
|
|
21
|
+
end
|
|
22
|
+
rescue Errno::ESRCH
|
|
23
|
+
reply(ctx, "Process already exited.")
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def handle_kill(ctx)
|
|
27
|
+
session = @deps.session_manager.get(ctx.thread_id)
|
|
28
|
+
if session&.process_pid
|
|
29
|
+
Process.kill("KILL", session.process_pid)
|
|
30
|
+
cleanup_and_reply(ctx, ":skull: Session force killed.")
|
|
31
|
+
else
|
|
32
|
+
reply(ctx, "No active session to kill.")
|
|
33
|
+
end
|
|
34
|
+
rescue Errno::ESRCH
|
|
35
|
+
cleanup_and_reply(ctx, "Process already exited, session cleaned up.")
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def handle_cd(ctx)
|
|
39
|
+
cleaned = ctx.arg.to_s.strip
|
|
40
|
+
return reply(ctx, ":x: Usage: `!cd <path>`") if cleaned.empty?
|
|
41
|
+
|
|
42
|
+
expanded = File.expand_path(cleaned)
|
|
43
|
+
apply_working_dir(ctx, expanded)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def apply_working_dir(ctx, expanded)
|
|
47
|
+
if Dir.exist?(expanded)
|
|
48
|
+
@working_dirs[ctx.thread_id] = expanded
|
|
49
|
+
reply(ctx, ":file_folder: Working directory set to `#{expanded}` (applies to next new session)")
|
|
50
|
+
else
|
|
51
|
+
reply(ctx, ":x: Directory not found: `#{expanded}`")
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def cleanup_and_reply(ctx, message)
|
|
56
|
+
@deps.session_manager.stop_session(ctx.thread_id)
|
|
57
|
+
reply(ctx, message)
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|