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,497 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "open3"
|
|
4
|
+
|
|
5
|
+
module Earl
|
|
6
|
+
# Automates Safari via osascript (JavaScript for Automation) to interact with
|
|
7
|
+
# web pages. Used by GithubPatHandler to create fine-grained PATs on github.com.
|
|
8
|
+
#
|
|
9
|
+
# Dynamic string values that appear inside doJavaScript() are declared as JXA
|
|
10
|
+
# variables and escaped into single-quoted JS literals to avoid double escaping
|
|
11
|
+
# (Ruby heredoc -> JXA string -> inner JS string). Numeric values are
|
|
12
|
+
# interpolated directly since they need no quoting.
|
|
13
|
+
#
|
|
14
|
+
# Most DOM methods return an 'OK'/'NOT_FOUND:...' status string from inner JS
|
|
15
|
+
# and raise SafariAutomation::Error if the expected element is not found.
|
|
16
|
+
module SafariAutomation
|
|
17
|
+
# Raised when Safari/osascript automation fails (element not found, process error).
|
|
18
|
+
class Error < StandardError; end
|
|
19
|
+
|
|
20
|
+
# Standard JS escaping for JXA variables: escapes backslashes and single quotes.
|
|
21
|
+
JS_ESCAPE_LINES = <<~'JS'.chomp
|
|
22
|
+
var esc = val.replace(/\\\\/g, "\\\\\\\\").replace(/'/g, "\\\\'");
|
|
23
|
+
JS
|
|
24
|
+
|
|
25
|
+
module_function
|
|
26
|
+
|
|
27
|
+
def navigate(url)
|
|
28
|
+
execute_js <<~JS
|
|
29
|
+
var safari = Application("Safari");
|
|
30
|
+
safari.activate();
|
|
31
|
+
var win = safari.windows[0];
|
|
32
|
+
if (!win) { safari.Document().make(); win = safari.windows[0]; }
|
|
33
|
+
win.currentTab.url = #{url.to_json};
|
|
34
|
+
JS
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Shared helper: wraps an inner JS expression in doJavaScript on the active tab.
|
|
38
|
+
def execute_do_js(inner_js)
|
|
39
|
+
execute_js <<~JS
|
|
40
|
+
var safari = Application("Safari");
|
|
41
|
+
var tab = safari.windows[0].currentTab;
|
|
42
|
+
safari.doJavaScript("#{inner_js}", {in: tab});
|
|
43
|
+
JS
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Executes doJavaScript with JXA variable declarations prepended.
|
|
47
|
+
# Vars are set in the JXA scope (outside doJavaScript), body runs inside the browser.
|
|
48
|
+
def execute_do_js_with_vars(vars, body)
|
|
49
|
+
execute_js <<~JS
|
|
50
|
+
var safari = Application("Safari");
|
|
51
|
+
var tab = safari.windows[0].currentTab;
|
|
52
|
+
#{vars}
|
|
53
|
+
safari.doJavaScript(#{body}, {in: tab});
|
|
54
|
+
JS
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def execute_js(script)
|
|
58
|
+
output, status = Open3.capture2e("osascript", "-l", "JavaScript", "-e", script)
|
|
59
|
+
raise Error, "osascript failed (exit #{status.exitstatus}): #{output}" unless status.success?
|
|
60
|
+
|
|
61
|
+
output
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def check_result!(output, element_description)
|
|
65
|
+
return unless output.strip.start_with?("NOT_FOUND")
|
|
66
|
+
|
|
67
|
+
raise Error, "Could not find #{element_description} on page"
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Builds JXA variable declarations for a single escaped value.
|
|
71
|
+
def escape_vars(value, var_name: "val")
|
|
72
|
+
<<~JS.chomp
|
|
73
|
+
var #{var_name} = #{value.to_json};
|
|
74
|
+
var esc = #{var_name}.replace(/\\\\/g, "\\\\\\\\").replace(/'/g, "\\\\'");
|
|
75
|
+
JS
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Navigation flow: fills form fields.
|
|
79
|
+
module NavigationHelpers
|
|
80
|
+
module_function
|
|
81
|
+
|
|
82
|
+
def fill_token_name(name)
|
|
83
|
+
vars = SafariAutomation.escape_vars(name)
|
|
84
|
+
body = token_name_body
|
|
85
|
+
output = SafariAutomation.execute_do_js_with_vars(vars, body)
|
|
86
|
+
SafariAutomation.check_result!(output, "token name input field")
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def token_name_body
|
|
90
|
+
<<~BODY.chomp
|
|
91
|
+
"(function() {" +
|
|
92
|
+
" var el = document.getElementById('user_programmatic_access_name')" +
|
|
93
|
+
" || document.querySelector('input[name*=programmatic_access]');" +
|
|
94
|
+
" if (!el) return 'NOT_FOUND:token_name_input';" +
|
|
95
|
+
" el.value = '" + esc + "'; el.dispatchEvent(new Event('input', {bubbles: true}));" +
|
|
96
|
+
" return 'OK';" +
|
|
97
|
+
"})()"
|
|
98
|
+
BODY
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
include NavigationHelpers
|
|
103
|
+
|
|
104
|
+
# Expiration flow: click button, select Custom, set date.
|
|
105
|
+
module ExpirationHelpers
|
|
106
|
+
module_function
|
|
107
|
+
|
|
108
|
+
def apply_expiration(days)
|
|
109
|
+
select_custom_expiration
|
|
110
|
+
sleep 0.5
|
|
111
|
+
apply_expiration_date(days)
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def select_custom_expiration
|
|
115
|
+
click_expiration_button
|
|
116
|
+
sleep 0.5
|
|
117
|
+
click_custom_option
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def click_expiration_button
|
|
121
|
+
output = SafariAutomation.execute_do_js(expiration_button_body)
|
|
122
|
+
SafariAutomation.check_result!(output, "expiration dropdown button")
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def click_custom_option
|
|
126
|
+
output = SafariAutomation.execute_do_js(custom_option_body)
|
|
127
|
+
SafariAutomation.check_result!(output, "'Custom' expiration option")
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def apply_expiration_date(days)
|
|
131
|
+
output = SafariAutomation.execute_do_js(date_input_body(days))
|
|
132
|
+
SafariAutomation.check_result!(output, "expiration date input")
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def expiration_button_body
|
|
136
|
+
"(function() { " \
|
|
137
|
+
"var btns = document.querySelectorAll('button'); " \
|
|
138
|
+
"for (var i = 0; i < btns.length; i++) { " \
|
|
139
|
+
"var t = btns[i].textContent.trim(); " \
|
|
140
|
+
"if (t.indexOf('days') !== -1 && t.indexOf('expir') === -1) { btns[i].click(); return 'OK'; } " \
|
|
141
|
+
"} " \
|
|
142
|
+
"return 'NOT_FOUND:expiration_button';" \
|
|
143
|
+
"})()"
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def custom_option_body
|
|
147
|
+
"(function() { " \
|
|
148
|
+
"var items = document.querySelectorAll('[role=menuitemradio]'); " \
|
|
149
|
+
"for (var i = 0; i < items.length; i++) { " \
|
|
150
|
+
"if (items[i].textContent.trim() === 'Custom') { items[i].click(); return 'OK'; } " \
|
|
151
|
+
"} " \
|
|
152
|
+
"return 'NOT_FOUND:custom_expiration_option';" \
|
|
153
|
+
"})()"
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def date_input_body(days)
|
|
157
|
+
"(function() { " \
|
|
158
|
+
"var input = document.querySelector('input[type=date]'); " \
|
|
159
|
+
"if (!input) return 'NOT_FOUND:expiration_date_input'; " \
|
|
160
|
+
"var d = new Date(); d.setDate(d.getDate() + #{days.to_i}); " \
|
|
161
|
+
"var val = d.toISOString().split('T')[0]; " \
|
|
162
|
+
"if (input.max && val > input.max) val = input.max; " \
|
|
163
|
+
"input.value = val;#{date_input_events} " \
|
|
164
|
+
"return 'OK';" \
|
|
165
|
+
"})()"
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def date_input_events
|
|
169
|
+
" input.dispatchEvent(new Event('input', {bubbles: true})); " \
|
|
170
|
+
"input.dispatchEvent(new Event('change', {bubbles: true}));"
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
include ExpirationHelpers
|
|
175
|
+
|
|
176
|
+
alias set_expiration apply_expiration
|
|
177
|
+
module_function :set_expiration
|
|
178
|
+
|
|
179
|
+
# Repository selection flow: click radio, open dialog, filter, select, close.
|
|
180
|
+
module RepositoryRadioHelpers
|
|
181
|
+
module_function
|
|
182
|
+
|
|
183
|
+
def click_select_repositories_radio
|
|
184
|
+
output = SafariAutomation.execute_do_js(select_repos_radio_body)
|
|
185
|
+
SafariAutomation.check_result!(output, "'Only select repositories' radio button")
|
|
186
|
+
sleep 0.5
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
def open_repository_dialog
|
|
190
|
+
output = SafariAutomation.execute_do_js(open_repo_dialog_body)
|
|
191
|
+
SafariAutomation.check_result!(output, "'Select repositories' button")
|
|
192
|
+
sleep 0.5
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
def close_repository_dialog
|
|
196
|
+
output = SafariAutomation.execute_do_js(close_dialog_body)
|
|
197
|
+
SafariAutomation.check_result!(output, "repository dialog close button")
|
|
198
|
+
sleep 1
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
def select_repos_radio_body
|
|
202
|
+
"(function() { " \
|
|
203
|
+
"var radio = document.querySelector('input[value=selected][name=install_target]'); " \
|
|
204
|
+
"if (!radio) return 'NOT_FOUND:select_repos_radio'; " \
|
|
205
|
+
"radio.click(); return 'OK';" \
|
|
206
|
+
"})()"
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
def open_repo_dialog_body
|
|
210
|
+
"(function() { " \
|
|
211
|
+
"var buttons = document.querySelectorAll('button'); " \
|
|
212
|
+
"for (var i = 0; i < buttons.length; i++) { " \
|
|
213
|
+
"if (buttons[i].textContent.trim().indexOf('Select repositor') !== -1) { " \
|
|
214
|
+
"buttons[i].click(); return 'OK'; } " \
|
|
215
|
+
"} " \
|
|
216
|
+
"return 'NOT_FOUND:select_repos_button';" \
|
|
217
|
+
"})()"
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
def close_dialog_body
|
|
221
|
+
"(function() { " \
|
|
222
|
+
"var dialog = document.querySelector('dialog[open]'); " \
|
|
223
|
+
"if (!dialog) return 'OK'; " \
|
|
224
|
+
"var closeBtn = dialog.querySelector('button[aria-label=Close]'); " \
|
|
225
|
+
"if (closeBtn) { closeBtn.click(); return 'OK'; } " \
|
|
226
|
+
"return 'NOT_FOUND:dialog_close_button';" \
|
|
227
|
+
"})()"
|
|
228
|
+
end
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
include RepositoryRadioHelpers
|
|
232
|
+
|
|
233
|
+
# Repository search and selection within the dialog.
|
|
234
|
+
module RepositorySearchHelpers
|
|
235
|
+
module_function
|
|
236
|
+
|
|
237
|
+
def select_repository(repo)
|
|
238
|
+
RepositoryRadioHelpers.click_select_repositories_radio
|
|
239
|
+
RepositoryRadioHelpers.open_repository_dialog
|
|
240
|
+
filter_and_select_repo(repo)
|
|
241
|
+
RepositoryRadioHelpers.close_repository_dialog
|
|
242
|
+
verify_repository_selected!(repo)
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
def filter_and_select_repo(repo)
|
|
246
|
+
repo_name = repo.include?("/") ? repo.split("/", 2).last : repo
|
|
247
|
+
filter_repo_search(repo_name)
|
|
248
|
+
sleep 1
|
|
249
|
+
click_repo_option(repo)
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
def filter_repo_search(repo_name)
|
|
253
|
+
vars = SafariAutomation.escape_vars(repo_name, var_name: "searchTerm")
|
|
254
|
+
body = repo_filter_body
|
|
255
|
+
output = SafariAutomation.execute_do_js_with_vars(vars, body)
|
|
256
|
+
SafariAutomation.check_result!(output, "repository search dialog")
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
def click_repo_option(repo)
|
|
260
|
+
vars = SafariAutomation.escape_vars(repo, var_name: "fullRepo")
|
|
261
|
+
body = repo_option_body
|
|
262
|
+
output = SafariAutomation.execute_do_js_with_vars(vars, body)
|
|
263
|
+
SafariAutomation.check_result!(output, "repository '#{repo}' in search results")
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
def verify_repository_selected!(repo)
|
|
267
|
+
vars = SafariAutomation.escape_vars(repo, var_name: "repoName")
|
|
268
|
+
body = verify_repo_body
|
|
269
|
+
output = SafariAutomation.execute_do_js_with_vars(vars, body)
|
|
270
|
+
SafariAutomation.check_result!(output, "repository '#{repo}' selection (token would scope to ALL repos)")
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
def repo_filter_body
|
|
274
|
+
'"(function() {" +' \
|
|
275
|
+
'" var dialog = document.querySelector(\'dialog[open]\');" +' \
|
|
276
|
+
'" if (!dialog) return \'NOT_FOUND:repo_dialog\';" +' \
|
|
277
|
+
'" var search = dialog.querySelector(\'input[name=filter], input[type=search]\');" +' \
|
|
278
|
+
'" if (!search) return \'NOT_FOUND:repo_search_input\';" +' \
|
|
279
|
+
'" search.value = \'" + esc + "\';" +' \
|
|
280
|
+
'" search.dispatchEvent(new Event(\'input\', {bubbles: true}));" +' \
|
|
281
|
+
'" return \'OK\';" +' \
|
|
282
|
+
'" })()"'
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
def repo_option_body
|
|
286
|
+
'"(function() {" +' \
|
|
287
|
+
'" var dialog = document.querySelector(\'dialog[open]\');" +' \
|
|
288
|
+
'" if (!dialog) return \'NOT_FOUND:repo_dialog\';" +' \
|
|
289
|
+
'" var options = dialog.querySelectorAll(\'[role=option]\');" +' \
|
|
290
|
+
'" for (var i = 0; i < options.length; i++) {" +' \
|
|
291
|
+
'" if (options[i].textContent.indexOf(\'" + esc + "\') !== -1) {" +' \
|
|
292
|
+
'" options[i].click(); return \'OK\'; }" +' \
|
|
293
|
+
'" }" +' \
|
|
294
|
+
'" return \'NOT_FOUND:repo_option\';" +' \
|
|
295
|
+
'" })()"'
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
def verify_repo_body
|
|
299
|
+
<<~BODY.chomp
|
|
300
|
+
"(function() {" +
|
|
301
|
+
" var page = document.body.innerText;" +
|
|
302
|
+
" if (page.indexOf('" + esc + "') !== -1 && " +
|
|
303
|
+
" (page.indexOf('1 repository') !== -1 || " +
|
|
304
|
+
" page.indexOf('Selected') !== -1)) return 'OK';" +
|
|
305
|
+
" return 'NOT_FOUND:repository_selection';" +
|
|
306
|
+
"})()"
|
|
307
|
+
BODY
|
|
308
|
+
end
|
|
309
|
+
end
|
|
310
|
+
|
|
311
|
+
include RepositorySearchHelpers
|
|
312
|
+
|
|
313
|
+
# Permission flow: expand panel, select permission, optionally set write level.
|
|
314
|
+
module PermissionExpandHelpers
|
|
315
|
+
module_function
|
|
316
|
+
|
|
317
|
+
def apply_permissions(permissions)
|
|
318
|
+
permissions.each do |perm_name, level|
|
|
319
|
+
display_name = perm_name.tr("_", " ")
|
|
320
|
+
expand_add_permissions
|
|
321
|
+
add_permission_option(display_name)
|
|
322
|
+
PermissionLevelHelpers.upgrade_permission_level(display_name) if level == "write"
|
|
323
|
+
sleep 1
|
|
324
|
+
end
|
|
325
|
+
end
|
|
326
|
+
|
|
327
|
+
def expand_add_permissions
|
|
328
|
+
output = SafariAutomation.execute_do_js(expand_permissions_body)
|
|
329
|
+
SafariAutomation.check_result!(output, "'Add permissions' button")
|
|
330
|
+
sleep 0.5
|
|
331
|
+
end
|
|
332
|
+
|
|
333
|
+
def add_permission_option(display_name)
|
|
334
|
+
vars = SafariAutomation.escape_vars(display_name, var_name: "permName")
|
|
335
|
+
output = SafariAutomation.execute_do_js_with_vars(vars, add_perm_body)
|
|
336
|
+
SafariAutomation.check_result!(output, "permission '#{display_name}' in Add permissions list")
|
|
337
|
+
sleep 0.5
|
|
338
|
+
end
|
|
339
|
+
|
|
340
|
+
def expand_permissions_body
|
|
341
|
+
"(function() { " \
|
|
342
|
+
"var expandables = document.querySelectorAll('[aria-expanded]'); " \
|
|
343
|
+
"for (var i = 0; i < expandables.length; i++) { " \
|
|
344
|
+
"if (expandables[i].textContent.trim().indexOf('Add permissions') !== -1) { " \
|
|
345
|
+
"if (expandables[i].getAttribute('aria-expanded') === 'false') expandables[i].click(); " \
|
|
346
|
+
"return 'OK'; } " \
|
|
347
|
+
"} " \
|
|
348
|
+
"return 'NOT_FOUND:add_permissions_button';" \
|
|
349
|
+
"})()"
|
|
350
|
+
end
|
|
351
|
+
|
|
352
|
+
def add_perm_body
|
|
353
|
+
'"(function() {" +' \
|
|
354
|
+
'" var options = document.querySelectorAll(\'[role=option]\');" +' \
|
|
355
|
+
'" for (var i = 0; i < options.length; i++) {" +' \
|
|
356
|
+
'" var t = options[i].textContent.trim().toLowerCase();" +' \
|
|
357
|
+
'" if (t === \'" + esc.toLowerCase() + "\' || " +' \
|
|
358
|
+
'" t.indexOf(\'" + esc.toLowerCase() + "\') === 0) {" +' \
|
|
359
|
+
'" options[i].click(); return \'OK\'; }" +' \
|
|
360
|
+
'" }" +' \
|
|
361
|
+
'" return \'NOT_FOUND:permission_option\';" +' \
|
|
362
|
+
'" })()"'
|
|
363
|
+
end
|
|
364
|
+
end
|
|
365
|
+
|
|
366
|
+
include PermissionExpandHelpers
|
|
367
|
+
|
|
368
|
+
alias set_permissions apply_permissions
|
|
369
|
+
module_function :set_permissions
|
|
370
|
+
|
|
371
|
+
# Permission level upgrade flow: click access button, select "Read and write".
|
|
372
|
+
module PermissionLevelHelpers
|
|
373
|
+
module_function
|
|
374
|
+
|
|
375
|
+
def upgrade_permission_level(display_name)
|
|
376
|
+
click_access_level_button(display_name)
|
|
377
|
+
sleep 0.5
|
|
378
|
+
select_read_write_option(display_name)
|
|
379
|
+
end
|
|
380
|
+
|
|
381
|
+
def click_access_level_button(display_name)
|
|
382
|
+
vars = SafariAutomation.escape_vars(display_name, var_name: "permName")
|
|
383
|
+
output = SafariAutomation.execute_do_js_with_vars(vars, access_level_body)
|
|
384
|
+
SafariAutomation.check_result!(output, "access level button for '#{display_name}'")
|
|
385
|
+
end
|
|
386
|
+
|
|
387
|
+
def select_read_write_option(display_name)
|
|
388
|
+
output = SafariAutomation.execute_do_js(read_write_body)
|
|
389
|
+
SafariAutomation.check_result!(output, "'Read and write' option for '#{display_name}'")
|
|
390
|
+
end
|
|
391
|
+
|
|
392
|
+
def access_level_body
|
|
393
|
+
'"(function() {" +' \
|
|
394
|
+
'" var btns = document.querySelectorAll(\'button[aria-haspopup=true]\');" +' \
|
|
395
|
+
'" for (var i = btns.length - 1; i >= 0; i--) {" +' \
|
|
396
|
+
'" if (btns[i].textContent.indexOf(\'Read-only\') === -1) continue;" +' \
|
|
397
|
+
'" var row = btns[i].closest(\'li, [class*=Box-row]\') || btns[i].parentElement.parentElement;" +' \
|
|
398
|
+
'" if (row && row.textContent.toLowerCase().indexOf(\'" + esc.toLowerCase() + "\') !== -1) {" +' \
|
|
399
|
+
'" btns[i].click(); return \'OK\'; }" +' \
|
|
400
|
+
'" }" +' \
|
|
401
|
+
'" return \'NOT_FOUND:access_level_button\';" +' \
|
|
402
|
+
'" })()"'
|
|
403
|
+
end
|
|
404
|
+
|
|
405
|
+
def read_write_body
|
|
406
|
+
"(function() { " \
|
|
407
|
+
"var items = document.querySelectorAll('[role=menuitem], [role=menuitemradio]'); " \
|
|
408
|
+
"for (var i = 0; i < items.length; i++) { " \
|
|
409
|
+
"if (items[i].textContent.trim() === 'Read and write') { items[i].click(); return 'OK'; } " \
|
|
410
|
+
"} " \
|
|
411
|
+
"return 'NOT_FOUND:read_and_write_option';" \
|
|
412
|
+
"})()"
|
|
413
|
+
end
|
|
414
|
+
end
|
|
415
|
+
|
|
416
|
+
include PermissionLevelHelpers
|
|
417
|
+
|
|
418
|
+
# Generation and token extraction flow.
|
|
419
|
+
module GenerationHelpers
|
|
420
|
+
module_function
|
|
421
|
+
|
|
422
|
+
def click_generate
|
|
423
|
+
output = SafariAutomation.execute_do_js(generate_button_body)
|
|
424
|
+
SafariAutomation.check_result!(output, "Generate token button")
|
|
425
|
+
wait_for_confirmation_frame
|
|
426
|
+
end
|
|
427
|
+
|
|
428
|
+
def confirm_generation
|
|
429
|
+
output = SafariAutomation.execute_do_js(confirm_dialog_body)
|
|
430
|
+
SafariAutomation.check_result!(output, "generation confirmation button")
|
|
431
|
+
sleep 3
|
|
432
|
+
end
|
|
433
|
+
|
|
434
|
+
def wait_for_confirmation_frame
|
|
435
|
+
8.times do
|
|
436
|
+
sleep 1
|
|
437
|
+
loaded = SafariAutomation.execute_do_js(confirmation_check_body)
|
|
438
|
+
return if loaded.strip == "LOADED"
|
|
439
|
+
end
|
|
440
|
+
raise Error, "Confirmation dialog did not load after clicking Generate"
|
|
441
|
+
end
|
|
442
|
+
|
|
443
|
+
def extract_token
|
|
444
|
+
poll_for_token
|
|
445
|
+
end
|
|
446
|
+
|
|
447
|
+
def poll_for_token
|
|
448
|
+
10.times do
|
|
449
|
+
sleep 1
|
|
450
|
+
token = attempt_token_extraction
|
|
451
|
+
return token unless token.empty?
|
|
452
|
+
end
|
|
453
|
+
raise Error, "Token not found on page after 10 attempts"
|
|
454
|
+
end
|
|
455
|
+
|
|
456
|
+
def attempt_token_extraction
|
|
457
|
+
output = SafariAutomation.execute_do_js(token_extraction_body)
|
|
458
|
+
output.strip
|
|
459
|
+
end
|
|
460
|
+
|
|
461
|
+
def generate_button_body
|
|
462
|
+
"(function() { " \
|
|
463
|
+
"var btn = document.querySelector('.js-integrations-install-form-submit'); " \
|
|
464
|
+
"if (!btn) return 'NOT_FOUND:generate_button'; " \
|
|
465
|
+
"btn.click(); return 'OK';" \
|
|
466
|
+
"})()"
|
|
467
|
+
end
|
|
468
|
+
|
|
469
|
+
def confirm_dialog_body
|
|
470
|
+
"(function() { " \
|
|
471
|
+
"var dialog = document.getElementById('confirm-fg-pat'); " \
|
|
472
|
+
"if (!dialog) return 'NOT_FOUND:confirmation_dialog'; " \
|
|
473
|
+
"if (!dialog.open) dialog.showModal(); " \
|
|
474
|
+
"var btn = dialog.querySelector('button[type=submit]'); " \
|
|
475
|
+
"if (!btn) return 'NOT_FOUND:confirmation_submit'; " \
|
|
476
|
+
"btn.click(); return 'OK';" \
|
|
477
|
+
"})()"
|
|
478
|
+
end
|
|
479
|
+
|
|
480
|
+
def confirmation_check_body
|
|
481
|
+
"document.getElementById('confirm-fg-pat') ? 'LOADED' : 'WAITING'"
|
|
482
|
+
end
|
|
483
|
+
|
|
484
|
+
def token_extraction_body
|
|
485
|
+
"(function() { " \
|
|
486
|
+
"var sel = '#new-access-token, [id*=token-value], .token-code, input[readonly][value^=github_pat_]'; " \
|
|
487
|
+
"var token = document.querySelector(sel); " \
|
|
488
|
+
"if (token) return token.value || token.textContent || ''; " \
|
|
489
|
+
"var match = document.body.innerText.match(/github_pat_[A-Za-z0-9_]+/); " \
|
|
490
|
+
"return match ? match[0] : '';" \
|
|
491
|
+
"})()"
|
|
492
|
+
end
|
|
493
|
+
end
|
|
494
|
+
|
|
495
|
+
include GenerationHelpers
|
|
496
|
+
end
|
|
497
|
+
end
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Earl
|
|
4
|
+
class SessionManager
|
|
5
|
+
# Persistence query and stats methods extracted to reduce class method count.
|
|
6
|
+
module Persistence
|
|
7
|
+
# Returns the Claude session ID for a thread, checking active sessions
|
|
8
|
+
# first, then falling back to the persisted session store.
|
|
9
|
+
def claude_session_id_for(thread_id)
|
|
10
|
+
@mutex.synchronize do
|
|
11
|
+
session = @sessions[thread_id]
|
|
12
|
+
return session.session_id if session
|
|
13
|
+
|
|
14
|
+
persisted_session_for(thread_id)&.claude_session_id
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# Returns the persisted session data for a thread from the session store.
|
|
19
|
+
def persisted_session_for(thread_id)
|
|
20
|
+
@session_store&.load&.dig(thread_id)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def save_stats(thread_id)
|
|
24
|
+
@mutex.synchronize do
|
|
25
|
+
session = @sessions[thread_id]
|
|
26
|
+
return unless session && @session_store
|
|
27
|
+
|
|
28
|
+
persisted = @session_store.load[thread_id]
|
|
29
|
+
return unless persisted
|
|
30
|
+
|
|
31
|
+
apply_stats_to_persisted(persisted, session)
|
|
32
|
+
@session_store.save(thread_id, persisted)
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
def apply_stats_to_persisted(persisted, session)
|
|
39
|
+
stats = session.stats
|
|
40
|
+
persisted.total_cost = stats.total_cost
|
|
41
|
+
persisted.total_input_tokens = stats.total_input_tokens
|
|
42
|
+
persisted.total_output_tokens = stats.total_output_tokens
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Earl
|
|
4
|
+
class SessionManager
|
|
5
|
+
# Session creation, resumption, and spawning logic.
|
|
6
|
+
module SessionCreation
|
|
7
|
+
private
|
|
8
|
+
|
|
9
|
+
def reuse_session(session, short_id)
|
|
10
|
+
log(:debug, "Reusing session for thread #{short_id}")
|
|
11
|
+
session
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def resume_or_create(ctx, persisted)
|
|
15
|
+
build_resume_session(ctx, persisted)
|
|
16
|
+
rescue StandardError => error
|
|
17
|
+
log(:warn, "Resume failed for thread #{ctx.short_id}: #{error.message}, creating new session")
|
|
18
|
+
create_session(ctx)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def build_resume_session(ctx, persisted)
|
|
22
|
+
thread_id, short_id, session_config = ctx.deconstruct
|
|
23
|
+
sc_channel, sc_dir, sc_user = session_config.deconstruct
|
|
24
|
+
p_sid, p_chan, p_dir = persisted.to_h.values_at(:claude_session_id, :channel_id, :working_dir)
|
|
25
|
+
log(:info, "Attempting to resume session for thread #{short_id}")
|
|
26
|
+
spawn_and_register(SpawnParams.new(
|
|
27
|
+
session_id: p_sid, thread_id: thread_id,
|
|
28
|
+
channel_id: sc_channel || p_chan,
|
|
29
|
+
working_dir: sc_dir || p_dir, username: sc_user
|
|
30
|
+
))
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def resume_session(thread_id, persisted)
|
|
34
|
+
short_id = thread_id[0..7]
|
|
35
|
+
log(:info, "Resuming session for thread #{short_id}")
|
|
36
|
+
params = SpawnParams.new(
|
|
37
|
+
session_id: persisted.claude_session_id, thread_id: thread_id,
|
|
38
|
+
channel_id: persisted.channel_id, working_dir: persisted.working_dir, username: nil
|
|
39
|
+
)
|
|
40
|
+
session = spawn_claude_session(params)
|
|
41
|
+
@mutex.synchronize { @sessions[thread_id] = session }
|
|
42
|
+
rescue StandardError => error
|
|
43
|
+
log(:warn, "Startup resume failed for thread #{short_id}: #{error.message}")
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def create_session(ctx)
|
|
47
|
+
thread_id, short_id, session_config = ctx.deconstruct
|
|
48
|
+
channel_id, working_dir, username = session_config.deconstruct
|
|
49
|
+
log(:info, "Creating new session for thread #{short_id}")
|
|
50
|
+
params = SpawnParams.new(
|
|
51
|
+
session_id: nil, thread_id: thread_id,
|
|
52
|
+
channel_id: channel_id, working_dir: working_dir, username: username
|
|
53
|
+
)
|
|
54
|
+
spawn_and_register(params)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def spawn_and_register(params)
|
|
58
|
+
_, thread_id, channel_id, working_dir, = params.deconstruct
|
|
59
|
+
session = spawn_claude_session(params)
|
|
60
|
+
persist_ctx = PersistenceContext.new(channel_id: channel_id, working_dir: working_dir, paused: false)
|
|
61
|
+
register_session(thread_id, session, persist_ctx)
|
|
62
|
+
session
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def spawn_claude_session(params)
|
|
66
|
+
sid, thread_id, channel_id, working_dir, username = params.deconstruct
|
|
67
|
+
session = ClaudeSession.new(
|
|
68
|
+
**(sid ? { session_id: sid } : {}),
|
|
69
|
+
permission_config: build_permission_config(thread_id, channel_id),
|
|
70
|
+
mode: sid ? :resume : :new,
|
|
71
|
+
working_dir: working_dir, username: username
|
|
72
|
+
)
|
|
73
|
+
session.start
|
|
74
|
+
session
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def register_session(thread_id, session, persist_ctx)
|
|
78
|
+
@sessions[thread_id] = session
|
|
79
|
+
@session_store&.save(thread_id, build_persisted(session, persist_ctx))
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def build_permission_config(thread_id, channel_id)
|
|
83
|
+
return nil unless @config
|
|
84
|
+
|
|
85
|
+
resolved = [channel_id, @config.channel_id].compact.first
|
|
86
|
+
build_permission_env(@config, channel_id: resolved, thread_id: thread_id)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def build_persisted(session, persist_ctx)
|
|
90
|
+
now = Time.now.iso8601
|
|
91
|
+
p_channel_id, p_working_dir, paused = persist_ctx.deconstruct
|
|
92
|
+
SessionStore::PersistedSession.new(
|
|
93
|
+
claude_session_id: session.session_id,
|
|
94
|
+
channel_id: p_channel_id, working_dir: p_working_dir,
|
|
95
|
+
started_at: now, last_activity_at: now,
|
|
96
|
+
is_paused: paused, message_count: 0,
|
|
97
|
+
**stats_hash(session)
|
|
98
|
+
)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def stats_hash(session)
|
|
102
|
+
stats = session.stats
|
|
103
|
+
{ total_cost: stats.total_cost, total_input_tokens: stats.total_input_tokens,
|
|
104
|
+
total_output_tokens: stats.total_output_tokens }
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|