openclacky 1.2.2 → 1.2.4

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 (39) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +36 -0
  3. data/lib/clacky/agent.rb +11 -0
  4. data/lib/clacky/client.rb +8 -4
  5. data/lib/clacky/default_skills/browser-setup/SKILL.md +90 -0
  6. data/lib/clacky/default_skills/channel-manager/SKILL.md +1 -0
  7. data/lib/clacky/default_skills/channel-manager/weixin_setup.rb +28 -8
  8. data/lib/clacky/mcp/stdio_transport.rb +6 -1
  9. data/lib/clacky/providers.rb +5 -7
  10. data/lib/clacky/server/browser_manager.rb +16 -3
  11. data/lib/clacky/server/channel/adapters/feishu/message_parser.rb +46 -1
  12. data/lib/clacky/server/channel/adapters/wecom/adapter.rb +38 -13
  13. data/lib/clacky/server/channel/channel_manager.rb +14 -4
  14. data/lib/clacky/server/channel/channel_ui_controller.rb +27 -11
  15. data/lib/clacky/server/http_server.rb +13 -3
  16. data/lib/clacky/tools/browser.rb +3 -3
  17. data/lib/clacky/tools/glob.rb +19 -4
  18. data/lib/clacky/tools/grep.rb +13 -1
  19. data/lib/clacky/tools/security.rb +1 -2
  20. data/lib/clacky/tools/terminal.rb +210 -14
  21. data/lib/clacky/ui2/ui_controller.rb +20 -2
  22. data/lib/clacky/utils/file_ignore_helper.rb +78 -5
  23. data/lib/clacky/utils/login_shell.rb +3 -1
  24. data/lib/clacky/utils/model_pricing.rb +28 -3
  25. data/lib/clacky/utils/scripts_manager.rb +1 -0
  26. data/lib/clacky/version.rb +1 -1
  27. data/lib/clacky/web/app.css +1 -0
  28. data/lib/clacky/web/settings.js +5 -0
  29. data/lib/clacky/web/weixin-qr.html +5 -4
  30. data/scripts/build/lib/apt.sh +71 -1
  31. data/scripts/build/src/install.sh.cc +1 -1
  32. data/scripts/build/src/install_rails_deps.sh.cc +4 -4
  33. data/scripts/build/src/install_system_deps.sh.cc +1 -1
  34. data/scripts/install.ps1 +44 -17
  35. data/scripts/install.sh +72 -2
  36. data/scripts/install_rails_deps.sh +75 -5
  37. data/scripts/install_system_deps.sh +72 -2
  38. data/scripts/wsl_network_doctor.ps1 +196 -0
  39. metadata +3 -2
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: cfaa1f45ce6ab05b0a101571f59e809d92430ee862196b21a714ceb56c3ec8b5
4
- data.tar.gz: 6d76cd4d7bf8568fd8229dc9770f23fb027296c660ad0ba3d514be61df69c5ce
3
+ metadata.gz: 80e1b5549b0e55760ec4e0e905eea3ab50bc9a66bd8c256e628a1afdb8531ba0
4
+ data.tar.gz: 54142ef66c3b74d3998214202fa73618dd9b22ebdb06586c8d6896aafcb56d9d
5
5
  SHA512:
6
- metadata.gz: aa5e444bc28d570022df63172027028d99224191f6d2a0b26537cb5c1597c523b165b35efd58747312586aa01f133c522b5c9cae46b5f25d06501b1a083b6e31
7
- data.tar.gz: ae1d58c07b9a24937b1c4182e19a093394565fdc972c5caf72cba66f3bf0419cbdd0874f7b2d741d7b00b8efdd8fe83a2efee767f7df1a3e8e6656b237631721
6
+ metadata.gz: d69d02391cc1929d26f2cfc2a260d27cb6b1f6840c254ae67f7ad358f621ba16eb2476fd5cee28308f7eaf2e0eb21d5983b49de4f84c6905863292625644641a
7
+ data.tar.gz: 54dbeb6d31f187edc30ea4e6030776e988cf50ee7ed44a2177a77473d00c47ae358df25dfd9bd526ea93126b94733feaca5a63048cde8657201c4c1e7cd05edd
data/CHANGELOG.md CHANGED
@@ -5,6 +5,42 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [1.2.4] - 2026-05-27
9
+
10
+ ### Added
11
+ - Browser/agent snapshot now supports `with_page` for paginated results
12
+ - Prefer WSL1 over WSL2 for better compatibility
13
+
14
+ ### Fixed
15
+ - Feishu post message type and WeCom mixed message type not handled properly
16
+ - WeChat stale QR code session not detected when reconfiguring channel
17
+ - Channel adapter using stale reference after reconfiguration
18
+ - Benchmark terminated early by 10-second outer request timeout
19
+ - Model card action buttons overflowing on narrow viewports
20
+ - Save button staying disabled when reopening model edit modal
21
+ - WSL pipe breaking when `redirect_exe_stdin` appends `</dev/null`
22
+ - MCP registry not hot-reloading after writing config
23
+ - MCP server breaking from bashrc PS1 customization
24
+ - Terminal realtime output not streaming correctly
25
+ - PowerShell UTF-8 encoding issues
26
+ - WSL bashrc not loading in non-interactive shells
27
+ - Qwen 3.7 Max incorrectly treated as vision-capable model
28
+
29
+ ### More
30
+ - Log service start failure reason for easier diagnostics
31
+
32
+ ## [1.2.3] - 2026-05-27
33
+
34
+ ### Added
35
+ - Qwen 3.7 model support
36
+ - WSL network doctor PowerShell script
37
+ - Limit on glob/grep file walker to prevent excessive traversal
38
+
39
+ ### Fixed
40
+ - Terminal single-line mode issue
41
+ - Terminal execution hang
42
+ - apt try logic
43
+
8
44
  ## [1.2.2] - 2026-05-25
9
45
 
10
46
  ### More
data/lib/clacky/agent.rb CHANGED
@@ -910,6 +910,17 @@ module Clacky
910
910
  # Inject working_dir so tools don't rely on Dir.chdir global state
911
911
  args[:working_dir] = @working_dir if @working_dir
912
912
 
913
+ # For terminal: stream live stdout chunks to the UI as they arrive,
914
+ # so the user sees real-time output (e.g. build logs) instead of a
915
+ # blank spinner. The UI buffers lines for Ctrl+O fullscreen view
916
+ # (CLI) and emits tool_stdout WS events (WebUI) that the browser
917
+ # appends to the running .tool-item.
918
+ if call[:name] == "terminal" && @ui.respond_to?(:show_tool_stdout)
919
+ args[:on_output] = ->(chunk) {
920
+ @ui.show_tool_stdout([chunk])
921
+ }
922
+ end
923
+
913
924
  # Show progress immediately for every tool execution so the user
914
925
  # always knows the agent is working. Using +with_progress+ wraps
915
926
  # the execution in an +ensure+ block so the spinner/ticker is
data/lib/clacky/client.rb CHANGED
@@ -10,7 +10,7 @@ module Clacky
10
10
 
11
11
  attr_reader :provider_id
12
12
 
13
- def initialize(api_key, base_url:, model:, anthropic_format: false)
13
+ def initialize(api_key, base_url:, model:, anthropic_format: false, read_timeout: nil)
14
14
  @api_key = api_key
15
15
  @base_url = base_url
16
16
  @model = model
@@ -38,6 +38,10 @@ module Clacky
38
38
  # Non-vision models (DeepSeek, Kimi, MiniMax, etc.) reject image_url
39
39
  # content blocks; the conversion layer strips them when this is false.
40
40
  @vision_supported = Providers.supports?(provider_id, :vision, model_name: @model)
41
+
42
+ # Optional override for Faraday read_timeout (e.g. benchmark calls).
43
+ # nil means use the default (300s for streaming).
44
+ @read_timeout = read_timeout
41
45
  end
42
46
 
43
47
  # Returns true when the client is using the AWS Bedrock Converse API.
@@ -447,7 +451,7 @@ module Clacky
447
451
  @bedrock_connection ||= Faraday.new(url: @base_url) do |conn|
448
452
  conn.headers["Content-Type"] = "application/json"
449
453
  conn.headers["Authorization"] = "Bearer #{@api_key}"
450
- conn.options.timeout = 300
454
+ conn.options.timeout = @read_timeout || 300
451
455
  conn.options.open_timeout = 10
452
456
  conn.ssl.verify = false
453
457
  conn.adapter Faraday.default_adapter
@@ -458,7 +462,7 @@ module Clacky
458
462
  @openai_connection ||= Faraday.new(url: @base_url) do |conn|
459
463
  conn.headers["Content-Type"] = "application/json"
460
464
  conn.headers["Authorization"] = "Bearer #{@api_key}"
461
- conn.options.timeout = 300
465
+ conn.options.timeout = @read_timeout || 300
462
466
  conn.options.open_timeout = 10
463
467
  conn.ssl.verify = false
464
468
  conn.adapter Faraday.default_adapter
@@ -494,7 +498,7 @@ module Clacky
494
498
  if @provider_id == "kimi-coding"
495
499
  conn.headers["User-Agent"] = "claude-cli/1.0.51 (external, cli)"
496
500
  end
497
- conn.options.timeout = 300
501
+ conn.options.timeout = @read_timeout || 300
498
502
  conn.options.open_timeout = 10
499
503
  conn.ssl.verify = false
500
504
  conn.adapter Faraday.default_adapter
@@ -111,6 +111,96 @@ chrome-devtools-mcp --version 2>/dev/null
111
111
 
112
112
  If still missing after user confirms, stop with error message.
113
113
 
114
+ ### Step 2.5 — WSL networking setup (only when session context shows `OS: WSL/Windows`)
115
+
116
+ **Skip this entire step on macOS / Linux.** Look at the session context line that begins with `[Session context: ...]` — only run this step if it includes `OS: WSL/Windows`.
117
+
118
+ #### Background (read this so you know what to do)
119
+
120
+ The browser tool runs inside WSL but Chrome/Edge runs on Windows. By default WSL2 uses NAT networking, which means `127.0.0.1` inside WSL **cannot** reach Windows' Chrome debug port. The fix is to enable WSL2 **mirrored networking** (`networkingMode=mirrored` in `%USERPROFILE%\.wslconfig`), which makes WSL share Windows' network stack so `127.0.0.1` works directly.
121
+
122
+ We have a helper script that handles all the Windows-side details:
123
+
124
+ ```
125
+ ~/.clacky/scripts/wsl_network_doctor.ps1
126
+ ```
127
+
128
+ It exposes three subcommands:
129
+
130
+ | Subcommand | What it does | Exit code |
131
+ |---|---|---|
132
+ | `status` | Check whether mirrored is configured (auto-passes on WSL1) | `0` OK / `10` NEED_ENABLE |
133
+ | `enable` | Write `networkingMode=mirrored` to `.wslconfig` (does NOT shut down WSL) | `0` success / `1` fail |
134
+ | `repair` | Restart Windows Host Network Service (HNS) via UAC prompt | `0` launched / `1` fail |
135
+
136
+ Invoke it from WSL like this:
137
+
138
+ ```bash
139
+ powershell.exe -NoProfile -ExecutionPolicy Bypass -File "$(wslpath -w ~/.clacky/scripts/wsl_network_doctor.ps1)" <subcommand>
140
+ ```
141
+
142
+ #### Step 2.5.1 — Check status
143
+
144
+ ```bash
145
+ powershell.exe -NoProfile -ExecutionPolicy Bypass -File "$(wslpath -w ~/.clacky/scripts/wsl_network_doctor.ps1)" status
146
+ ```
147
+
148
+ - Exit `0` (output starts with `OK:`) → either mirrored is configured (WSL2) or
149
+ Ubuntu is running on WSL1 (which shares the Windows network stack and needs no
150
+ config). Either way, proceed to Step 3.
151
+ - Exit `10` (output starts with `NEED_ENABLE:`) → continue to Step 2.5.2.
152
+ - Any other failure → show the output to the user and ask them to retry. Stop here.
153
+
154
+ #### Step 2.5.2 — Enable mirrored (only when NEED_ENABLE)
155
+
156
+ Tell the user what's about to happen (in their language):
157
+
158
+ > WSL doesn't have mirrored networking enabled yet — the browser tool needs it to reach Chrome on Windows.
159
+ > I'll add one line to `%USERPROFILE%\.wslconfig`. Your current WSL session will NOT be restarted.
160
+
161
+ Run:
162
+
163
+ ```bash
164
+ powershell.exe -NoProfile -ExecutionPolicy Bypass -File "$(wslpath -w ~/.clacky/scripts/wsl_network_doctor.ps1)" enable
165
+ ```
166
+
167
+ If the script exits `0`:
168
+
169
+ > ✅ `.wslconfig` updated. Tell the user (in their language):
170
+ >
171
+ > The config takes effect only after WSL restarts, but we can't restart WSL from inside WSL.
172
+ > Please:
173
+ >
174
+ > 1. Open **PowerShell** on Windows
175
+ > 2. Run: `wsl --shutdown`
176
+ > 3. Reopen the Clacky terminal
177
+ > 4. Run `/browser-setup` again
178
+ >
179
+ > Stop here. Wait for the user to come back in a new session.
180
+
181
+ If the script exits non-zero, show the output to the user and stop. Do NOT proceed to Step 3 — without mirrored networking the browser tool will not work.
182
+
183
+ #### Step 2.5.3 — When to run repair
184
+
185
+ Do NOT run `repair` proactively. Only run it later if **all** of the following are true:
186
+
187
+ - `status` returned `OK` (mirrored is configured)
188
+ - The user has restarted WSL since the config was written
189
+ - Step 3's `browser(action="status")` still fails with a "Chrome/Edge is not running or remote debugging is not enabled" error
190
+
191
+ In that situation, tell the user (in their language):
192
+
193
+ > The config looks correct but the browser still can't connect. Windows Host Network Service may be stuck — I'll restart it.
194
+ > **A Windows User Account Control (UAC) prompt will appear shortly. Please click "Yes".**
195
+
196
+ Then run:
197
+
198
+ ```bash
199
+ powershell.exe -NoProfile -ExecutionPolicy Bypass -File "$(wslpath -w ~/.clacky/scripts/wsl_network_doctor.ps1)" repair
200
+ ```
201
+
202
+ After it returns, tell the user to run `wsl --shutdown` in PowerShell and reopen Clacky. Stop and wait.
203
+
114
204
  ### Step 3 — Verify Chrome/Edge is running with remote debugging
115
205
 
116
206
  **CRITICAL**: Do NOT attempt `browser()` calls yet. First check if the browser is reachable using the API:
@@ -341,6 +341,7 @@ Where `$QRCODE_ID` is the `qrcode_id` from Step 2's JSON output.
341
341
 
342
342
  Run this command with `timeout: 60`. If it doesn't succeed, **retry up to 3 times with the same `$QRCODE_ID`** — the QR code stays valid for 5 minutes. Only stop retrying if:
343
343
  - Exit code is 0 → success
344
+ - Output contains "stale-session" → the qrcode_id was already consumed by a prior login; **immediately restart from Step 1** (do NOT retry with same id)
344
345
  - Output contains "expired" → QR expired, offer to restart from Step 1
345
346
  - Output contains "timed out" → offer to restart from Step 1
346
347
  - 3 retries exhausted → show error and offer to restart from Step 1
@@ -49,16 +49,20 @@ GIVEN_QRCODE_ID = QRCODE_ID_IDX ? ARGV[QRCODE_ID_IDX + 1] : nil
49
49
  # Logging (suppress in --fetch-qr mode so stdout is clean JSON)
50
50
  # ---------------------------------------------------------------------------
51
51
 
52
- def step(msg); $stderr.puts("[weixin-setup] #{msg}") unless FETCH_QR_MODE; end
53
- def ok(msg); $stderr.puts("[weixin-setup] ✅ #{msg}") unless FETCH_QR_MODE; end
52
+ WEIXIN_LOG_FILE = File.expand_path("~/.clacky/weixin_setup_debug.log")
53
+ def wlog(msg)
54
+ File.open(WEIXIN_LOG_FILE, "a") { |f| f.puts("[#{Time.now.strftime("%H:%M:%S")}] #{msg}") }
55
+ rescue StandardError
56
+ # ignore — debug log is best-effort
57
+ end
58
+
59
+ def step(msg); $stderr.puts("[weixin-setup] #{msg}") unless FETCH_QR_MODE; wlog(msg); end
60
+ def ok(msg); $stderr.puts("[weixin-setup] ✅ #{msg}") unless FETCH_QR_MODE; wlog("✅ #{msg}"); end
54
61
 
55
62
  # In fetch-qr mode, write to stderr so stdout stays clean JSON
56
63
  def log(msg)
57
- if FETCH_QR_MODE
58
- $stderr.puts("[weixin-setup] #{msg}")
59
- else
60
- $stderr.puts("[weixin-setup] #{msg}")
61
- end
64
+ $stderr.puts("[weixin-setup] #{msg}")
65
+ wlog(msg)
62
66
  end
63
67
 
64
68
  def fail!(msg)
@@ -169,6 +173,7 @@ end
169
173
  def poll_until_confirmed(qrcode)
170
174
  deadline = Time.now + LOGIN_DEADLINE_S
171
175
  scanned_once = false
176
+ started_at = Time.now
172
177
 
173
178
  loop do
174
179
  fail!("Login timed out. Please run setup again.") if Time.now > deadline
@@ -179,7 +184,12 @@ def poll_until_confirmed(qrcode)
179
184
  timeout: QR_POLL_TIMEOUT_S
180
185
  )
181
186
 
182
- next if resp.nil? # read timeout = server-side long-poll ended, retry
187
+ if resp.nil?
188
+ wlog("poll: timeout/nil, retrying...")
189
+ next
190
+ end
191
+
192
+ wlog("poll response: #{resp.to_json}")
183
193
 
184
194
  case resp["status"]
185
195
  when "wait"
@@ -190,10 +200,19 @@ def poll_until_confirmed(qrcode)
190
200
  scanned_once = true
191
201
  end
192
202
  when "confirmed"
203
+ elapsed = Time.now - started_at
193
204
  token = resp["bot_token"].to_s.strip
194
205
  base_url = resp["baseurl"].to_s.strip
195
206
  base_url = ILINK_BASE_URL if base_url.empty?
196
207
  fail!("Login confirmed but no token received") if token.empty?
208
+ # If confirmed arrived within 3 seconds of starting, this is almost certainly
209
+ # iLink returning the existing login state (account already logged in),
210
+ # not the result of the user scanning this QR code.
211
+ if elapsed < 3 && !scanned_once
212
+ wlog("confirmed too fast (#{elapsed.round(1)}s), treating as stale session")
213
+ fail!("[stale-session] QR session confirmed immediately — account already logged in. Run --fetch-qr to get a fresh QR code.")
214
+ end
215
+ wlog("confirmed after #{elapsed.round(1)}s")
197
216
  return { token: token, base_url: base_url }
198
217
  when "expired"
199
218
  fail!("QR code expired. Please run setup again.")
@@ -214,6 +233,7 @@ end
214
233
  if FETCH_QR_MODE
215
234
  $stderr.puts("[weixin-setup] Fetching QR code from iLink...")
216
235
  qr_resp = ilink_get("ilink/bot/get_bot_qrcode?bot_type=#{CGI.escape(BOT_TYPE)}")
236
+ wlog("fetch-qr response: #{qr_resp.to_json}")
217
237
  fail!("No qrcode in response: #{qr_resp.inspect}") unless qr_resp&.dig("qrcode")
218
238
 
219
239
  qrcode = qr_resp["qrcode"]
@@ -3,8 +3,10 @@
3
3
  require "json"
4
4
  require "open3"
5
5
  require "monitor"
6
+ require "shellwords"
6
7
 
7
8
  require_relative "transport"
9
+ require_relative "../utils/login_shell"
8
10
 
9
11
  module Clacky
10
12
  module Mcp
@@ -29,7 +31,10 @@ module Clacky
29
31
  opts = { unsetenv_others: false }
30
32
  opts[:chdir] = @cwd if @cwd && File.directory?(@cwd)
31
33
 
32
- @stdin, @stdout, @stderr, @wait_thr = Open3.popen3(full_env, @command, *@args, opts)
34
+ inner = ([@command] + @args).map { |a| Shellwords.shellescape(a) }.join(" ")
35
+ wrapped = Clacky::Utils::LoginShell.login_shell_command(inner)
36
+
37
+ @stdin, @stdout, @stderr, @wait_thr = Open3.popen3(full_env, *wrapped, opts)
33
38
  @stdin.sync = true
34
39
 
35
40
  Thread.new do
@@ -335,28 +335,26 @@ module Clacky
335
335
  "name" => "Qwen (Alibaba)",
336
336
  "base_url" => "https://dashscope.aliyuncs.com/compatible-mode/v1",
337
337
  "api" => "openai-completions",
338
- "default_model" => "qwen3.6-plus",
338
+ "default_model" => "qwen3.7-max",
339
339
  "models" => [
340
+ "qwen3.7-max",
340
341
  "qwen3.6-plus",
341
342
  "qwen3.6-max",
342
343
  "qwen3.6-27b",
343
344
  "qwen3.6-flash",
344
345
  "qwen-plus-latest",
345
- "qwen-vl-plus",
346
- "qwen-vl-max"
347
346
  ],
348
347
  "endpoint_variants" => [
349
348
  { "label" => "Mainland China", "label_key" => "settings.models.baseurl.variant.mainland_cn", "base_url" => "https://dashscope.aliyuncs.com/compatible-mode/v1", "region" => "cn" }.freeze,
350
349
  { "label" => "Singapore", "label_key" => "settings.models.baseurl.variant.international", "base_url" => "https://dashscope-intl.aliyuncs.com/compatible-mode/v1", "region" => "intl" }.freeze,
351
350
  { "label" => "US (Virginia)", "label_key" => "settings.models.baseurl.variant.us", "base_url" => "https://dashscope-us.aliyuncs.com/compatible-mode/v1", "region" => "us" }.freeze
352
351
  ].freeze,
353
- "capabilities" => { "vision" => false }.freeze,
352
+ "capabilities" => { "vision" => true }.freeze,
354
353
  "model_capabilities" => {
355
- "qwen3.6-27b" => { "vision" => true }.freeze,
356
- "qwen-vl-plus" => { "vision" => true }.freeze,
357
- "qwen-vl-max" => { "vision" => true }.freeze
354
+ "qwen3.7-max" => { "vision" => false }.freeze
358
355
  }.freeze,
359
356
  "lite_models" => {
357
+ "qwen3.7-max" => "qwen3.6-flash",
360
358
  "qwen3.6-plus" => "qwen3.6-flash",
361
359
  "qwen3.6-max" => "qwen3.6-flash",
362
360
  "qwen3.6-27b" => "qwen3.6-flash",
@@ -258,7 +258,19 @@ module Clacky
258
258
  # close_others: true prevents inheriting the server's listening socket (port 7070).
259
259
  # The MCP daemon is an independent external process and should not hold server fds.
260
260
  stdin, stdout, stderr_io, wait_thr = Open3.popen3(*wrapped, close_others: true)
261
- Thread.new { stderr_io.read rescue nil }
261
+ stderr_buf = String.new
262
+ stderr_thr = Thread.new do
263
+ stderr_io.each_line { |line| stderr_buf << line }
264
+ rescue IOError
265
+ end
266
+
267
+ # Give the process a moment to fail fast (e.g. command not found)
268
+ sleep 0.1
269
+ unless wait_thr.alive?
270
+ stderr_thr.join(1)
271
+ Clacky::Logger.error("[BrowserManager] MCP daemon exited immediately (exit=#{wait_thr.value.exitstatus}). stderr:\n#{stderr_buf}")
272
+ raise "chrome-devtools-mcp failed to start (exit=#{wait_thr.value.exitstatus}): #{stderr_buf.lines.last(5).join}"
273
+ end
262
274
 
263
275
  # MCP handshake
264
276
  init_msg = json_rpc("initialize", {
@@ -280,9 +292,10 @@ module Clacky
280
292
  init_resp = read_response(stdout, target_id: 1,
281
293
  timeout: Clacky::Tools::Browser::MCP_HANDSHAKE_TIMEOUT)
282
294
  unless init_resp
283
- Clacky::Logger.error("[BrowserManager] MCP initialize handshake timed out after #{Clacky::Tools::Browser::MCP_HANDSHAKE_TIMEOUT}s")
295
+ stderr_thr.join(0.5)
296
+ Clacky::Logger.error("[BrowserManager] MCP initialize handshake timed out after #{Clacky::Tools::Browser::MCP_HANDSHAKE_TIMEOUT}s. stderr:\n#{stderr_buf}")
284
297
  Process.kill("TERM", wait_thr.pid) rescue nil
285
- raise "Chrome MCP initialize handshake timed out"
298
+ raise "Chrome MCP initialize handshake timed out. stderr: #{stderr_buf.lines.last(5).join}"
286
299
  end
287
300
 
288
301
  Clacky::Logger.debug("[BrowserManager] MCP initialize successful, sending initialized notification...")
@@ -55,7 +55,10 @@ module Clacky
55
55
 
56
56
  msg_type = message["message_type"]
57
57
  Clacky::Logger.info("[feishu] msg_type=#{msg_type} content=#{message["content"].to_s[0..300]}")
58
- return nil unless %w[text image file].include?(msg_type)
58
+ unless %w[text image file post].include?(msg_type)
59
+ Clacky::Logger.info("[feishu] dropping unsupported msg_type=#{msg_type}")
60
+ return nil
61
+ end
59
62
 
60
63
  content_raw = message["content"]
61
64
  return nil unless content_raw
@@ -77,6 +80,11 @@ module Clacky
77
80
  file_name = content["file_name"]
78
81
  return nil unless file_key
79
82
  file_attachments = [{ key: file_key, name: file_name.to_s }]
83
+ when "post"
84
+ parsed = parse_post_content(content)
85
+ text = parsed[:text]
86
+ image_keys = parsed[:image_keys]
87
+ return nil if text.empty? && image_keys.empty?
80
88
  end
81
89
 
82
90
  chat_id = message["chat_id"]
@@ -122,6 +130,43 @@ module Clacky
122
130
  # Feishu mentions are formatted as <at user_id="...">Name</at>
123
131
  text.gsub(/<at[^>]*>.*?<\/at>/, "").strip
124
132
  end
133
+
134
+ # Parse a Feishu post content body into text and image_keys.
135
+ # post content structure from event payloads:
136
+ # {"title": "", "content": [[{tag, text, ...}, ...], ...]}
137
+ def parse_post_content(content)
138
+ rows = content["content"]
139
+ return { text: "", image_keys: [] } unless rows.is_a?(Array)
140
+
141
+ text_lines = []
142
+ image_keys = []
143
+
144
+ rows.each do |row|
145
+ next unless row.is_a?(Array)
146
+ line_parts = []
147
+ row.each do |element|
148
+ next unless element.is_a?(Hash)
149
+ case element["tag"]
150
+ when "text", "md", "code_block"
151
+ part = element["text"].to_s
152
+ line_parts << part unless part.empty?
153
+ when "a"
154
+ part = element["text"].to_s
155
+ part = element["href"].to_s if part.empty?
156
+ line_parts << part unless part.empty?
157
+ when "img"
158
+ key = element["image_key"].to_s
159
+ image_keys << key unless key.empty?
160
+ when "at"
161
+ # skipped — mention identity resolved via top-level mentions field
162
+ end
163
+ end
164
+ line = line_parts.join.strip
165
+ text_lines << line unless line.empty?
166
+ end
167
+
168
+ { text: text_lines.join("\n"), image_keys: image_keys }
169
+ end
125
170
  end
126
171
  end
127
172
  end
@@ -77,7 +77,8 @@ module Clacky
77
77
 
78
78
  def handle_raw_message(raw)
79
79
  msgtype = raw["msgtype"]
80
- return unless %w[text image file].include?(msgtype)
80
+ Clacky::Logger.info("[wecom] msgtype=#{msgtype} raw=#{raw.to_s[0..300]}")
81
+ return unless %w[text image file mixed].include?(msgtype)
81
82
 
82
83
  chat_id = raw["chatid"] || raw.dig("from", "userid")
83
84
  return unless chat_id
@@ -92,18 +93,9 @@ module Clacky
92
93
  text = raw.dig("text", "content").to_s.strip
93
94
  return if text.empty?
94
95
  when "image"
95
- url = raw.dig("image", "url")
96
- aeskey = raw.dig("image", "aeskey")
97
- return unless url
98
- result = MediaDownloader.download(url, aeskey)
99
- mime = MediaDownloader.detect_mime(result[:body])
100
- if result[:body].bytesize > MAX_IMAGE_BYTES
101
- @ws_client.send_message(chat_id, "Image too large (#{(result[:body].bytesize / 1024.0).round(0).to_i}KB), max #{MAX_IMAGE_BYTES / 1024}KB")
102
- return
103
- end
104
- require "base64"
105
- data_url = "data:#{mime};base64,#{Base64.strict_encode64(result[:body])}"
106
- files = [{ name: "image.jpg", mime_type: mime, data_url: data_url }]
96
+ file = download_image(raw["image"], chat_id)
97
+ return unless file
98
+ files = [file]
107
99
  when "file"
108
100
  url = raw.dig("file", "url")
109
101
  aeskey = raw.dig("file", "aeskey")
@@ -113,6 +105,9 @@ module Clacky
113
105
  filename = result[:filename] || filename
114
106
  saved = Clacky::Utils::FileProcessor.save(body: result[:body], filename: filename)
115
107
  files = [saved]
108
+ when "mixed"
109
+ text, files = parse_mixed(raw.dig("mixed", "msg_item") || [], chat_id)
110
+ return if text.empty? && files.empty?
116
111
  end
117
112
 
118
113
  event = {
@@ -138,6 +133,36 @@ module Clacky
138
133
  end
139
134
  end
140
135
 
136
+ private def download_image(image_data, chat_id)
137
+ url = image_data&.[]("url")
138
+ aeskey = image_data&.[]("aeskey")
139
+ return nil unless url
140
+ result = MediaDownloader.download(url, aeskey)
141
+ mime = MediaDownloader.detect_mime(result[:body])
142
+ if result[:body].bytesize > MAX_IMAGE_BYTES
143
+ @ws_client.send_message(chat_id, "Image too large (#{(result[:body].bytesize / 1024.0).round(0).to_i}KB), max #{MAX_IMAGE_BYTES / 1024}KB")
144
+ return nil
145
+ end
146
+ require "base64"
147
+ data_url = "data:#{mime};base64,#{Base64.strict_encode64(result[:body])}"
148
+ { name: "image.jpg", mime_type: mime, data_url: data_url }
149
+ end
150
+
151
+ private def parse_mixed(items, chat_id)
152
+ text_parts = []
153
+ files = []
154
+ items.each do |item|
155
+ case item["msgtype"]
156
+ when "text"
157
+ text_parts << item.dig("text", "content").to_s.strip
158
+ when "image"
159
+ file = download_image(item["image"], chat_id)
160
+ files << file if file
161
+ end
162
+ end
163
+ [text_parts.join("\n").strip, files]
164
+ end
165
+
141
166
  MAX_IMAGE_BYTES = Clacky::Utils::FileProcessor::MAX_IMAGE_BYTES
142
167
  end
143
168
 
@@ -81,6 +81,16 @@ module Clacky
81
81
  #
82
82
  # For Weixin (iLink protocol) a context_token is required for every outbound
83
83
  # message. This method looks up the most-recently cached token for user_id.
84
+
85
+ # Return the currently-live adapter for a given platform, or nil if none running.
86
+ # Thread-safe — acquires @mutex to read from @adapters.
87
+ # @param platform [Symbol, String]
88
+ # @return [Object, nil]
89
+ def adapter_for(platform)
90
+ platform = platform.to_sym
91
+ @mutex.synchronize { @adapters.find { |a| a.platform_id == platform } }
92
+ end
93
+
84
94
  # If no token is found the message cannot be delivered and nil is returned.
85
95
  #
86
96
  # For Feishu and WeCom the chat_id / user_id is sufficient — no token needed.
@@ -91,7 +101,7 @@ module Clacky
91
101
  # @return [Hash, nil] adapter result hash, or nil on failure
92
102
  def send_to_user(platform, user_id, message)
93
103
  platform = platform.to_sym
94
- adapter = @mutex.synchronize { @adapters.find { |a| a.platform_id == platform } }
104
+ adapter = adapter_for(platform)
95
105
 
96
106
  unless adapter
97
107
  Clacky::Logger.warn("[ChannelManager] send_to_user: no running adapter for :#{platform}")
@@ -114,7 +124,7 @@ module Clacky
114
124
  # @return [Array<String>]
115
125
  def known_users(platform)
116
126
  platform = platform.to_sym
117
- adapter = @mutex.synchronize { @adapters.find { |a| a.platform_id == platform } }
127
+ adapter = adapter_for(platform)
118
128
  return [] unless adapter
119
129
 
120
130
  # Weixin adapter exposes @context_tokens whose keys are user_ids
@@ -307,7 +317,7 @@ module Clacky
307
317
  if channel_ui
308
318
  @registry.with_session(old_session_id) { |s| s[:ui]&.unsubscribe_channel(channel_ui); s.delete(:channel_ui) }
309
319
  else
310
- channel_ui = ChannelUIController.new(event, adapter)
320
+ channel_ui = ChannelUIController.new(event, -> { adapter_for(event[:platform]) })
311
321
  end
312
322
 
313
323
  bind_key_to_session(key, session_id)
@@ -384,7 +394,7 @@ module Clacky
384
394
  # Create a long-lived ChannelUIController for this session and subscribe it
385
395
  # to the session's WebUIController. It stays for the session's full lifetime
386
396
  # so all events (agent output, errors, status) flow through web_ui → channel_ui.
387
- channel_ui = ChannelUIController.new(event, adapter)
397
+ channel_ui = ChannelUIController.new(event, -> { adapter_for(event[:platform]) })
388
398
  @registry.with_session(session_id) do |s|
389
399
  s[:ui]&.subscribe_channel(channel_ui)
390
400
  s[:channel_ui] = channel_ui
@@ -22,13 +22,18 @@ module Clacky
22
22
 
23
23
  attr_reader :platform, :chat_id
24
24
 
25
- def initialize(event, adapter)
26
- @platform = event[:platform]
27
- @chat_id = event[:chat_id]
28
- @message_id = event[:message_id] # original message to reply under
29
- @adapter = adapter
30
- @buffer = []
31
- @mutex = Mutex.new
25
+ # @param event [Hash] inbound IM event
26
+ # @param adapter_resolver [Proc] callable returning the current live adapter for this platform.
27
+ # Using a resolver (instead of caching the adapter instance) ensures that after
28
+ # reload_platform replaces an adapter, in-flight sessions automatically pick up the
29
+ # new one — no swap/patch needed.
30
+ def initialize(event, adapter_resolver)
31
+ @platform = event[:platform]
32
+ @chat_id = event[:chat_id]
33
+ @message_id = event[:message_id] # original message to reply under
34
+ @adapter_resolver = adapter_resolver
35
+ @buffer = []
36
+ @mutex = Mutex.new
32
37
  end
33
38
 
34
39
  # Update the reply context for the current inbound message.
@@ -185,15 +190,25 @@ module Clacky
185
190
  text = text.to_s.gsub(/<think>[\s\S]*?<\/think>\n*/i, "").strip
186
191
  return if text.empty?
187
192
 
188
- @adapter.send_text(@chat_id, text, reply_to: @message_id)
193
+ adapter = @adapter_resolver.call
194
+ unless adapter
195
+ Clacky::Logger.warn("[ChannelUI] send_text: no live adapter for :#{@platform}")
196
+ return nil
197
+ end
198
+ adapter.send_text(@chat_id, text, reply_to: @message_id)
189
199
  rescue StandardError => e
190
200
  Clacky::Logger.warn("[ChannelUI] send_text failed", platform: @platform, chat_id: @chat_id, error: e)
191
201
  nil
192
202
  end
193
203
 
194
204
  def send_file(path, name = nil)
195
- if @adapter.respond_to?(:send_file)
196
- @adapter.send_file(@chat_id, path, name: name)
205
+ adapter = @adapter_resolver.call
206
+ unless adapter
207
+ Clacky::Logger.warn("[ChannelUI] send_file: no live adapter for :#{@platform}")
208
+ return nil
209
+ end
210
+ if adapter.respond_to?(:send_file)
211
+ adapter.send_file(@chat_id, path, name: name)
197
212
  else
198
213
  # Fallback for adapters that don't support file sending
199
214
  send_text("File: #{name || File.basename(path)}\n#{path}")
@@ -222,7 +237,8 @@ module Clacky
222
237
  end
223
238
 
224
239
  def flush_adapter_pending
225
- @adapter.flush_pending(@chat_id) if @adapter.respond_to?(:flush_pending)
240
+ adapter = @adapter_resolver.call
241
+ adapter.flush_pending(@chat_id) if adapter&.respond_to?(:flush_pending)
226
242
  end
227
243
  end
228
244
  end