Class: Clacky::BrowserManager

Inherits:
Object
  • Object
show all
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.expand_path("~/.clacky/browser.yml").freeze

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initializeBrowserManager

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

.instanceObject



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).

Parameters:

  • detected (Hash)

    { mode: :ws_endpoint, value: String } from BrowserDetector

Returns:

  • (Array<String>)

    command array



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_flagsObject

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.

Parameters:

  • chrome_version (String)

    detected Chrome major version



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.(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_configObject


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.message}")
  {}
end

#mcp_call(tool_name, arguments = {}) ⇒ Hash

Execute a chrome-devtools-mcp tool call. Ensures daemon is running first. Thread-safe via @mutex.

Parameters:

  • tool_name (String)
  • arguments (Hash) (defaults to: {})

Returns:

  • (Hash)

    parsed MCP result

Raises:



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.message
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.

Returns:

  • (Boolean)


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

#reloadObject

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.message.to_s.lines.first&.strip || e.message.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

#startObject

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.message.to_s.lines.first&.strip || e.message.to_s
    Clacky::Logger.warn("[BrowserManager] Pre-warm failed: #{msg}")
  end
end

#statusHash

Returns a status hash with real daemon liveness. Uses wait_thr.alive? for a lightweight check — no ping, no mutex needed.

Returns:

  • (Hash)

    { enabled: bool, daemon_running: bool, chrome_version: String|nil }



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

#stopObject

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

#toggleBoolean

Toggle the browser tool on/off by flipping ‘enabled` in browser.yml. Raises if browser.yml doesn’t exist (not yet set up).

Returns:

  • (Boolean)

    new enabled state



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