Class: Clacky::BrowserManager
- Inherits:
-
Object
- Object
- Clacky::BrowserManager
- Defined in:
- lib/clacky/server/browser_manager.rb
Overview
BrowserManager owns the chrome-devtools-mcp daemon lifecycle.
It mirrors the ChannelManager pattern:
- start → read browser.yml; if enabled, pre-warm the MCP daemon
- stop → kill the daemon
- reload → stop + re-read yml + start (called after browser-setup writes yml)
- status → { enabled: bool, daemon_running: bool, chrome_version: String|nil }
- toggle → flip enabled in browser.yml and reload
browser.yml schema:
enabled: true/false — whether the browser tool is active
chrome_version: "146" — detected Chrome version (set by browser-setup skill)
configured_at: date — when setup was last run
Liveness check strategy:
process_alive? sends an MCP `ping` (standard in MCP spec 2024-11-05) and
waits up to 3s for a response. If the ping succeeds the daemon is healthy.
If it times out or raises an IO error the daemon is truly dead — kill it so
ensure_process! will spawn a fresh one on the next call.
Chrome connection problems (e.g. Chrome closed) surface only during the
actual mcp_call and are reported back to the caller; they do NOT trigger a
daemon restart.
Browser tool (browser.rb) delegates daemon access here instead of using class-level @@mcp_process variables directly. BrowserManager holds the single mutable state; the mutex lives here too.
Constant Summary collapse
- BROWSER_CONFIG_PATH =
File.("~/.clacky/browser.yml").freeze
Class Method Summary collapse
Instance Method Summary collapse
-
#build_mcp_command(detected) ⇒ Array<String>
Build chrome-devtools-mcp command with explicit connection parameters.
-
#chrome_mcp_feature_flags ⇒ Object
Feature flags for chrome-devtools-mcp.
-
#configure(chrome_version:) ⇒ Object
Write browser.yml with the given config and reload the daemon.
-
#ensure_process! ⇒ Object
Must be called inside @mutex.
- #extract_text_content(result) ⇒ Object
-
#initialize ⇒ BrowserManager
constructor
A new instance of BrowserManager.
- #json_rpc(method, params, id:) ⇒ Object
-
#kill_process! ⇒ Object
Must be called inside @mutex.
-
#load_config ⇒ Object
————————————————————————— Private —————————————————————————.
-
#mcp_call(tool_name, arguments = {}) ⇒ Hash
Execute a chrome-devtools-mcp tool call.
-
#process_alive? ⇒ Boolean
Must be called inside @mutex.
- #read_response(io, target_id:, timeout: 10) ⇒ Object
-
#reload ⇒ Object
Hot-reload: stop existing daemon, re-read yml, restart if enabled.
-
#start ⇒ Object
Start the daemon if browser.yml marks the browser as enabled.
-
#status ⇒ Hash
Returns a status hash with real daemon liveness.
-
#stop ⇒ Object
Stop and clean up the daemon.
-
#toggle ⇒ Boolean
Toggle the browser tool on/off by flipping ‘enabled` in browser.yml.
Constructor Details
#initialize ⇒ BrowserManager
Returns a new instance of BrowserManager.
42 43 44 45 46 47 |
# File 'lib/clacky/server/browser_manager.rb', line 42 def initialize @process = nil # { stdin:, stdout:, pid:, wait_thr: } @mutex = Mutex.new @call_id = 2 # 1 reserved for MCP initialize handshake @config = {} # last successfully read browser.yml content end |
Class Method Details
.instance ⇒ Object
37 38 39 |
# File 'lib/clacky/server/browser_manager.rb', line 37 def instance @instance ||= new end |
Instance Method Details
#build_mcp_command(detected) ⇒ Array<String>
Build chrome-devtools-mcp command with explicit connection parameters. Always uses the detected browser endpoint (no –autoConnect fallback).
283 284 285 286 287 288 289 290 291 292 293 |
# File 'lib/clacky/server/browser_manager.rb', line 283 def build_mcp_command(detected) args = chrome_mcp_feature_flags case detected[:mode] when :ws_endpoint Clacky::Logger.info("[BrowserManager] Using ws_endpoint mode: #{detected[:value]}") ["chrome-devtools-mcp", *args, "--wsEndpoint", detected[:value]] else raise "Unknown detection mode: #{detected[:mode]}" end end |
#chrome_mcp_feature_flags ⇒ Object
Feature flags for chrome-devtools-mcp
296 297 298 299 300 301 302 |
# File 'lib/clacky/server/browser_manager.rb', line 296 def chrome_mcp_feature_flags %w[ --experimentalStructuredContent --experimental-page-id-routing --experimentalVision ] end |
#configure(chrome_version:) ⇒ Object
Write browser.yml with the given config and reload the daemon. Called by HttpServer POST /api/browser/configure.
127 128 129 130 131 132 133 134 135 136 137 |
# File 'lib/clacky/server/browser_manager.rb', line 127 def configure(chrome_version:) cfg = { "enabled" => true, "browser" => "chrome", "chrome_version" => chrome_version.to_s, "configured_at" => Date.today.to_s } FileUtils.mkdir_p(File.dirname(BROWSER_CONFIG_PATH)) File.write(BROWSER_CONFIG_PATH, cfg.to_yaml) reload end |
#ensure_process! ⇒ Object
Must be called inside @mutex
217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 |
# File 'lib/clacky/server/browser_manager.rb', line 217 def ensure_process! return if process_alive? # ⭐️ Critical: Verify Chrome is reachable BEFORE starting MCP daemon detected = Clacky::Utils::BrowserDetector.detect if detected[:status] == :not_found raise Clacky::BrowserNotReachableError, <<~MSG.strip Chrome/Edge is not running or remote debugging is not enabled. Please: 1. Open Chrome or Edge 2. Enable remote debugging: Visit chrome://inspect/#remote-debugging and click "Allow remote debugging" 3. Retry this action The browser tool will automatically reconnect once Chrome is running. MSG end # Build command with verified detection result cmd = build_mcp_command(detected) Clacky::Logger.info("[BrowserManager] Starting MCP daemon: #{cmd.join(' ')}") # close_others: true prevents inheriting the server's listening socket (port 7070). # The MCP daemon is an independent external process and should not hold server fds. stdin, stdout, stderr_io, wait_thr = Open3.popen3(*cmd, close_others: true) Thread.new { stderr_io.read rescue nil } # MCP handshake init_msg = json_rpc("initialize", { protocolVersion: "2024-11-05", capabilities: {}, clientInfo: { name: "clacky", version: "1.0" } }, id: 1) notify_msg = JSON.generate({ jsonrpc: "2.0", method: "notifications/initialized", params: {} }) Clacky::Logger.debug("[BrowserManager] Sending MCP initialize...") stdin.write(init_msg + "\n") stdin.flush init_resp = read_response(stdout, target_id: 1, timeout: Clacky::Tools::Browser::MCP_HANDSHAKE_TIMEOUT) unless init_resp Clacky::Logger.error("[BrowserManager] MCP initialize handshake timed out after #{Clacky::Tools::Browser::MCP_HANDSHAKE_TIMEOUT}s") Process.kill("TERM", wait_thr.pid) rescue nil raise "Chrome MCP initialize handshake timed out" end Clacky::Logger.debug("[BrowserManager] MCP initialize successful, sending initialized notification...") stdin.write(notify_msg + "\n") stdin.flush @process = { stdin: stdin, stdout: stdout, pid: wait_thr.pid, wait_thr: wait_thr } @call_id = 2 Clacky::Logger.info("[BrowserManager] MCP daemon started successfully (pid=#{wait_thr.pid})") end |
#extract_text_content(result) ⇒ Object
367 368 369 370 371 372 |
# File 'lib/clacky/server/browser_manager.rb', line 367 def extract_text_content(result) Array(result["content"]) .select { |b| b.is_a?(Hash) && b["type"] == "text" } .map { |b| b["text"].to_s } .join("\n") end |
#json_rpc(method, params, id:) ⇒ Object
343 344 345 |
# File 'lib/clacky/server/browser_manager.rb', line 343 def json_rpc(method, params, id:) JSON.generate({ jsonrpc: "2.0", id: id, method: method, params: params }) end |
#kill_process! ⇒ Object
Must be called inside @mutex. Clears @process immediately so other threads see it as gone, then closes IO handles and sends TERM. Uses wait_thr.join(2) in a background thread to reap the child and avoid zombie processes; escalates to KILL if the process doesn’t exit within the grace period.
320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 |
# File 'lib/clacky/server/browser_manager.rb', line 320 def kill_process! ps = @process return unless ps @process = nil # Clear first — prevents other threads from re-entering ps[:stdin].close rescue nil ps[:stdout].close rescue nil Process.kill("TERM", ps[:pid]) rescue nil # Reap the child process asynchronously to avoid zombies Thread.new do Thread.current.name = "browser-manager-reap" unless ps[:wait_thr].join(1) Process.kill("KILL", ps[:pid]) rescue nil end rescue StandardError nil end Clacky::Logger.info("[BrowserManager] MCP daemon killed (pid=#{ps[:pid]})") end |
#load_config ⇒ Object
Private
208 209 210 211 212 213 214 |
# File 'lib/clacky/server/browser_manager.rb', line 208 def load_config return {} unless File.exist?(BROWSER_CONFIG_PATH) YAMLCompat.safe_load(File.read(BROWSER_CONFIG_PATH), permitted_classes: [Date, Time, Symbol]) || {} rescue StandardError => e Clacky::Logger.warn("[BrowserManager] Failed to read browser.yml: #{e.}") {} end |
#mcp_call(tool_name, arguments = {}) ⇒ Hash
Execute a chrome-devtools-mcp tool call. Ensures daemon is running first. Thread-safe via @mutex.
165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 |
# File 'lib/clacky/server/browser_manager.rb', line 165 def mcp_call(tool_name, arguments = {}) call_resp = nil @mutex.synchronize do ensure_process! # May raise BrowserNotReachableError call_id = @call_id @call_id += 1 msg = json_rpc("tools/call", { name: tool_name, arguments: arguments }, id: call_id) @process[:stdin].write(msg + "\n") @process[:stdin].flush call_resp = read_response(@process[:stdout], target_id: call_id, timeout: Clacky::Tools::Browser::MCP_CALL_TIMEOUT) unless call_resp raise "Chrome MCP tools/call '#{tool_name}' timed out after #{Clacky::Tools::Browser::MCP_CALL_TIMEOUT}s" end if call_resp["error"] err = call_resp["error"] raise "Chrome MCP error: #{err.is_a?(Hash) ? err["message"] : err}" end result = call_resp["result"] || {} if result["isError"] text = extract_text_content(result) raise text.empty? ? "Chrome MCP tool '#{tool_name}' failed" : text end result end rescue Clacky::BrowserNotReachableError => e # Return friendly error for AI to guide user raise Clacky::AgentError, e. end |
#process_alive? ⇒ Boolean
Must be called inside @mutex. Uses wait_thr.alive? as the primary liveness check — fast and reliable. Only falls back to an MCP ping if the thread is alive but we want to verify the protocol layer is responsive (currently skipped for simplicity). Kills the process only when the OS thread confirms it has actually exited.
309 310 311 312 313 |
# File 'lib/clacky/server/browser_manager.rb', line 309 def process_alive? return false if @process.nil? @process[:wait_thr]&.alive? == true end |
#read_response(io, target_id:, timeout: 10) ⇒ Object
347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 |
# File 'lib/clacky/server/browser_manager.rb', line 347 def read_response(io, target_id:, timeout: 10) Timeout.timeout(timeout) do loop do line = io.gets break if line.nil? line = line.strip next if line.empty? begin msg = JSON.parse(line) return msg if msg.is_a?(Hash) && msg["id"] == target_id rescue JSON::ParserError next end end nil end rescue Timeout::Error nil end |
#reload ⇒ Object
Hot-reload: stop existing daemon, re-read yml, restart if enabled. Called by HttpServer after browser-setup writes a new browser.yml.
85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 |
# File 'lib/clacky/server/browser_manager.rb', line 85 def reload Clacky::Logger.info("[BrowserManager] Reloading...") @mutex.synchronize { kill_process! } cfg = load_config @config = cfg if cfg["enabled"] == true Clacky::Logger.info("[BrowserManager] Browser enabled, restarting daemon") Thread.new do Thread.current.name = "browser-manager-reload" @mutex.synchronize { ensure_process! } rescue Clacky::BrowserNotReachableError => e # Expected: Chrome not running yet — will start lazily on first use Clacky::Logger.debug("[BrowserManager] Skipping reload start: Chrome not running") rescue StandardError => e # Unexpected error (handshake failure, port conflict, etc.) msg = e..to_s.lines.first&.strip || e..to_s Clacky::Logger.warn("[BrowserManager] Reload start failed: #{msg}") end else Clacky::Logger.info("[BrowserManager] Browser disabled after reload — daemon not started") end end |
#start ⇒ Object
Start the daemon if browser.yml marks the browser as enabled. Non-blocking — returns immediately (daemon spawn takes ~200ms in background).
55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 |
# File 'lib/clacky/server/browser_manager.rb', line 55 def start cfg = load_config unless cfg["enabled"] == true Clacky::Logger.info("[BrowserManager] Not enabled — skipping daemon start") return end @config = cfg Clacky::Logger.info("[BrowserManager] Browser enabled, pre-warming MCP daemon...") Thread.new do Thread.current.name = "browser-manager-start" @mutex.synchronize { ensure_process! } rescue Clacky::BrowserNotReachableError => e # Expected: Chrome not running yet — will start lazily on first use Clacky::Logger.debug("[BrowserManager] Skipping pre-warm: Chrome not running") rescue StandardError => e # Unexpected error (handshake failure, port conflict, etc.) msg = e..to_s.lines.first&.strip || e..to_s Clacky::Logger.warn("[BrowserManager] Pre-warm failed: #{msg}") end end |
#status ⇒ Hash
Returns a status hash with real daemon liveness. Uses wait_thr.alive? for a lightweight check — no ping, no mutex needed.
113 114 115 116 117 118 119 120 121 122 |
# File 'lib/clacky/server/browser_manager.rb', line 113 def status cfg = load_config enabled = cfg["enabled"] == true running = @process && @process[:wait_thr]&.alive? { enabled: enabled, daemon_running: !!running, chrome_version: cfg["chrome_version"] } end |
#stop ⇒ Object
Stop and clean up the daemon.
78 79 80 81 |
# File 'lib/clacky/server/browser_manager.rb', line 78 def stop @mutex.synchronize { kill_process! } Clacky::Logger.info("[BrowserManager] Daemon stopped") end |
#toggle ⇒ Boolean
Toggle the browser tool on/off by flipping ‘enabled` in browser.yml. Raises if browser.yml doesn’t exist (not yet set up).
142 143 144 145 146 147 148 149 150 151 152 |
# File 'lib/clacky/server/browser_manager.rb', line 142 def toggle raise "Browser not configured. Run /browser-setup first." unless File.exist?(BROWSER_CONFIG_PATH) cfg = load_config new_enabled = !(cfg["enabled"] == true) cfg["enabled"] = new_enabled File.write(BROWSER_CONFIG_PATH, cfg.to_yaml) @config = cfg reload new_enabled end |