openclacky 0.9.7 → 0.9.9

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 (85) hide show
  1. checksums.yaml +4 -4
  2. data/.clacky/skills/gem-release/SKILL.md +16 -1
  3. data/CHANGELOG.md +51 -0
  4. data/docs/browser-cdp-native-design.md +195 -0
  5. data/docs/session-management-redesign.md +202 -0
  6. data/docs/system-skill-authoring-guide.md +47 -0
  7. data/lib/clacky/agent/cost_tracker.rb +2 -1
  8. data/lib/clacky/agent/message_compressor_helper.rb +2 -2
  9. data/lib/clacky/agent/session_serializer.rb +36 -3
  10. data/lib/clacky/agent/skill_manager.rb +68 -5
  11. data/lib/clacky/agent/system_prompt_builder.rb +30 -13
  12. data/lib/clacky/agent/tool_executor.rb +8 -12
  13. data/lib/clacky/agent.rb +172 -30
  14. data/lib/clacky/brand_config.rb +90 -0
  15. data/lib/clacky/cli.rb +37 -8
  16. data/lib/clacky/default_parsers/doc_parser.rb +69 -0
  17. data/lib/clacky/default_parsers/docx_parser.rb +172 -0
  18. data/lib/clacky/default_parsers/pdf_parser.rb +79 -0
  19. data/lib/clacky/default_parsers/pptx_parser.rb +140 -0
  20. data/lib/clacky/default_parsers/xlsx_parser.rb +121 -0
  21. data/lib/clacky/default_skills/browser-setup/SKILL.md +158 -0
  22. data/lib/clacky/default_skills/channel-setup/SKILL.md +139 -42
  23. data/lib/clacky/default_skills/channel-setup/feishu_setup.rb +582 -0
  24. data/lib/clacky/default_skills/channel-setup/weixin_setup.rb +274 -0
  25. data/lib/clacky/default_skills/onboard/SKILL.md +132 -6
  26. data/lib/clacky/default_skills/personal-website/SKILL.md +113 -0
  27. data/lib/clacky/default_skills/personal-website/publish.rb +214 -0
  28. data/lib/clacky/default_skills/skill-add/SKILL.md +6 -7
  29. data/lib/clacky/default_skills/skill-add/scripts/install_from_zip.rb +1 -1
  30. data/lib/clacky/default_skills/skill-creator/SKILL.md +3 -3
  31. data/lib/clacky/json_ui_controller.rb +5 -0
  32. data/lib/clacky/message_format/open_ai.rb +7 -1
  33. data/lib/clacky/plain_ui_controller.rb +5 -0
  34. data/lib/clacky/server/browser_manager.rb +308 -0
  35. data/lib/clacky/server/channel/adapters/feishu/adapter.rb +80 -15
  36. data/lib/clacky/server/channel/adapters/feishu/bot.rb +216 -7
  37. data/lib/clacky/server/channel/adapters/feishu/message_parser.rb +12 -0
  38. data/lib/clacky/server/channel/adapters/wecom/adapter.rb +2 -8
  39. data/lib/clacky/server/channel/adapters/weixin/adapter.rb +391 -0
  40. data/lib/clacky/server/channel/adapters/weixin/api_client.rb +374 -0
  41. data/lib/clacky/server/channel/channel_config.rb +6 -0
  42. data/lib/clacky/server/channel/channel_manager.rb +50 -15
  43. data/lib/clacky/server/channel/channel_ui_controller.rb +18 -0
  44. data/lib/clacky/server/channel.rb +1 -0
  45. data/lib/clacky/server/http_server.rb +210 -45
  46. data/lib/clacky/server/scheduler.rb +1 -1
  47. data/lib/clacky/server/session_registry.rb +131 -40
  48. data/lib/clacky/server/web_ui_controller.rb +21 -2
  49. data/lib/clacky/session_manager.rb +43 -73
  50. data/lib/clacky/skill.rb +64 -42
  51. data/lib/clacky/tools/browser.rb +728 -183
  52. data/lib/clacky/tools/invoke_skill.rb +18 -11
  53. data/lib/clacky/tools/shell.rb +16 -7
  54. data/lib/clacky/tools/web_fetch.rb +3 -1
  55. data/lib/clacky/ui2/README.md +1 -1
  56. data/lib/clacky/ui2/components/input_area.rb +5 -2
  57. data/lib/clacky/ui2/components/welcome_banner.rb +23 -0
  58. data/lib/clacky/ui2/ui_controller.rb +16 -2
  59. data/lib/clacky/ui_interface.rb +7 -0
  60. data/lib/clacky/utils/file_processor.rb +116 -139
  61. data/lib/clacky/utils/model_pricing.rb +5 -4
  62. data/lib/clacky/utils/parser_manager.rb +93 -0
  63. data/lib/clacky/utils/workspace_rules.rb +46 -0
  64. data/lib/clacky/version.rb +1 -1
  65. data/lib/clacky/web/app.css +696 -62
  66. data/lib/clacky/web/app.js +181 -51
  67. data/lib/clacky/web/brand.js +14 -0
  68. data/lib/clacky/web/channels.js +10 -1
  69. data/lib/clacky/web/i18n.js +49 -7
  70. data/lib/clacky/web/index.html +67 -10
  71. data/lib/clacky/web/onboard.js +2 -1
  72. data/lib/clacky/web/sessions.js +255 -69
  73. data/lib/clacky/web/settings.js +113 -1
  74. data/lib/clacky/web/skills.js +16 -3
  75. data/lib/clacky/web/tasks.js +11 -6
  76. data/lib/clacky/web/weixin-qr.html +104 -0
  77. data/lib/clacky.rb +2 -0
  78. data/scripts/install.sh +41 -11
  79. metadata +48 -7
  80. data/lib/clacky/default_skills/pdf-reader/SKILL.md +0 -90
  81. data/lib/clacky/utils/file_parser/docx_parser.rb +0 -156
  82. data/lib/clacky/utils/file_parser/pptx_parser.rb +0 -116
  83. data/lib/clacky/utils/file_parser/xlsx_parser.rb +0 -95
  84. data/lib/clacky/utils/file_parser/zip_parser.rb +0 -60
  85. data/scripts/install_agent_browser.sh +0 -67
@@ -0,0 +1,214 @@
1
+ #!/usr/bin/env ruby
2
+ # publish.rb — Publish or delete a profile card on the platform
3
+ #
4
+ # Usage:
5
+ # ruby publish.rb publish --name "NAME" --html-file /path/to/card.html
6
+ # ruby publish.rb delete --slug SLUG
7
+ #
8
+ # On first publish, prints the card URL and saves the update token to
9
+ # ~/.clacky/card_token (used for future updates/deletes).
10
+ #
11
+ # Environment:
12
+ # CARD_API_BASE — platform base URL (default: https://app.clacky.ai)
13
+ # CARD_HMAC_SECRET — shared secret (default matches platform default)
14
+
15
+ require "net/http"
16
+ require "uri"
17
+ require "json"
18
+ require "openssl"
19
+ require "digest"
20
+ require "optparse"
21
+ require "fileutils"
22
+
23
+ # ── Config ───────────────────────────────────────────────────────────────────
24
+
25
+ API_BASE = ENV.fetch("CLACKY_LICENSE_SERVER", "https://www.openclacky.com")
26
+ HMAC_SECRET = ENV.fetch("CARD_HMAC_SECRET", "openclacky-card-v1-default-secret-change-me")
27
+ TOKEN_FILE = File.expand_path("~/clacky_workspace/personal_website/token.json")
28
+
29
+ # ── HMAC signing ─────────────────────────────────────────────────────────────
30
+
31
+ def device_fingerprint
32
+ # Combine stable machine identifiers into an opaque hash
33
+ parts = []
34
+ parts << `hostname`.strip
35
+ # macOS: hardware UUID
36
+ hw = `system_profiler SPHardwareDataType 2>/dev/null | grep 'Hardware UUID'`.strip
37
+ parts << hw unless hw.empty?
38
+ # Fallback: username
39
+ parts << ENV["USER"].to_s
40
+ Digest::SHA256.hexdigest(parts.join("|"))[0, 16]
41
+ end
42
+
43
+ def hmac_headers
44
+ ts = Time.now.to_i.to_s
45
+ fingerprint = device_fingerprint
46
+ payload = "openclacky:#{ts}:#{fingerprint}"
47
+ signature = OpenSSL::HMAC.hexdigest("SHA256", HMAC_SECRET, payload)
48
+ {
49
+ "X-Card-Timestamp" => ts,
50
+ "X-Card-Fingerprint" => fingerprint,
51
+ "X-Card-Signature" => signature,
52
+ "Content-Type" => "application/json"
53
+ }
54
+ end
55
+
56
+ # ── HTTP helpers ──────────────────────────────────────────────────────────────
57
+
58
+ def http_request(method, path, body: nil)
59
+ uri = URI.parse("#{API_BASE}#{path}")
60
+ http = Net::HTTP.new(uri.host, uri.port)
61
+ http.use_ssl = uri.scheme == "https"
62
+ http.read_timeout = 15
63
+ http.open_timeout = 10
64
+
65
+ req_class = { "POST" => Net::HTTP::Post, "PATCH" => Net::HTTP::Patch,
66
+ "DELETE" => Net::HTTP::Delete }[method]
67
+ req = req_class.new(uri.path)
68
+ hmac_headers.each { |k, v| req[k] = v }
69
+ req.body = body.to_json if body
70
+
71
+ response = http.request(req)
72
+ parsed = JSON.parse(response.body) rescue { "raw" => response.body }
73
+ [response.code.to_i, parsed]
74
+ rescue => e
75
+ warn "❌ Network error: #{e.message}"
76
+ exit 1
77
+ end
78
+
79
+ # ── Token storage ─────────────────────────────────────────────────────────────
80
+
81
+ def load_token_data
82
+ return {} unless File.exist?(TOKEN_FILE)
83
+ JSON.parse(File.read(TOKEN_FILE)) rescue {}
84
+ end
85
+
86
+ def save_token_data(data)
87
+ FileUtils.mkdir_p(File.dirname(TOKEN_FILE))
88
+ File.write(TOKEN_FILE, JSON.pretty_generate(data))
89
+ File.chmod(0600, TOKEN_FILE)
90
+ end
91
+
92
+ # ── Commands ──────────────────────────────────────────────────────────────────
93
+
94
+ def cmd_publish(name:, html_file:)
95
+ unless File.exist?(html_file)
96
+ warn "❌ HTML file not found: #{html_file}"
97
+ exit 1
98
+ end
99
+
100
+ html_content = File.read(html_file, encoding: "utf-8")
101
+
102
+ if html_content.bytesize > 1_048_576
103
+ warn "❌ HTML file exceeds 1MB (#{html_content.bytesize / 1024}KB)"
104
+ exit 1
105
+ end
106
+
107
+ token_data = load_token_data
108
+
109
+ # If we already have a slug + token, do an update (PATCH) instead of create
110
+ if token_data["slug"] && token_data["update_token"]
111
+ slug = token_data["slug"]
112
+ token = token_data["update_token"]
113
+
114
+ uri = URI.parse("#{API_BASE}/api/v1/personal_websites/#{slug}")
115
+ http = Net::HTTP.new(uri.host, uri.port)
116
+ http.use_ssl = uri.scheme == "https"
117
+ req = Net::HTTP::Patch.new(uri.path)
118
+ hmac_headers.each { |k, v| req[k] = v }
119
+ req["X-Card-Token"] = token
120
+ req.body = { html_content: html_content }.to_json
121
+ resp = http.request(req)
122
+ status = resp.code.to_i
123
+ body = JSON.parse(resp.body) rescue { "raw" => resp.body }
124
+
125
+ if status == 200
126
+ puts "✅ Website updated: #{body["url"]}"
127
+ else
128
+ warn "❌ Update failed (#{status}): #{body["error"] || body.inspect}"
129
+ exit 1
130
+ end
131
+ else
132
+ # First publish — POST to create
133
+ status, body = http_request("POST", "/api/v1/personal_websites",
134
+ body: { name: name, html_content: html_content })
135
+
136
+ if status == 201
137
+ save_token_data("slug" => body["slug"], "update_token" => body["update_token"])
138
+ puts "✅ Website published: #{body["url"]}"
139
+ puts " Slug: #{body["slug"]}"
140
+ puts " Token saved to: #{TOKEN_FILE}"
141
+ else
142
+ warn "❌ Publish failed (#{status}): #{body["error"] || body.inspect}"
143
+ exit 1
144
+ end
145
+ end
146
+ end
147
+
148
+ def cmd_delete(slug: nil)
149
+ token_data = load_token_data
150
+ token = token_data["update_token"]
151
+ slug = slug || token_data["slug"]
152
+
153
+ unless token && slug
154
+ warn "❌ No published website found (#{TOKEN_FILE} missing or incomplete)."
155
+ warn " Nothing to delete."
156
+ exit 1
157
+ end
158
+
159
+ uri = URI.parse("#{API_BASE}/api/v1/personal_websites/#{slug}")
160
+ http = Net::HTTP.new(uri.host, uri.port)
161
+ http.use_ssl = uri.scheme == "https"
162
+ req = Net::HTTP::Delete.new(uri.path)
163
+ hmac_headers.each { |k, v| req[k] = v }
164
+ req["X-Card-Token"] = token
165
+
166
+ resp = http.request(req)
167
+ status = resp.code.to_i
168
+ body = JSON.parse(resp.body) rescue { "raw" => resp.body }
169
+
170
+ if status == 200
171
+ File.delete(TOKEN_FILE) if File.exist?(TOKEN_FILE)
172
+ puts "✅ Personal website deleted: /~#{slug}"
173
+ else
174
+ warn "❌ Delete failed (#{status}): #{body["error"] || body.inspect}"
175
+ exit 1
176
+ end
177
+ rescue => e
178
+ warn "❌ Network error: #{e.message}"
179
+ exit 1
180
+ end
181
+
182
+ # ── CLI parsing ───────────────────────────────────────────────────────────────
183
+
184
+ command = ARGV.shift
185
+
186
+ case command
187
+ when "publish"
188
+ options = {}
189
+ OptionParser.new do |opts|
190
+ opts.on("--name NAME") { |v| options[:name] = v }
191
+ opts.on("--html-file FILE") { |v| options[:html_file] = v }
192
+ end.parse!
193
+
194
+ unless options[:name] && options[:html_file]
195
+ warn "Usage: ruby publish.rb publish --name NAME --html-file FILE"
196
+ exit 1
197
+ end
198
+
199
+ cmd_publish(name: options[:name], html_file: File.expand_path(options[:html_file]))
200
+
201
+ when "delete"
202
+ options = {}
203
+ OptionParser.new do |opts|
204
+ opts.on("--slug SLUG") { |v| options[:slug] = v } # optional, auto-read from token file
205
+ end.parse!
206
+
207
+ cmd_delete(slug: options[:slug])
208
+
209
+ else
210
+ warn "Usage: ruby publish.rb publish|delete [options]"
211
+ warn " publish --name NAME --html-file FILE"
212
+ warn " delete --slug SLUG"
213
+ exit 1
214
+ end
@@ -11,12 +11,12 @@ Installs a skill from a zip URL **or a local zip file path** using the bundled `
11
11
 
12
12
  ## How to Install
13
13
 
14
- The script path is available as `$SKILL_DIR/scripts/install_from_zip.rb` — use it directly, no `find` needed.
14
+ The script path is listed in the Supporting Files above (`scripts/install_from_zip.rb`) — use the full path directly, no `find` needed.
15
15
 
16
16
  The script accepts **either a remote URL or a local file path**:
17
17
 
18
18
  ```bash
19
- ruby "$SKILL_DIR/scripts/install_from_zip.rb" <zip_url_or_path> [slug]
19
+ ruby "SKILL_DIR/scripts/install_from_zip.rb" <zip_url_or_path> [slug]
20
20
  ```
21
21
 
22
22
  - `<zip_url_or_path>` — a remote `https://` URL **or** an absolute/relative local path to a `.zip` file
@@ -26,7 +26,7 @@ The script handles everything automatically:
26
26
  - For URLs: downloads the zip (follows HTTP redirects)
27
27
  - For local paths: reads the file directly (no download needed)
28
28
  - Extracts and locates all `SKILL.md` files inside
29
- - Copies skill directories to `.clacky/skills/` in the current project (overwrites existing)
29
+ - Copies skill directories to `~/.clacky/skills/`
30
30
  - Reports installed skills with their descriptions
31
31
 
32
32
  **Do NOT manually download or unzip — the script handles everything.**
@@ -38,7 +38,7 @@ The script handles everything automatically:
38
38
  /skill-add https://store.clacky.ai/skills/canvas-design-1.2.0.zip
39
39
  ```
40
40
  ```bash
41
- ruby "$SKILL_DIR/scripts/install_from_zip.rb" \
41
+ ruby "SKILL_DIR/scripts/install_from_zip.rb" \
42
42
  "https://store.clacky.ai/skills/canvas-design-1.2.0.zip" \
43
43
  "canvas-design"
44
44
  ```
@@ -48,13 +48,12 @@ ruby "$SKILL_DIR/scripts/install_from_zip.rb" \
48
48
  /skill-add /Users/alice/Downloads/my-skill-1.0.0.zip
49
49
  ```
50
50
  ```bash
51
- ruby "$SKILL_DIR/scripts/install_from_zip.rb" \
51
+ ruby "SKILL_DIR/scripts/install_from_zip.rb" \
52
52
  "/Users/alice/Downloads/my-skill-1.0.0.zip"
53
53
  ```
54
54
 
55
55
  ## Notes
56
56
 
57
- - Skills install to `.clacky/skills/` in the current project
58
- - Project-level skills override global skills (`~/.clacky/skills/`)
57
+ - Skills install to `~/.clacky/skills/` by default
59
58
  - Local paths may be absolute (`/path/to/skill.zip`) or use `~` (`~/Downloads/skill.zip`)
60
59
  - If the user doesn't provide a URL or path, ask them for the zip source
@@ -26,7 +26,7 @@ class ZipSkillInstaller
26
26
  # If not provided, we try to infer it from the filename in the URL/path, e.g.
27
27
  # "ui-ux-pro-max-1.0.0.zip" → "ui-ux-pro-max".
28
28
  @skill_name = skill_name || infer_skill_name(zip_source)
29
- @target_dir = target_dir || File.join(Dir.pwd, '.clacky', 'skills')
29
+ @target_dir = target_dir || File.join(Dir.home, '.clacky', 'skills')
30
30
  @installed_skills = []
31
31
  @errors = []
32
32
  end
@@ -151,13 +151,13 @@ end
151
151
  puts result # stdout is the output
152
152
  ```
153
153
 
154
- Invoke from SKILL.md using the built-in `$SKILL_DIR` variableit expands to the skill's absolute directory path at runtime, so the script reference always works regardless of where the skill is installed (user-level, project-level, or shipped with the gem):
154
+ Invoke from SKILL.md by referencing the script via the Supporting Files block at runtime, the AI receives the full absolute path of every supporting file. Refer to it as `SKILL_DIR` in instructions so the AI substitutes the correct path from the Supporting Files list:
155
155
 
156
156
  ```bash
157
- ruby "$SKILL_DIR/scripts/do_something.rb" "argument"
157
+ ruby "SKILL_DIR/scripts/do_something.rb" "argument"
158
158
  ```
159
159
 
160
- Never hardcode paths like `~/.clacky/skills/my-skill/scripts/...` — they break when the skill is installed at a different location. Never use `find` to locate scripts — `$SKILL_DIR` is always correct and requires no shell command.
160
+ Never hardcode paths like `~/.clacky/skills/my-skill/scripts/...` — they break when the skill is installed at a different location. Never use `find` to locate scripts — the Supporting Files block always provides the correct absolute paths.
161
161
 
162
162
  Ruby standard library covers most needs (`net/http`, `json`, `fileutils`, `uri`, `time`). No gems needed for basic API calls.
163
163
 
@@ -97,6 +97,10 @@ module Clacky
97
97
  emit("info", message: message)
98
98
  end
99
99
 
100
+ def show_idle_status(phase:, message:)
101
+ emit("idle_status", phase: phase.to_s, message: message)
102
+ end
103
+
100
104
  def show_warning(message)
101
105
  emit("warning", message: message)
102
106
  end
@@ -189,5 +193,6 @@ module Clacky
189
193
  def stop
190
194
  # no-op
191
195
  end
196
+
192
197
  end
193
198
  end
@@ -99,10 +99,16 @@ module Clacky
99
99
 
100
100
  response[:tool_calls].map do |tc|
101
101
  result = results_map[tc[:id]]
102
+ raw_content = result ? result[:content] : { error: "Tool result missing" }.to_json
103
+
104
+ # OpenAI tool message content must be a String.
105
+ # If a tool returned multipart Array blocks (e.g. screenshot image), convert to JSON.
106
+ content = raw_content.is_a?(Array) ? JSON.generate(raw_content) : raw_content
107
+
102
108
  {
103
109
  role: "tool",
104
110
  tool_call_id: tc[:id],
105
- content: result ? result[:content] : { error: "Tool result missing" }.to_json
111
+ content: content
106
112
  }
107
113
  end
108
114
  end
@@ -87,6 +87,11 @@ module Clacky
87
87
  puts_line("[info] #{message}")
88
88
  end
89
89
 
90
+ def show_idle_status(phase:, message:)
91
+ # In plain mode, just print the final state
92
+ puts_line("[info] #{message}") if phase.to_s == "end"
93
+ end
94
+
90
95
  def show_warning(message)
91
96
  puts_line("[warn] #{message}")
92
97
  end
@@ -0,0 +1,308 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+
5
+ module Clacky
6
+ # BrowserManager owns the chrome-devtools-mcp daemon lifecycle.
7
+ #
8
+ # It mirrors the ChannelManager pattern:
9
+ # - start → read browser.yml; if enabled, pre-warm the MCP daemon
10
+ # - stop → kill the daemon
11
+ # - reload → stop + re-read yml + start (called after browser-setup writes yml)
12
+ # - status → { enabled: bool, daemon_running: bool, chrome_version: String|nil }
13
+ # - toggle → flip enabled in browser.yml and reload
14
+ #
15
+ # browser.yml schema:
16
+ # enabled: true/false — whether the browser tool is active
17
+ # chrome_version: "146" — detected Chrome version (set by browser-setup skill)
18
+ # configured_at: date — when setup was last run
19
+ #
20
+ # Liveness check strategy:
21
+ # process_alive? sends an MCP `ping` (standard in MCP spec 2024-11-05) and
22
+ # waits up to 3s for a response. If the ping succeeds the daemon is healthy.
23
+ # If it times out or raises an IO error the daemon is truly dead — kill it so
24
+ # ensure_process! will spawn a fresh one on the next call.
25
+ #
26
+ # Chrome connection problems (e.g. Chrome closed) surface only during the
27
+ # actual mcp_call and are reported back to the caller; they do NOT trigger a
28
+ # daemon restart.
29
+ #
30
+ # Browser tool (browser.rb) delegates daemon access here instead of using
31
+ # class-level @@mcp_process variables directly. BrowserManager holds the
32
+ # single mutable state; the mutex lives here too.
33
+ class BrowserManager
34
+ BROWSER_CONFIG_PATH = File.expand_path("~/.clacky/browser.yml").freeze
35
+
36
+ class << self
37
+ def instance
38
+ @instance ||= new
39
+ end
40
+ end
41
+
42
+ def initialize
43
+ @process = nil # { stdin:, stdout:, pid:, wait_thr: }
44
+ @mutex = Mutex.new
45
+ @call_id = 2 # 1 reserved for MCP initialize handshake
46
+ @config = {} # last successfully read browser.yml content
47
+ end
48
+
49
+ # ---------------------------------------------------------------------------
50
+ # Lifecycle
51
+ # ---------------------------------------------------------------------------
52
+
53
+ # Start the daemon if browser.yml marks the browser as enabled.
54
+ # Non-blocking — returns immediately (daemon spawn takes ~200ms in background).
55
+ def start
56
+ cfg = load_config
57
+ unless cfg["enabled"] == true
58
+ Clacky::Logger.info("[BrowserManager] Not enabled — skipping daemon start")
59
+ return
60
+ end
61
+
62
+ @config = cfg
63
+ Clacky::Logger.info("[BrowserManager] Browser enabled, pre-warming MCP daemon...")
64
+ Thread.new do
65
+ Thread.current.name = "browser-manager-start"
66
+ @mutex.synchronize { ensure_process! }
67
+ rescue StandardError => e
68
+ Clacky::Logger.warn("[BrowserManager] Pre-warm failed: #{e.message}")
69
+ end
70
+ end
71
+
72
+ # Stop and clean up the daemon.
73
+ def stop
74
+ @mutex.synchronize { kill_process! }
75
+ Clacky::Logger.info("[BrowserManager] Daemon stopped")
76
+ end
77
+
78
+ # Hot-reload: stop existing daemon, re-read yml, restart if enabled.
79
+ # Called by HttpServer after browser-setup writes a new browser.yml.
80
+ def reload
81
+ Clacky::Logger.info("[BrowserManager] Reloading...")
82
+ @mutex.synchronize { kill_process! }
83
+
84
+ cfg = load_config
85
+ @config = cfg
86
+
87
+ if cfg["enabled"] == true
88
+ Clacky::Logger.info("[BrowserManager] Browser enabled, restarting daemon")
89
+ Thread.new do
90
+ Thread.current.name = "browser-manager-reload"
91
+ @mutex.synchronize { ensure_process! }
92
+ rescue StandardError => e
93
+ Clacky::Logger.warn("[BrowserManager] Reload start failed: #{e.message}")
94
+ end
95
+ else
96
+ Clacky::Logger.info("[BrowserManager] Browser disabled after reload — daemon not started")
97
+ end
98
+ end
99
+
100
+ # Returns a status hash with real daemon liveness.
101
+ # @return [Hash] { enabled: bool, daemon_running: bool, chrome_version: String|nil }
102
+ def status
103
+ cfg = load_config
104
+ enabled = cfg["enabled"] == true
105
+ running = @mutex.synchronize { process_alive? }
106
+ {
107
+ enabled: enabled,
108
+ daemon_running: running,
109
+ chrome_version: cfg["chrome_version"]
110
+ }
111
+ end
112
+
113
+ # Write browser.yml with the given config and reload the daemon.
114
+ # Called by HttpServer POST /api/browser/configure.
115
+ # @param chrome_version [String] detected Chrome major version
116
+ def configure(chrome_version:)
117
+ cfg = {
118
+ "enabled" => true,
119
+ "browser" => "chrome",
120
+ "chrome_version" => chrome_version.to_s,
121
+ "configured_at" => Date.today.to_s
122
+ }
123
+ FileUtils.mkdir_p(File.dirname(BROWSER_CONFIG_PATH))
124
+ File.write(BROWSER_CONFIG_PATH, cfg.to_yaml)
125
+ reload
126
+ end
127
+
128
+ # Toggle the browser tool on/off by flipping `enabled` in browser.yml.
129
+ # Raises if browser.yml doesn't exist (not yet set up).
130
+ # @return [Boolean] new enabled state
131
+ def toggle
132
+ raise "Browser not configured. Run /browser-setup first." unless File.exist?(BROWSER_CONFIG_PATH)
133
+
134
+ cfg = load_config
135
+ new_enabled = !(cfg["enabled"] == true)
136
+ cfg["enabled"] = new_enabled
137
+ File.write(BROWSER_CONFIG_PATH, cfg.to_yaml)
138
+ @config = cfg
139
+ reload
140
+ new_enabled
141
+ end
142
+
143
+ # ---------------------------------------------------------------------------
144
+ # MCP call interface — used by Browser tool
145
+ # ---------------------------------------------------------------------------
146
+
147
+ # Execute a chrome-devtools-mcp tool call. Ensures daemon is running first.
148
+ # Thread-safe via @mutex.
149
+ # @param tool_name [String]
150
+ # @param arguments [Hash]
151
+ # @return [Hash] parsed MCP result
152
+ # @raise [RuntimeError] on timeout or protocol error
153
+ def mcp_call(tool_name, arguments = {})
154
+ call_resp = nil
155
+
156
+ @mutex.synchronize do
157
+ ensure_process!
158
+
159
+ call_id = @call_id
160
+ @call_id += 1
161
+
162
+ msg = json_rpc("tools/call", { name: tool_name, arguments: arguments }, id: call_id)
163
+ @process[:stdin].write(msg + "\n")
164
+ @process[:stdin].flush
165
+
166
+ call_resp = read_response(@process[:stdout], target_id: call_id,
167
+ timeout: Clacky::Tools::Browser::MCP_CALL_TIMEOUT)
168
+
169
+ unless call_resp
170
+ kill_process!
171
+ raise "Chrome MCP tools/call '#{tool_name}' timed out after #{Clacky::Tools::Browser::MCP_CALL_TIMEOUT}s"
172
+ end
173
+
174
+ if call_resp["error"]
175
+ err = call_resp["error"]
176
+ raise "Chrome MCP error: #{err.is_a?(Hash) ? err["message"] : err}"
177
+ end
178
+
179
+ result = call_resp["result"] || {}
180
+
181
+ if result["isError"]
182
+ text = extract_text_content(result)
183
+ raise text.empty? ? "Chrome MCP tool '#{tool_name}' failed" : text
184
+ end
185
+
186
+ result
187
+ end
188
+ end
189
+
190
+ # ---------------------------------------------------------------------------
191
+ # Private
192
+ # ---------------------------------------------------------------------------
193
+ private
194
+
195
+ def load_config
196
+ return {} unless File.exist?(BROWSER_CONFIG_PATH)
197
+ YAML.safe_load(File.read(BROWSER_CONFIG_PATH), permitted_classes: [Date, Time, Symbol]) || {}
198
+ rescue StandardError => e
199
+ Clacky::Logger.warn("[BrowserManager] Failed to read browser.yml: #{e.message}")
200
+ {}
201
+ end
202
+
203
+ # Must be called inside @mutex
204
+ def ensure_process!
205
+ return if process_alive?
206
+
207
+ cmd = Clacky::Tools::Browser.build_mcp_command
208
+ stdin, stdout, stderr_io, wait_thr = Open3.popen3(*cmd)
209
+ Thread.new { stderr_io.read rescue nil }
210
+
211
+ # MCP handshake
212
+ init_msg = json_rpc("initialize", {
213
+ protocolVersion: "2024-11-05",
214
+ capabilities: {},
215
+ clientInfo: { name: "clacky", version: "1.0" }
216
+ }, id: 1)
217
+
218
+ notify_msg = JSON.generate({
219
+ jsonrpc: "2.0",
220
+ method: "notifications/initialized",
221
+ params: {}
222
+ })
223
+
224
+ stdin.write(init_msg + "\n")
225
+ stdin.flush
226
+
227
+ init_resp = read_response(stdout, target_id: 1,
228
+ timeout: Clacky::Tools::Browser::MCP_HANDSHAKE_TIMEOUT)
229
+ unless init_resp
230
+ Process.kill("TERM", wait_thr.pid) rescue nil
231
+ raise "Chrome MCP initialize handshake timed out"
232
+ end
233
+
234
+ stdin.write(notify_msg + "\n")
235
+ stdin.flush
236
+
237
+ @process = { stdin: stdin, stdout: stdout, pid: wait_thr.pid, wait_thr: wait_thr }
238
+ @call_id = 2
239
+ Clacky::Logger.info("[BrowserManager] MCP daemon started (pid=#{wait_thr.pid})")
240
+ end
241
+
242
+ # Must be called inside @mutex.
243
+ # Sends an MCP `ping` (MCP spec 2024-11-05) and waits up to 3s for a pong.
244
+ # A successful ping means the MCP daemon process is alive and responsive.
245
+ # Chrome connection health is NOT checked here — Chrome problems surface only
246
+ # during the actual mcp_call and are reported back to the caller.
247
+ def process_alive?
248
+ return false if @process.nil?
249
+
250
+ ping_id = 0
251
+ msg = json_rpc("ping", {}, id: ping_id)
252
+ @process[:stdin].write(msg + "\n")
253
+ @process[:stdin].flush
254
+ resp = read_response(@process[:stdout], target_id: ping_id, timeout: 3)
255
+ if resp.nil?
256
+ kill_process!
257
+ return false
258
+ end
259
+ true
260
+ rescue StandardError
261
+ kill_process!
262
+ false
263
+ end
264
+
265
+ # Must be called inside @mutex
266
+ def kill_process!
267
+ ps = @process
268
+ return unless ps
269
+
270
+ Process.kill("TERM", ps[:pid]) rescue nil
271
+ ps[:stdin].close rescue nil
272
+ ps[:stdout].close rescue nil
273
+ @process = nil
274
+ Clacky::Logger.info("[BrowserManager] MCP daemon killed")
275
+ end
276
+
277
+ def json_rpc(method, params, id:)
278
+ JSON.generate({ jsonrpc: "2.0", id: id, method: method, params: params })
279
+ end
280
+
281
+ def read_response(io, target_id:, timeout: 10)
282
+ Timeout.timeout(timeout) do
283
+ loop do
284
+ line = io.gets
285
+ break if line.nil?
286
+ line = line.strip
287
+ next if line.empty?
288
+ begin
289
+ msg = JSON.parse(line)
290
+ return msg if msg.is_a?(Hash) && msg["id"] == target_id
291
+ rescue JSON::ParserError
292
+ next
293
+ end
294
+ end
295
+ nil
296
+ end
297
+ rescue Timeout::Error
298
+ nil
299
+ end
300
+
301
+ def extract_text_content(result)
302
+ Array(result["content"])
303
+ .select { |b| b.is_a?(Hash) && b["type"] == "text" }
304
+ .map { |b| b["text"].to_s }
305
+ .join("\n")
306
+ end
307
+ end
308
+ end