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