pocketrb 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 (83) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +32 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +456 -0
  5. data/exe/pocketrb +6 -0
  6. data/lib/pocketrb/agent/compaction.rb +187 -0
  7. data/lib/pocketrb/agent/context.rb +171 -0
  8. data/lib/pocketrb/agent/loop.rb +276 -0
  9. data/lib/pocketrb/agent/spawn_tool.rb +72 -0
  10. data/lib/pocketrb/agent/subagent_manager.rb +196 -0
  11. data/lib/pocketrb/bus/events.rb +99 -0
  12. data/lib/pocketrb/bus/message_bus.rb +148 -0
  13. data/lib/pocketrb/channels/base.rb +69 -0
  14. data/lib/pocketrb/channels/cli.rb +109 -0
  15. data/lib/pocketrb/channels/telegram.rb +607 -0
  16. data/lib/pocketrb/channels/whatsapp.rb +242 -0
  17. data/lib/pocketrb/cli/base.rb +119 -0
  18. data/lib/pocketrb/cli/chat.rb +67 -0
  19. data/lib/pocketrb/cli/config.rb +52 -0
  20. data/lib/pocketrb/cli/cron.rb +144 -0
  21. data/lib/pocketrb/cli/gateway.rb +132 -0
  22. data/lib/pocketrb/cli/init.rb +39 -0
  23. data/lib/pocketrb/cli/plans.rb +28 -0
  24. data/lib/pocketrb/cli/skills.rb +34 -0
  25. data/lib/pocketrb/cli/start.rb +55 -0
  26. data/lib/pocketrb/cli/telegram.rb +93 -0
  27. data/lib/pocketrb/cli/version.rb +18 -0
  28. data/lib/pocketrb/cli/whatsapp.rb +60 -0
  29. data/lib/pocketrb/cli.rb +124 -0
  30. data/lib/pocketrb/config.rb +190 -0
  31. data/lib/pocketrb/cron/job.rb +155 -0
  32. data/lib/pocketrb/cron/service.rb +395 -0
  33. data/lib/pocketrb/heartbeat/service.rb +175 -0
  34. data/lib/pocketrb/mcp/client.rb +172 -0
  35. data/lib/pocketrb/mcp/memory_tool.rb +133 -0
  36. data/lib/pocketrb/media/processor.rb +258 -0
  37. data/lib/pocketrb/memory.rb +283 -0
  38. data/lib/pocketrb/planning/manager.rb +159 -0
  39. data/lib/pocketrb/planning/plan.rb +223 -0
  40. data/lib/pocketrb/planning/tool.rb +176 -0
  41. data/lib/pocketrb/providers/anthropic.rb +333 -0
  42. data/lib/pocketrb/providers/base.rb +98 -0
  43. data/lib/pocketrb/providers/claude_cli.rb +412 -0
  44. data/lib/pocketrb/providers/claude_max_proxy.rb +347 -0
  45. data/lib/pocketrb/providers/openrouter.rb +205 -0
  46. data/lib/pocketrb/providers/registry.rb +59 -0
  47. data/lib/pocketrb/providers/ruby_llm_provider.rb +136 -0
  48. data/lib/pocketrb/providers/types.rb +111 -0
  49. data/lib/pocketrb/session/manager.rb +192 -0
  50. data/lib/pocketrb/session/session.rb +204 -0
  51. data/lib/pocketrb/skills/builtin/github/SKILL.md +113 -0
  52. data/lib/pocketrb/skills/builtin/proactive/SKILL.md +101 -0
  53. data/lib/pocketrb/skills/builtin/reflection/SKILL.md +109 -0
  54. data/lib/pocketrb/skills/builtin/tmux/SKILL.md +130 -0
  55. data/lib/pocketrb/skills/builtin/weather/SKILL.md +130 -0
  56. data/lib/pocketrb/skills/create_tool.rb +115 -0
  57. data/lib/pocketrb/skills/loader.rb +164 -0
  58. data/lib/pocketrb/skills/modify_tool.rb +123 -0
  59. data/lib/pocketrb/skills/skill.rb +75 -0
  60. data/lib/pocketrb/tools/background_job_manager.rb +261 -0
  61. data/lib/pocketrb/tools/base.rb +118 -0
  62. data/lib/pocketrb/tools/browser.rb +152 -0
  63. data/lib/pocketrb/tools/browser_advanced.rb +470 -0
  64. data/lib/pocketrb/tools/browser_session.rb +167 -0
  65. data/lib/pocketrb/tools/cron.rb +222 -0
  66. data/lib/pocketrb/tools/edit_file.rb +101 -0
  67. data/lib/pocketrb/tools/exec.rb +194 -0
  68. data/lib/pocketrb/tools/jobs.rb +127 -0
  69. data/lib/pocketrb/tools/list_dir.rb +102 -0
  70. data/lib/pocketrb/tools/memory.rb +167 -0
  71. data/lib/pocketrb/tools/message.rb +70 -0
  72. data/lib/pocketrb/tools/para_memory.rb +264 -0
  73. data/lib/pocketrb/tools/read_file.rb +65 -0
  74. data/lib/pocketrb/tools/registry.rb +160 -0
  75. data/lib/pocketrb/tools/send_file.rb +158 -0
  76. data/lib/pocketrb/tools/think.rb +35 -0
  77. data/lib/pocketrb/tools/web_fetch.rb +150 -0
  78. data/lib/pocketrb/tools/web_search.rb +102 -0
  79. data/lib/pocketrb/tools/write_file.rb +55 -0
  80. data/lib/pocketrb/version.rb +5 -0
  81. data/lib/pocketrb.rb +75 -0
  82. data/pocketrb.gemspec +60 -0
  83. metadata +327 -0
@@ -0,0 +1,470 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "browser_session"
4
+
5
+ module Pocketrb
6
+ module Tools
7
+ # Enhanced browser tool with sessions and tabs
8
+ class BrowserAdvanced < Base
9
+ ACTIONS = %w[
10
+ new_tab close_tab focus_tab list_tabs
11
+ navigate back forward refresh
12
+ click type hover scroll
13
+ screenshot snapshot
14
+ execute_js wait
15
+ get_text get_attribute
16
+ fill_form select_option
17
+ press_key
18
+ ].freeze
19
+
20
+ def name
21
+ "browser"
22
+ end
23
+
24
+ def description
25
+ <<~DESC.strip
26
+ Browse the web with a persistent browser session. Supports multiple tabs,
27
+ navigation, clicking, typing, screenshots, and JavaScript execution.
28
+ The browser stays open between calls for efficient multi-step browsing.
29
+ DESC
30
+ end
31
+
32
+ def parameters
33
+ {
34
+ type: "object",
35
+ properties: {
36
+ action: {
37
+ type: "string",
38
+ enum: ACTIONS,
39
+ description: "Browser action to perform"
40
+ },
41
+ url: {
42
+ type: "string",
43
+ description: "URL for navigation or new tab"
44
+ },
45
+ tab_id: {
46
+ type: "string",
47
+ description: "Tab ID (uses active tab if not specified)"
48
+ },
49
+ selector: {
50
+ type: "string",
51
+ description: "CSS or XPath selector for element"
52
+ },
53
+ text: {
54
+ type: "string",
55
+ description: "Text to type or search for"
56
+ },
57
+ key: {
58
+ type: "string",
59
+ description: "Key to press (Enter, Tab, Escape, etc.)"
60
+ },
61
+ javascript: {
62
+ type: "string",
63
+ description: "JavaScript code to execute"
64
+ },
65
+ attribute: {
66
+ type: "string",
67
+ description: "Element attribute to get"
68
+ },
69
+ options: {
70
+ type: "object",
71
+ description: "Additional options (timeout, wait_until, etc.)"
72
+ },
73
+ form_data: {
74
+ type: "object",
75
+ description: "Form fields to fill: {selector: value, ...}"
76
+ },
77
+ scroll_to: {
78
+ type: "string",
79
+ enum: %w[top bottom element],
80
+ description: "Where to scroll"
81
+ },
82
+ full_page: {
83
+ type: "boolean",
84
+ description: "Take full page screenshot"
85
+ }
86
+ },
87
+ required: ["action"]
88
+ }
89
+ end
90
+
91
+ def available?
92
+ # Check if playwright is available
93
+ system("which npx > /dev/null 2>&1") || system("which playwright > /dev/null 2>&1")
94
+ end
95
+
96
+ def execute(action:, **args)
97
+ case action
98
+ # Tab management
99
+ when "new_tab"
100
+ new_tab(args[:url])
101
+ when "close_tab"
102
+ close_tab(args[:tab_id])
103
+ when "focus_tab"
104
+ focus_tab(args[:tab_id])
105
+ when "list_tabs"
106
+ list_tabs
107
+
108
+ # Navigation
109
+ when "navigate"
110
+ navigate(args[:url], args[:options] || {})
111
+ when "back"
112
+ go_back
113
+ when "forward"
114
+ go_forward
115
+ when "refresh"
116
+ refresh_page
117
+
118
+ # Interaction
119
+ when "click"
120
+ click_element(args[:selector], args[:options] || {})
121
+ when "type"
122
+ type_text(args[:selector], args[:text], args[:options] || {})
123
+ when "hover"
124
+ hover_element(args[:selector])
125
+ when "scroll"
126
+ scroll_page(args[:scroll_to], args[:selector])
127
+ when "press_key"
128
+ press_key(args[:key], args[:selector])
129
+ when "fill_form"
130
+ fill_form(args[:form_data])
131
+ when "select_option"
132
+ select_option(args[:selector], args[:text])
133
+
134
+ # Content
135
+ when "screenshot"
136
+ take_screenshot(args[:full_page])
137
+ when "snapshot"
138
+ get_snapshot
139
+ when "get_text"
140
+ get_text(args[:selector])
141
+ when "get_attribute"
142
+ get_attribute(args[:selector], args[:attribute])
143
+ when "execute_js"
144
+ execute_javascript(args[:javascript])
145
+
146
+ # Wait
147
+ when "wait"
148
+ wait_for(args[:selector], args[:options] || {})
149
+
150
+ else
151
+ error("Unknown action: #{action}")
152
+ end
153
+ rescue Playwright::TimeoutError => e
154
+ error("Timeout: #{e.message}")
155
+ rescue Playwright::Error => e
156
+ error("Browser error: #{e.message}")
157
+ rescue StandardError => e
158
+ error("Error: #{e.message}")
159
+ end
160
+
161
+ private
162
+
163
+ def session
164
+ BrowserSession.instance
165
+ end
166
+
167
+ def page(tab_id = nil)
168
+ if tab_id
169
+ session.tabs[tab_id]&.dig(:page)
170
+ else
171
+ session.active_page
172
+ end
173
+ end
174
+
175
+ def ensure_page!
176
+ p = page
177
+ return p if p
178
+
179
+ # Auto-create a tab if none exists
180
+ session.new_tab
181
+ session.active_page
182
+ end
183
+
184
+ # === Tab Management ===
185
+
186
+ def new_tab(url)
187
+ tab_id = session.new_tab(url: url)
188
+ info = session.tab_info(tab_id)
189
+
190
+ if url
191
+ success("Opened new tab #{tab_id}: #{info[:title]}\nURL: #{info[:url]}")
192
+ else
193
+ success("Opened new empty tab: #{tab_id}")
194
+ end
195
+ end
196
+
197
+ def close_tab(tab_id)
198
+ tab_id ||= session.active_tab_id
199
+ return error("No tab to close") unless tab_id
200
+
201
+ if session.close_tab(tab_id)
202
+ remaining = session.tabs.size
203
+ success("Closed tab #{tab_id}. #{remaining} tabs remaining.")
204
+ else
205
+ error("Tab not found: #{tab_id}")
206
+ end
207
+ end
208
+
209
+ def focus_tab(tab_id)
210
+ return error("Tab ID required") unless tab_id
211
+
212
+ if session.focus_tab(tab_id)
213
+ info = session.tab_info(tab_id)
214
+ success("Focused tab #{tab_id}: #{info[:title]}")
215
+ else
216
+ error("Tab not found: #{tab_id}")
217
+ end
218
+ end
219
+
220
+ def list_tabs
221
+ tabs = session.list_tabs
222
+ return "No tabs open. Use action 'new_tab' to open one." if tabs.empty?
223
+
224
+ lines = ["Open tabs (#{tabs.size}):"]
225
+ tabs.each do |tab|
226
+ marker = tab[:active] ? "→" : " "
227
+ lines << "#{marker} [#{tab[:id]}] #{tab[:title]}"
228
+ lines << " #{tab[:url]}"
229
+ end
230
+ lines.join("\n")
231
+ end
232
+
233
+ # === Navigation ===
234
+
235
+ def navigate(url, options)
236
+ return error("URL required") unless url
237
+
238
+ p = ensure_page!
239
+ wait_until = options[:wait_until] || "domcontentloaded"
240
+
241
+ p.goto(url, wait_until: wait_until)
242
+ session.update_tab_info(session.active_tab_id)
243
+
244
+ title = p.title
245
+ success("Navigated to: #{title}\nURL: #{p.url}")
246
+ end
247
+
248
+ def go_back
249
+ p = ensure_page!
250
+ p.go_back
251
+ session.update_tab_info(session.active_tab_id)
252
+ success("Navigated back to: #{p.title}")
253
+ end
254
+
255
+ def go_forward
256
+ p = ensure_page!
257
+ p.go_forward
258
+ session.update_tab_info(session.active_tab_id)
259
+ success("Navigated forward to: #{p.title}")
260
+ end
261
+
262
+ def refresh_page
263
+ p = ensure_page!
264
+ p.reload
265
+ success("Page refreshed: #{p.title}")
266
+ end
267
+
268
+ # === Interaction ===
269
+
270
+ def click_element(selector, options)
271
+ return error("Selector required") unless selector
272
+
273
+ p = ensure_page!
274
+ timeout = options[:timeout] || 5000
275
+
276
+ p.click(selector, timeout: timeout)
277
+ success("Clicked: #{selector}")
278
+ end
279
+
280
+ def type_text(selector, text, _options)
281
+ return error("Selector and text required") unless selector && text
282
+
283
+ p = ensure_page!
284
+
285
+ p.fill(selector, text)
286
+ success("Typed into #{selector}: #{text[0..50]}#{"..." if text.length > 50}")
287
+ end
288
+
289
+ def hover_element(selector)
290
+ return error("Selector required") unless selector
291
+
292
+ p = ensure_page!
293
+ p.hover(selector)
294
+ success("Hovering over: #{selector}")
295
+ end
296
+
297
+ def scroll_page(direction, selector)
298
+ p = ensure_page!
299
+
300
+ case direction
301
+ when "top"
302
+ p.evaluate("window.scrollTo(0, 0)")
303
+ success("Scrolled to top")
304
+ when "bottom"
305
+ p.evaluate("window.scrollTo(0, document.body.scrollHeight)")
306
+ success("Scrolled to bottom")
307
+ when "element"
308
+ return error("Selector required for element scroll") unless selector
309
+
310
+ p.evaluate("document.querySelector('#{selector}')?.scrollIntoView({behavior: 'smooth'})")
311
+ success("Scrolled to element: #{selector}")
312
+ else
313
+ p.evaluate("window.scrollBy(0, 500)")
314
+ success("Scrolled down")
315
+ end
316
+ end
317
+
318
+ def press_key(key, selector)
319
+ return error("Key required") unless key
320
+
321
+ p = ensure_page!
322
+
323
+ if selector
324
+ p.press(selector, key)
325
+ else
326
+ p.keyboard.press(key)
327
+ end
328
+
329
+ success("Pressed key: #{key}")
330
+ end
331
+
332
+ def fill_form(form_data)
333
+ return error("Form data required") unless form_data.is_a?(Hash)
334
+
335
+ p = ensure_page!
336
+ filled = []
337
+
338
+ form_data.each do |selector, value|
339
+ p.fill(selector.to_s, value.to_s)
340
+ filled << selector
341
+ end
342
+
343
+ success("Filled #{filled.size} form fields: #{filled.join(", ")}")
344
+ end
345
+
346
+ def select_option(selector, value)
347
+ return error("Selector and value required") unless selector && value
348
+
349
+ p = ensure_page!
350
+ p.select_option(selector, value: value)
351
+ success("Selected '#{value}' in #{selector}")
352
+ end
353
+
354
+ # === Content ===
355
+
356
+ def take_screenshot(full_page)
357
+ p = ensure_page!
358
+
359
+ # Save to workspace
360
+ filename = "screenshot_#{Time.now.strftime("%Y%m%d_%H%M%S")}.png"
361
+ path = resolve_path(filename)
362
+
363
+ p.screenshot(path: path.to_s, full_page: full_page || false)
364
+ success("Screenshot saved: #{path}")
365
+ end
366
+
367
+ def get_snapshot
368
+ p = ensure_page!
369
+
370
+ # Get a simplified view of the page
371
+ title = p.title
372
+ url = p.url
373
+
374
+ # Get visible text content
375
+ content = p.evaluate(<<~JS)
376
+ (() => {
377
+ const walker = document.createTreeWalker(
378
+ document.body,
379
+ NodeFilter.SHOW_TEXT,
380
+ { acceptNode: (node) => {
381
+ const parent = node.parentElement;
382
+ if (!parent) return NodeFilter.FILTER_REJECT;
383
+ const style = getComputedStyle(parent);
384
+ if (style.display === 'none' || style.visibility === 'hidden') {
385
+ return NodeFilter.FILTER_REJECT;
386
+ }
387
+ return node.textContent.trim() ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_REJECT;
388
+ }}
389
+ );
390
+ const texts = [];
391
+ while (walker.nextNode()) {
392
+ texts.push(walker.currentNode.textContent.trim());
393
+ }
394
+ return texts.join(' ').substring(0, 5000);
395
+ })()
396
+ JS
397
+
398
+ # Get interactive elements
399
+ elements = p.evaluate(<<~JS)
400
+ (() => {
401
+ const items = [];
402
+ document.querySelectorAll('a, button, input, select, textarea, [onclick], [role="button"]').forEach((el, i) => {
403
+ if (i > 50) return;
404
+ const rect = el.getBoundingClientRect();
405
+ if (rect.width === 0 || rect.height === 0) return;
406
+
407
+ let desc = el.textContent?.trim()?.substring(0, 50) || el.getAttribute('aria-label') || el.getAttribute('title') || el.getAttribute('placeholder') || '';
408
+ const tag = el.tagName.toLowerCase();
409
+ const type = el.getAttribute('type') || '';
410
+
411
+ items.push(`[${i}] <${tag}${type ? ' type=' + type : ''}> ${desc}`);
412
+ });
413
+ return items.join('\\n');
414
+ })()
415
+ JS
416
+
417
+ <<~SNAPSHOT
418
+ # Page Snapshot
419
+
420
+ **Title:** #{title}
421
+ **URL:** #{url}
422
+
423
+ ## Content Summary
424
+ #{content[0..2000]}#{"...(truncated)" if content.length > 2000}
425
+
426
+ ## Interactive Elements
427
+ #{elements}
428
+ SNAPSHOT
429
+ end
430
+
431
+ def get_text(selector)
432
+ p = ensure_page!
433
+
434
+ text = p.text_content(selector || "body")
435
+
436
+ text = "#{text[0..3000]}...(truncated)" if text.length > 3000
437
+ success(text)
438
+ end
439
+
440
+ def get_attribute(selector, attribute)
441
+ return error("Selector and attribute required") unless selector && attribute
442
+
443
+ p = ensure_page!
444
+ value = p.get_attribute(selector, attribute)
445
+ success("#{selector}[#{attribute}] = #{value}")
446
+ end
447
+
448
+ def execute_javascript(code)
449
+ return error("JavaScript code required") unless code
450
+
451
+ p = ensure_page!
452
+ result = p.evaluate(code)
453
+ success("Result: #{result.inspect}")
454
+ end
455
+
456
+ # === Wait ===
457
+
458
+ def wait_for(selector, options)
459
+ return error("Selector required") unless selector
460
+
461
+ p = ensure_page!
462
+ timeout = options[:timeout] || 10_000
463
+ state = options[:state] || "visible"
464
+
465
+ p.wait_for_selector(selector, state: state, timeout: timeout)
466
+ success("Element found: #{selector}")
467
+ end
468
+ end
469
+ end
470
+ end
@@ -0,0 +1,167 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "singleton"
4
+
5
+ module Pocketrb
6
+ module Tools
7
+ # Manages persistent browser sessions with tabs
8
+ class BrowserSession
9
+ include Singleton
10
+
11
+ attr_reader :tabs, :active_tab_id
12
+
13
+ def initialize
14
+ @playwright = nil
15
+ @browser = nil
16
+ @context = nil
17
+ @tabs = {} # id => { page:, url:, title: }
18
+ @active_tab_id = nil
19
+ @tab_counter = 0
20
+ @mutex = Mutex.new
21
+ @started = false
22
+ end
23
+
24
+ def start!
25
+ return if @started
26
+
27
+ @mutex.synchronize do
28
+ return if @started
29
+
30
+ require "playwright"
31
+ @playwright = Playwright.create(playwright_cli_executable_path: find_playwright_cli)
32
+ @browser = @playwright.chromium.launch(
33
+ headless: ENV["BROWSER_HEADLESS"] != "false",
34
+ args: ["--no-sandbox", "--disable-setuid-sandbox"]
35
+ )
36
+ @context = @browser.new_context(
37
+ viewport: { width: 1280, height: 800 },
38
+ user_agent: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36"
39
+ )
40
+ @started = true
41
+ Pocketrb.logger.info("Browser session started")
42
+ end
43
+ end
44
+
45
+ def stop!
46
+ @mutex.synchronize do
47
+ @tabs.each_value do |tab|
48
+ tab[:page]&.close
49
+ rescue StandardError
50
+ nil
51
+ end
52
+ @tabs.clear
53
+ @context&.close
54
+ @browser&.close
55
+ @playwright&.stop
56
+ @playwright = nil
57
+ @browser = nil
58
+ @context = nil
59
+ @active_tab_id = nil
60
+ @started = false
61
+ Pocketrb.logger.info("Browser session stopped")
62
+ end
63
+ end
64
+
65
+ def started?
66
+ @started
67
+ end
68
+
69
+ # Create a new tab
70
+ def new_tab(url: nil)
71
+ start! unless started?
72
+
73
+ @mutex.synchronize do
74
+ @tab_counter += 1
75
+ tab_id = "tab_#{@tab_counter}"
76
+
77
+ page = @context.new_page
78
+ page.goto(url) if url
79
+
80
+ @tabs[tab_id] = {
81
+ page: page,
82
+ url: url,
83
+ title: page.title,
84
+ created_at: Time.now
85
+ }
86
+ @active_tab_id = tab_id
87
+
88
+ tab_id
89
+ end
90
+ end
91
+
92
+ # Close a tab
93
+ def close_tab(tab_id)
94
+ @mutex.synchronize do
95
+ tab = @tabs.delete(tab_id)
96
+ return false unless tab
97
+
98
+ tab[:page]&.close
99
+ @active_tab_id = @tabs.keys.last if @active_tab_id == tab_id
100
+ true
101
+ end
102
+ end
103
+
104
+ # Focus a tab
105
+ def focus_tab(tab_id)
106
+ @mutex.synchronize do
107
+ return false unless @tabs.key?(tab_id)
108
+
109
+ @active_tab_id = tab_id
110
+ @tabs[tab_id][:page].bring_to_front
111
+ true
112
+ end
113
+ end
114
+
115
+ # Get active page
116
+ def active_page
117
+ return nil unless @active_tab_id
118
+
119
+ @tabs[@active_tab_id]&.dig(:page)
120
+ end
121
+
122
+ # Get tab info
123
+ def tab_info(tab_id)
124
+ tab = @tabs[tab_id]
125
+ return nil unless tab
126
+
127
+ {
128
+ id: tab_id,
129
+ url: tab[:page].url,
130
+ title: tab[:page].title,
131
+ active: tab_id == @active_tab_id
132
+ }
133
+ end
134
+
135
+ # List all tabs
136
+ def list_tabs
137
+ @tabs.map do |id, tab|
138
+ {
139
+ id: id,
140
+ url: tab[:page].url,
141
+ title: tab[:page].title,
142
+ active: id == @active_tab_id
143
+ }
144
+ end
145
+ end
146
+
147
+ # Update tab metadata
148
+ def update_tab_info(tab_id)
149
+ tab = @tabs[tab_id]
150
+ return unless tab
151
+
152
+ tab[:url] = tab[:page].url
153
+ tab[:title] = tab[:page].title
154
+ end
155
+
156
+ private
157
+
158
+ def find_playwright_cli
159
+ # Try common locations
160
+ ["npx playwright", "playwright", "node_modules/.bin/playwright"].each do |cmd|
161
+ return cmd if system("which #{cmd.split.first} > /dev/null 2>&1")
162
+ end
163
+ "npx playwright"
164
+ end
165
+ end
166
+ end
167
+ end