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.
-
#shell_escape(token) ⇒ Object
Shell-escape a single argv token for safe interpolation into a ‘-c` string.
-
#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.
44 45 46 47 48 49 |
# File 'lib/clacky/server/browser_manager.rb', line 44 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
39 40 41 |
# File 'lib/clacky/server/browser_manager.rb', line 39 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).
301 302 303 304 305 306 307 308 309 310 311 |
# File 'lib/clacky/server/browser_manager.rb', line 301 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
319 320 321 322 323 324 325 |
# File 'lib/clacky/server/browser_manager.rb', line 319 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.
129 130 131 132 133 134 135 136 137 138 139 |
# File 'lib/clacky/server/browser_manager.rb', line 129 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
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 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 |
# File 'lib/clacky/server/browser_manager.rb', line 219 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(' ')}") # Wrap in a shell that manually sources rc files (.zshrc/.bashrc) so # mise / rbenv / asdf activate and `chrome-devtools-mcp` (a node # binary installed under mise) is on PATH — otherwise the server, # when launched by launchd / a desktop icon with a minimal PATH, # cannot find node. # # LoginShell.login_shell_command builds argv like: # /bin/zsh -c "{ . ~/.zshrc; ... } 1>&2; exec chrome-devtools-mcp ..." # # The `1>&2` sends rc-time output (banners, mise warnings) to stderr, # keeping the child's stdout 100% clean for JSON-RPC. `exec` then # replaces the shell process with the MCP daemon itself, so the pid # / signals / waitpid we hold point at the real target. inner = cmd.map { |a| shell_escape(a) }.join(" ") wrapped = Clacky::Utils::LoginShell.login_shell_command(inner) # 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(*wrapped, 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
390 391 392 393 394 395 |
# File 'lib/clacky/server/browser_manager.rb', line 390 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
366 367 368 |
# File 'lib/clacky/server/browser_manager.rb', line 366 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.
343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 |
# File 'lib/clacky/server/browser_manager.rb', line 343 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
210 211 212 213 214 215 216 |
# File 'lib/clacky/server/browser_manager.rb', line 210 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.
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 203 204 |
# File 'lib/clacky/server/browser_manager.rb', line 167 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.
332 333 334 335 336 |
# File 'lib/clacky/server/browser_manager.rb', line 332 def process_alive? return false if @process.nil? @process[:wait_thr]&.alive? == true end |
#read_response(io, target_id:, timeout: 10) ⇒ Object
370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 |
# File 'lib/clacky/server/browser_manager.rb', line 370 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.
87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 |
# File 'lib/clacky/server/browser_manager.rb', line 87 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 |
#shell_escape(token) ⇒ Object
Shell-escape a single argv token for safe interpolation into a ‘-c` string.
314 315 316 |
# File 'lib/clacky/server/browser_manager.rb', line 314 def shell_escape(token) Shellwords.escape(token.to_s) end |
#start ⇒ Object
Start the daemon if browser.yml marks the browser as enabled. Non-blocking — returns immediately (daemon spawn takes ~200ms in background).
57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 |
# File 'lib/clacky/server/browser_manager.rb', line 57 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.
115 116 117 118 119 120 121 122 123 124 |
# File 'lib/clacky/server/browser_manager.rb', line 115 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.
80 81 82 83 |
# File 'lib/clacky/server/browser_manager.rb', line 80 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).
144 145 146 147 148 149 150 151 152 153 154 |
# File 'lib/clacky/server/browser_manager.rb', line 144 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 |