openclacky 1.2.3 → 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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 39490977253b82c6bf0e0e7c89e3763b86dbe56a46e8c6e716774c06387bc7a7
4
- data.tar.gz: 25224b037f948e7252467337a4e04051340d4bd7ff75456d7a8653acfc43e94c
3
+ metadata.gz: 80e1b5549b0e55760ec4e0e905eea3ab50bc9a66bd8c256e628a1afdb8531ba0
4
+ data.tar.gz: 54142ef66c3b74d3998214202fa73618dd9b22ebdb06586c8d6896aafcb56d9d
5
5
  SHA512:
6
- metadata.gz: 9ba3260b11c8b7f37c075ebc392567f3b049246932e65e1fc12df99363f86921af28c05e5fd4d46ddb1cd796092fc11315b80ef67937c13b5e6cbb9a276cdc47
7
- data.tar.gz: 8de209627490544c48f3d07f8b010cc40fe6ef51e597734b61adb7c6285d65d3a309aebebe9f9f563a681db60596b797e5a79d9eaa46c85805b37b75cca9369c
6
+ metadata.gz: d69d02391cc1929d26f2cfc2a260d27cb6b1f6840c254ae67f7ad358f621ba16eb2476fd5cee28308f7eaf2e0eb21d5983b49de4f84c6905863292625644641a
7
+ data.tar.gz: 54dbeb6d31f187edc30ea4e6030776e988cf50ee7ed44a2177a77473d00c47ae358df25dfd9bd526ea93126b94733feaca5a63048cde8657201c4c1e7cd05edd
data/CHANGELOG.md CHANGED
@@ -5,6 +5,30 @@ 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
+
8
32
  ## [1.2.3] - 2026-05-27
9
33
 
10
34
  ### Added
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
@@ -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
@@ -350,6 +350,9 @@ module Clacky
350
350
  { "label" => "US (Virginia)", "label_key" => "settings.models.baseurl.variant.us", "base_url" => "https://dashscope-us.aliyuncs.com/compatible-mode/v1", "region" => "us" }.freeze
351
351
  ].freeze,
352
352
  "capabilities" => { "vision" => true }.freeze,
353
+ "model_capabilities" => {
354
+ "qwen3.7-max" => { "vision" => false }.freeze
355
+ }.freeze,
353
356
  "lite_models" => {
354
357
  "qwen3.7-max" => "qwen3.6-flash",
355
358
  "qwen3.6-plus" => "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
@@ -368,6 +368,8 @@ module Clacky
368
368
  90
369
369
  elsif path == "/api/tool/browser"
370
370
  30
371
+ elsif path.end_with?("/benchmark")
372
+ 20
371
373
  else
372
374
  10
373
375
  end
@@ -1798,6 +1800,7 @@ module Clacky
1798
1800
 
1799
1801
  data["mcpServers"][name] = spec
1800
1802
  mcp_write_raw_config(data)
1803
+ @mcp_registry_mutex.synchronize { @mcp_registry&.reload }
1801
1804
  json_response(res, 200, { ok: true, name: name, config_path: mcp_config_path })
1802
1805
  end
1803
1806
 
@@ -1821,6 +1824,7 @@ module Clacky
1821
1824
 
1822
1825
  data["mcpServers"][name] = spec
1823
1826
  mcp_write_raw_config(data)
1827
+ @mcp_registry_mutex.synchronize { @mcp_registry&.reload }
1824
1828
  json_response(res, 200, { ok: true, name: name })
1825
1829
  end
1826
1830
 
@@ -1836,6 +1840,7 @@ module Clacky
1836
1840
 
1837
1841
  data["mcpServers"].delete(name)
1838
1842
  mcp_write_raw_config(data)
1843
+ @mcp_registry_mutex.synchronize { @mcp_registry&.reload }
1839
1844
  json_response(res, 200, { ok: true, name: name })
1840
1845
  end
1841
1846
 
@@ -3619,8 +3624,12 @@ module Clacky
3619
3624
  end
3620
3625
 
3621
3626
  results = threads.map do |t|
3622
- t.join(per_model_timeout + 3)
3623
- t.value rescue { ok: false, error: "thread failed" }
3627
+ if t.join(per_model_timeout + 3)
3628
+ t.value rescue { ok: false, error: "thread failed" }
3629
+ else
3630
+ t.kill
3631
+ { ok: false, error: "Request timed out" }
3632
+ end
3624
3633
  end
3625
3634
 
3626
3635
  json_response(res, 200, { ok: true, results: results })
@@ -3641,7 +3650,8 @@ module Clacky
3641
3650
  model_cfg["api_key"].to_s,
3642
3651
  base_url: model_cfg["base_url"].to_s,
3643
3652
  model: model_name,
3644
- anthropic_format: model_cfg["anthropic_format"] || false
3653
+ anthropic_format: model_cfg["anthropic_format"] || false,
3654
+ read_timeout: timeout_sec
3645
3655
  )
3646
3656
 
3647
3657
  # Override Faraday timeouts via a short-lived env var isn't ideal;
@@ -242,7 +242,7 @@ module Clacky
242
242
  { action: "tabs", success: true, profile: "user", output: format_tabs(pages), tabs: pages }
243
243
 
244
244
  when "snapshot"
245
- raw = mcp_call("take_snapshot")
245
+ raw = mcp_call("take_snapshot", with_page({}))
246
246
  text = build_ai_snapshot(extract_snapshot(raw),
247
247
  interactive: opts[:interactive] || opts["interactive"],
248
248
  compact: opts[:compact] || opts["compact"],
@@ -352,7 +352,7 @@ module Clacky
352
352
  sleep(ms.to_i / 1000.0)
353
353
  return { action: "act", success: true, profile: "user", output: "Waited #{ms}ms" }
354
354
  elsif sel
355
- mcp_call("wait_for", { text: [sel] })
355
+ mcp_call("wait_for", with_page({ text: [sel] }))
356
356
  else
357
357
  sleep(1)
358
358
  end
@@ -409,7 +409,7 @@ module Clacky
409
409
 
410
410
  call_args = { format: "png", fullPage: full_page }
411
411
  call_args[:uid] = uid if uid
412
- result = mcp_call("take_screenshot", call_args)
412
+ result = mcp_call("take_screenshot", with_page(call_args))
413
413
 
414
414
  image_block = Array(result["content"]).find { |b| b.is_a?(Hash) && b["type"] == "image" }
415
415
 
@@ -199,7 +199,7 @@ module Clacky
199
199
  # ---------------------------------------------------------------------
200
200
  def execute(command: nil, session_id: nil, input: nil, background: false,
201
201
  cwd: nil, env: nil, timeout: nil, kill: nil, idle_ms: nil,
202
- working_dir: nil, **_ignored)
202
+ working_dir: nil, on_output: nil, **_ignored)
203
203
  # Auto-tune: if the caller didn't explicitly set a timeout/idle_ms
204
204
  # AND the command is a well-known long-runner (rspec, bundle install,
205
205
  # cargo build, etc.), we stretch the budget AND disable idle-return.
@@ -226,7 +226,7 @@ module Clacky
226
226
  # Continue / poll a running session
227
227
  if session_id
228
228
  return { error: "input is required when session_id is given" } if input.nil?
229
- return do_continue(session_id.to_i, input.to_s, timeout: timeout, idle_ms: idle_ms)
229
+ return do_continue(session_id.to_i, input.to_s, timeout: timeout, idle_ms: idle_ms, on_output: on_output)
230
230
  end
231
231
 
232
232
  # Start a new command
@@ -243,7 +243,8 @@ module Clacky
243
243
  end
244
244
 
245
245
  return do_start(command.to_s, cwd: cwd, env: env, timeout: timeout,
246
- idle_ms: idle_ms, background: background ? true : false)
246
+ idle_ms: idle_ms, background: background ? true : false,
247
+ on_output: on_output)
247
248
  end
248
249
 
249
250
  { error: "terminal: must provide either `command`, or `session_id`+`input`, or `session_id`+`kill: true`." }
@@ -327,7 +328,7 @@ module Clacky
327
328
  # ---------------------------------------------------------------------
328
329
  # 1) Start a new command
329
330
  # ---------------------------------------------------------------------
330
- private def do_start(command, cwd:, env:, timeout:, background:, idle_ms: DEFAULT_IDLE_MS)
331
+ private def do_start(command, cwd:, env:, timeout:, background:, idle_ms: DEFAULT_IDLE_MS, on_output: nil)
331
332
  if cwd && !Dir.exist?(cwd.to_s)
332
333
  return { error: "cwd does not exist: #{cwd}" }
333
334
  end
@@ -345,6 +346,10 @@ module Clacky
345
346
  # that doesn't already have an explicit stdin redirect.
346
347
  safe_command = redirect_exe_stdin(safe_command)
347
348
 
349
+ # PowerShell 5 on Chinese Windows emits CP936/GBK by default; force
350
+ # UTF-8 so our PTY (which decodes as UTF-8) doesn't see ??? bytes.
351
+ safe_command = force_powershell_utf8(safe_command)
352
+
348
353
  # Background / dedicated path — never reuse the persistent shell,
349
354
  # because these commands stay running and would occupy the slot.
350
355
  if background
@@ -363,7 +368,8 @@ module Clacky
363
368
  background: true,
364
369
  persistent: false,
365
370
  original_command: command,
366
- rewritten_command: safe_command
371
+ rewritten_command: safe_command,
372
+ on_output: on_output
367
373
  )
368
374
  end
369
375
 
@@ -388,14 +394,15 @@ module Clacky
388
394
  idle_ms: idle_ms,
389
395
  persistent: persistent,
390
396
  original_command: command,
391
- rewritten_command: safe_command
397
+ rewritten_command: safe_command,
398
+ on_output: on_output
392
399
  )
393
400
  end
394
401
 
395
402
  # ---------------------------------------------------------------------
396
403
  # 2) Continue / poll an existing session
397
404
  # ---------------------------------------------------------------------
398
- private def do_continue(session_id, input, timeout:, idle_ms: DEFAULT_IDLE_MS)
405
+ private def do_continue(session_id, input, timeout:, idle_ms: DEFAULT_IDLE_MS, on_output: nil)
399
406
  session = SessionManager.refresh(session_id)
400
407
  return { error: "Session ##{session_id} not found (already finished or killed)." } unless session
401
408
 
@@ -406,7 +413,7 @@ module Clacky
406
413
 
407
414
  session.mutex.synchronize { session.writer.write(normalize_input_for_pty(input.to_s)) } unless input.to_s.empty?
408
415
 
409
- wait_and_package(session, timeout: timeout, idle_ms: idle_ms)
416
+ wait_and_package(session, timeout: timeout, idle_ms: idle_ms, on_output: on_output)
410
417
  end
411
418
 
412
419
  # `\n` is a Unix newline, not the "Enter key". Inside cooked-mode PTYs
@@ -455,10 +462,11 @@ module Clacky
455
462
  # :timeout | session_id, state=timeout | session_id, state=background
456
463
  private def wait_and_package(session, timeout:, idle_ms: DEFAULT_IDLE_MS,
457
464
  background: false, persistent: false,
458
- original_command: nil, rewritten_command: nil)
465
+ original_command: nil, rewritten_command: nil,
466
+ on_output: nil)
459
467
  start_offset = session.read_offset
460
468
 
461
- _before, code, state = read_until_marker(session, timeout: timeout, idle_ms: idle_ms)
469
+ _before, code, state = read_until_marker(session, timeout: timeout, idle_ms: idle_ms, on_output: on_output)
462
470
 
463
471
  new_offset = log_size(session)
464
472
  raw = read_log_slice(session.log_file, start_offset, new_offset)
@@ -1066,10 +1074,16 @@ module Clacky
1066
1074
  # whole persistent shell.
1067
1075
  def source_rc_in_session(session, rc_files)
1068
1076
  return if rc_files.empty?
1069
- cmd = rc_files.map { |f|
1077
+ sources = rc_files.map { |f|
1070
1078
  escaped = f.gsub('"', '\"')
1071
1079
  "source \"#{escaped}\" || true"
1072
1080
  }.join("; ")
1081
+ # rc files often gate interactive-only setup (mise activate, direnv
1082
+ # hook, nvm, pyenv, oh-my-zsh) on `[ -z "$PS1" ]` / `[[ -o interactive ]]`.
1083
+ # We normally keep PS1="" to suppress prompt noise in captured output,
1084
+ # but that makes those gates fail when we re-source rc here. Set a
1085
+ # placeholder PS1 just for the duration of the source, then restore "".
1086
+ cmd = %Q{__clacky_old_ps1="$PS1"; PS1="__CLACKY_PS1__"; #{sources}; PS1="$__clacky_old_ps1"; unset __clacky_old_ps1}
1073
1087
  run_inline(session, cmd, timeout: 15)
1074
1088
  end
1075
1089
 
@@ -1122,7 +1136,12 @@ module Clacky
1122
1136
  # Poll the log file until a marker matches, idle-return fires, or timeout.
1123
1137
  # Returns [raw_before_marker, exit_code_or_nil, state].
1124
1138
  # state ∈ :matched, :idle, :timeout, :eof
1125
- private def read_until_marker(session, timeout:, idle_ms: DEFAULT_IDLE_MS)
1139
+ #
1140
+ # `on_output` (optional Proc): called as on_output.call(chunk_string) for
1141
+ # each new piece of output as it arrives, BEFORE the marker is detected.
1142
+ # The chunk has ANSI codes / wrapper echoes stripped so it's safe to
1143
+ # render in a UI. The completion marker itself is never passed through.
1144
+ private def read_until_marker(session, timeout:, idle_ms: DEFAULT_IDLE_MS, on_output: nil)
1126
1145
  return ["", nil, :eof] unless session.marker_regex
1127
1146
 
1128
1147
  deadline = Time.now + timeout
@@ -1130,14 +1149,87 @@ module Clacky
1130
1149
  start_size = session.read_offset
1131
1150
  last_size = start_size
1132
1151
  last_change = Time.now
1152
+ streamed_to = start_size # bytes already pushed to on_output
1153
+ # Per-call streaming state: we hold back bytes until we see a \n so
1154
+ # we can run cleaning on whole lines, then drop wrapper-echo lines.
1155
+ stream_pending = +""
1156
+ # Phase: until we observe the full `{ user_cmd; }; __clacky_ec=$?; printf "..." "$__clacky_ec"`
1157
+ # wrapper echo (or decide it never came), buffer everything so the
1158
+ # wrapper opener `{ ...` doesn't leak to the UI.
1159
+ wrapper_swallowed = false
1160
+
1161
+ flush_stream = lambda do |raw, force_partial: false|
1162
+ return unless on_output && raw && !raw.empty?
1163
+ stream_pending << raw
1164
+
1165
+ # Phase 1: swallow the wrapper echo. The wrapper always ends with
1166
+ # the literal printf tail `"$__clacky_ec"`. Until we see that, we
1167
+ # accumulate; once we do, we strip the whole wrapper out and only
1168
+ # emit whatever real output came after it.
1169
+ unless wrapper_swallowed
1170
+ tail_marker = '"$__clacky_ec"'
1171
+ tail_idx = stream_pending.index(tail_marker)
1172
+ if tail_idx
1173
+ # Strip from start through end-of-line of the printf tail.
1174
+ eol_after = stream_pending.index("\n", tail_idx) || (stream_pending.bytesize - 1)
1175
+ stream_pending.replace(stream_pending.byteslice(eol_after + 1, stream_pending.bytesize - eol_after - 1).to_s)
1176
+ wrapper_swallowed = true
1177
+ elsif force_partial
1178
+ # End of stream and we never saw the wrapper tail — give up
1179
+ # on swallowing and emit what we have, run normal stripping.
1180
+ wrapper_swallowed = true
1181
+ else
1182
+ # Still hunting; keep buffering. Emit nothing yet.
1183
+ return
1184
+ end
1185
+ end
1186
+
1187
+ if force_partial
1188
+ buffered = stream_pending.dup
1189
+ stream_pending.clear
1190
+ else
1191
+ nl = stream_pending.rindex("\n")
1192
+ return if nl.nil?
1193
+ buffered = stream_pending.byteslice(0, nl + 1)
1194
+ stream_pending.replace(stream_pending.byteslice(nl + 1, stream_pending.bytesize - nl - 1).to_s)
1195
+ end
1196
+ cleaned = OutputCleaner.clean(buffered)
1197
+ # Strip any wrapper echo that still slipped through (e.g. when a
1198
+ # session is reused and ZLE re-echoes our wrapper mid-stream).
1199
+ cleaned = strip_command_echo(cleaned, marker_token: session.marker_token)
1200
+ # Belt-and-braces: drop any line that still carries our internal
1201
+ # tokens.
1202
+ cleaned = cleaned.lines.reject do |ln|
1203
+ ln.include?("__clacky_ec") ||
1204
+ ln.include?("__CLACKY_DONE_") ||
1205
+ ln.include?("__clacky_f") ||
1206
+ ln.include?("__clacky_pc") ||
1207
+ ln.match?(/\A\s*\}\s*>\s*\/dev\/null\s+2>&1;?\s*\z/)
1208
+ end.join
1209
+ on_output.call(cleaned) unless cleaned.empty?
1210
+ rescue StandardError
1211
+ # Streaming is best-effort — never let a UI bug abort the command.
1212
+ end
1133
1213
 
1134
1214
  loop do
1135
1215
  current_size = log_size(session)
1136
1216
  if current_size > last_size
1137
1217
  slice = read_log_slice(session.log_file, session.read_offset, current_size)
1138
1218
  if (m = slice.match(session.marker_regex))
1219
+ marker_abs = session.read_offset + m.begin(0)
1220
+ if marker_abs > streamed_to
1221
+ tail = read_log_slice(session.log_file, streamed_to, marker_abs)
1222
+ flush_stream.call(tail, force_partial: true)
1223
+ end
1139
1224
  return [slice[0...m.begin(0)], m[1].to_i, :matched]
1140
1225
  end
1226
+
1227
+ if current_size > streamed_to
1228
+ new_chunk = read_log_slice(session.log_file, streamed_to, current_size)
1229
+ flush_stream.call(new_chunk)
1230
+ streamed_to = current_size
1231
+ end
1232
+
1141
1233
  last_size = current_size
1142
1234
  last_change = Time.now
1143
1235
  end
@@ -1146,16 +1238,31 @@ module Clacky
1146
1238
  if session.status == "exited" || session.status == "killed"
1147
1239
  slice = read_log_slice(session.log_file, session.read_offset, log_size(session))
1148
1240
  if (m = slice.match(session.marker_regex))
1241
+ marker_abs = session.read_offset + m.begin(0)
1242
+ if marker_abs > streamed_to
1243
+ tail = read_log_slice(session.log_file, streamed_to, marker_abs)
1244
+ flush_stream.call(tail, force_partial: true)
1245
+ end
1149
1246
  return [slice[0...m.begin(0)], m[1].to_i, :matched]
1150
1247
  end
1248
+ final_size = log_size(session)
1249
+ if final_size > streamed_to
1250
+ flush_stream.call(read_log_slice(session.log_file, streamed_to, final_size), force_partial: true)
1251
+ end
1151
1252
  return [slice, nil, :eof]
1152
1253
  end
1153
1254
 
1154
1255
  if last_size > start_size && (Time.now - last_change) >= idle_sec
1256
+ # Going idle: flush any partial buffered line (e.g. an in-progress
1257
+ # progress bar without trailing \n) so the UI sees current state.
1258
+ flush_stream.call("", force_partial: true) unless stream_pending.empty?
1155
1259
  return ["", nil, :idle]
1156
1260
  end
1157
1261
 
1158
- return ["", nil, :timeout] if Time.now >= deadline
1262
+ if Time.now >= deadline
1263
+ flush_stream.call("", force_partial: true) unless stream_pending.empty?
1264
+ return ["", nil, :timeout]
1265
+ end
1159
1266
  sleep 0.05
1160
1267
  end
1161
1268
  end
@@ -1397,7 +1504,55 @@ module Clacky
1397
1504
  private def redirect_exe_stdin(command)
1398
1505
  return command unless command =~ /\.exe\b/i
1399
1506
  return command if command =~ /<\s*[^\s|&;]/
1400
- "#{command} </dev/null"
1507
+
1508
+ # If the command has a shell-level pipe, insert </dev/null before
1509
+ # the first pipe so only the .exe segment gets its stdin redirected,
1510
+ # rather than starving a downstream pipe reader (e.g. `tr`, `grep`).
1511
+ if command =~ /\|/
1512
+ command.sub(/\s*\|/, ' </dev/null |')
1513
+ else
1514
+ "#{command} </dev/null"
1515
+ end
1516
+ end
1517
+
1518
+ # PowerShell 5 on Chinese Windows defaults [Console]::OutputEncoding
1519
+ # to CP936/GBK; our PTY decodes as UTF-8 so non-ASCII output becomes
1520
+ # `???`. Inject UTF-8 setup into the user's PowerShell command so the
1521
+ # shell emits UTF-8 bytes regardless of host locale.
1522
+ POWERSHELL_PREAMBLE =
1523
+ "[Console]::OutputEncoding=[Text.Encoding]::UTF8;" \
1524
+ "$OutputEncoding=[Text.Encoding]::UTF8;"
1525
+
1526
+ # Only rewrites simple `powershell[.exe]` / `pwsh[.exe]` invocations.
1527
+ # Skips -File / -EncodedCommand / commands already handling encoding /
1528
+ # pipelines (anything risky to splice).
1529
+ private def force_powershell_utf8(command)
1530
+ cmd = command.to_s
1531
+ return command unless cmd =~ /\A\s*(?:powershell(?:\.exe)?|pwsh(?:\.exe)?)\b/i
1532
+ return command if cmd =~ /OutputEncoding/i
1533
+ return command if cmd =~ /\s-(?:File|EncodedCommand|enc|f)\b/i
1534
+
1535
+ # `-Command "..."` form: pipeline / chain characters inside the
1536
+ # quoted body are PowerShell-internal, not shell-level, so we splice
1537
+ # safely into the quoted string.
1538
+ if (m = cmd.match(/\A(\s*(?:powershell(?:\.exe)?|pwsh(?:\.exe)?)\s+(?:[^"'\s]+\s+)*?-(?:Command|c)\s+)(["'])(.*)\2(\s*(?:<\s*\S+\s*)?)\z/i))
1539
+ head, quote, body, tail = m[1], m[2], m[3], m[4].to_s
1540
+ return "#{head}#{quote}#{POWERSHELL_PREAMBLE}#{body}#{quote}#{tail}"
1541
+ end
1542
+
1543
+ # Outside the quoted-Command form, refuse to splice if there's any
1544
+ # shell-level pipe / chain — too risky to get the boundaries right.
1545
+ return command if cmd =~ /[|&;]/
1546
+
1547
+ if (m = cmd.match(/\A(\s*(?:powershell(?:\.exe)?|pwsh(?:\.exe)?))(.*)\z/i))
1548
+ exe, rest = m[1], m[2].to_s.strip
1549
+ return command if rest.start_with?("-") && rest !~ /\A-(?:Command|c)\b/i
1550
+ rest = rest.sub(/\A-(?:Command|c)\b\s*/i, "")
1551
+ inner = rest.empty? ? POWERSHELL_PREAMBLE.chomp(";") : "#{POWERSHELL_PREAMBLE}#{rest}"
1552
+ return %Q{#{exe} -Command "#{inner}"}
1553
+ end
1554
+
1555
+ command
1401
1556
  end
1402
1557
  end
1403
1558
  end
@@ -442,6 +442,7 @@ module Clacky
442
442
  # doesn't bleed into the next one, and so the buffer is ready before
443
443
  # on_output starts firing (which can happen before show_progress is called).
444
444
  @stdout_lines = nil
445
+ @stdout_partial_tail = false
445
446
 
446
447
  # Special handling for request_user_feedback: render as a readable interactive card
447
448
  # with the full question and options, rather than the truncated format_call summary.
@@ -493,11 +494,27 @@ module Clacky
493
494
  # Receive a chunk of shell stdout from the on_output callback.
494
495
  # Lines are buffered into @stdout_lines so that Ctrl+O can open a
495
496
  # fullscreen live view, matching the original output_buffer interaction.
496
- # @param lines [Array<String>] One or more stdout chunks
497
+ # @param lines [Array<String>] One or more stdout chunks (may contain
498
+ # embedded newlines or be partial lines)
497
499
  def show_tool_stdout(lines)
498
500
  return if lines.nil? || lines.empty?
499
501
  @stdout_lines ||= []
500
- @stdout_lines.concat(lines.map(&:chomp))
502
+ # Chunks may carry multiple newlines or trailing partial lines.
503
+ # Re-split on \n so the fullscreen view renders one logical line per row.
504
+ lines.each do |chunk|
505
+ next if chunk.nil? || chunk.empty?
506
+ chunk.to_s.split("\n", -1).each_with_index do |part, idx|
507
+ if idx == 0 && !@stdout_lines.empty? && @stdout_partial_tail
508
+ @stdout_lines[-1] = @stdout_lines[-1] + part
509
+ else
510
+ @stdout_lines << part
511
+ end
512
+ end
513
+ # Track whether the chunk ended on a partial line (no trailing \n)
514
+ # so the next chunk's first segment appends to it instead of
515
+ # starting a new row.
516
+ @stdout_partial_tail = !chunk.to_s.end_with?("\n")
517
+ end
501
518
  end
502
519
 
503
520
  # Show completion status (only for tasks with more than 5 iterations)
@@ -1354,6 +1371,7 @@ module Clacky
1354
1371
  # Also clear stdout buffer used by Ctrl+O (unrelated to progress, but
1355
1372
  # we don't want stale command output carried across user turns).
1356
1373
  @stdout_lines = nil
1374
+ @stdout_partial_tail = false
1357
1375
 
1358
1376
  # Render user message immediately before running agent
1359
1377
  unless data[:text].empty? && data[:files].empty?
@@ -51,7 +51,9 @@ module Clacky
51
51
 
52
52
  # { rc_sources; } 1>&2 — send rc-time stdout to stderr so target's
53
53
  # stdout is pristine. `exec` replaces the shell with target.
54
- script = "{ #{rc_sources}; } 1>&2; exec #{command}"
54
+ # PS1 trick: Ubuntu .bashrc has `[ -z "$PS1" ] && return` guard that
55
+ # skips the entire file in non-interactive shells. Setting PS1 defeats it.
56
+ script = "PS1='$ '; { #{rc_sources}; } 1>&2; exec #{command}"
55
57
  [shell, "-c", script]
56
58
  end
57
59
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Clacky
4
- VERSION = "1.2.3"
4
+ VERSION = "1.2.4"
5
5
  end
@@ -3649,6 +3649,7 @@ body {
3649
3649
  .model-card-grid-actions {
3650
3650
  display: flex;
3651
3651
  flex-direction: row;
3652
+ flex-wrap: wrap;
3652
3653
  gap: 0.5rem;
3653
3654
  padding-top: 0.625rem;
3654
3655
  border-top: 1px solid var(--color-border-primary);
@@ -169,6 +169,11 @@ const Settings = (() => {
169
169
  providerValue.classList.add("placeholder");
170
170
  }
171
171
 
172
+ // Reset save button
173
+ const saveBtn = document.getElementById("model-modal-save");
174
+ saveBtn.textContent = I18n.t("settings.models.btn.save");
175
+ saveBtn.disabled = false;
176
+
172
177
  // Clear test result
173
178
  document.getElementById("model-modal-test-result").textContent = "";
174
179
  document.getElementById("model-modal-test-result").className = "model-test-result";
@@ -140,10 +140,11 @@
140
140
  <script>
141
141
  const params = new URLSearchParams(location.search);
142
142
  const url = params.get("url");
143
- // since: Unix timestamp (seconds) passed by the setup skill when opening this page.
144
- // We only show success if token_updated_at > since, preventing false positives
145
- // when the user already had a token from a previous login.
146
- const since = parseInt(params.get("since") || "0", 10);
143
+ // since: the moment this page loaded, in Unix seconds.
144
+ // We only show success if token_updated_at >= since, so a pre-existing token
145
+ // never triggers the overlay.
146
+ // Intentionally NOT taken from the URL param — the page is the source of truth.
147
+ const since = Math.floor(Date.now() / 1000);
147
148
  const el = document.getElementById("qrcode");
148
149
 
149
150
  if (!url) {
data/scripts/install.ps1 CHANGED
@@ -11,8 +11,8 @@
11
11
  # -BrandName Display name shown in prompts (default: OpenClacky)
12
12
  # -CommandName CLI command name after install (default: openclacky)
13
13
  #
14
- # WSL2 is preferred. If virtualisation is unavailable (e.g. running inside a VM),
15
- # the script automatically falls back to WSL1.
14
+ # WSL1 is preferred (shares Windows network stack no mirrored networking needed).
15
+ # If WSL1 import fails, the script falls back to WSL2 with mirrored networking.
16
16
  # If WSL is not installed at all, the script enables it and asks you to reboot.
17
17
  # After rebooting, run the same command again to complete installation.
18
18
  #
@@ -501,32 +501,59 @@ if ($installPhase -eq "wsl-pending" -and $wslCode -eq 1) {
501
501
  # wslCode != 1 (0, -1, -444, 50, etc.): WSL is functional, continue.
502
502
  Remove-InstallReg -Name "InstallPhase"
503
503
 
504
- # Step 2: Install Ubuntu, preferring WSL2 when the real rootfs imports cleanly.
504
+ # Step 2: Install Ubuntu, preferring WSL1 (shares Windows network no mirrored needed).
505
+ # If WSL1 import fails, fall back to WSL2.
506
+ # If the distro already exists, keep whatever version was previously installed.
505
507
  if (Test-UbuntuInstalled) {
506
508
  Write-Info "Ubuntu (WSL) already installed — skipping import."
507
509
  $wslVersion = Get-InstallReg -Name "WslVersion" -Default 2
508
510
  } else {
509
511
  $tarPath = Get-UbuntuRootfs
510
- if (Test-VirtualisationSupported -TarPath $tarPath) {
511
- wsl.exe --set-default-version 2 >$null 2>$null
512
- Install-UbuntuRootfs -WslVersion 2 -TarPath $tarPath
513
- $wslVersion = 2
512
+
513
+ # Try WSL1 first
514
+ Write-Info "Attempting WSL1 import..."
515
+ $wsl1Ok = $false
516
+ try {
517
+ New-Item -ItemType Directory -Force -Path $UBUNTU_WSL_DIR | Out-Null
518
+ wsl.exe --import Ubuntu $UBUNTU_WSL_DIR $tarPath --version 1 >$null 2>$null
519
+ $wsl1Ok = ($LASTEXITCODE -eq 0)
520
+ } catch {
521
+ $wsl1Ok = $false
522
+ }
523
+
524
+ if ($wsl1Ok) {
525
+ Write-Success "Ubuntu (WSL1) imported successfully."
526
+ $wslVersion = 1
514
527
  } else {
515
- if ($wslFeaturesEnabled -ne "1") {
516
- # WSL components were never fully prepared — run Enable-WslFeatures and reboot.
517
- Write-Warn "WSL2 is not available and WSL components have not been fully set up."
518
- Enable-WslFeatures
519
- # Always exits (prompts reboot)
528
+ # Clean up failed WSL1 attempt
529
+ wsl.exe --unregister Ubuntu 2>$null | Out-Null
530
+ Remove-Item -Force -Recurse -ErrorAction SilentlyContinue $UBUNTU_WSL_DIR
531
+
532
+ Write-Info "WSL1 import failed, trying WSL2..."
533
+ if (Test-VirtualisationSupported -TarPath $tarPath) {
534
+ wsl.exe --set-default-version 2 >$null 2>$null
535
+ Install-UbuntuRootfs -WslVersion 2 -TarPath $tarPath
536
+ $wslVersion = 2
537
+ } else {
538
+ if ($wslFeaturesEnabled -ne "1") {
539
+ Write-Warn "Neither WSL1 nor WSL2 is available. Enabling WSL components..."
540
+ Enable-WslFeatures
541
+ # Always exits (prompts reboot)
542
+ }
543
+ Write-Fail "Failed to import Ubuntu into both WSL1 and WSL2."
544
+ Write-Fail "Please ensure Windows Subsystem for Linux is enabled and try again."
545
+ exit 1
520
546
  }
521
- Write-Info "[main] WSL2 unavailable, falling back to WSL1..."
522
- Install-UbuntuRootfs -WslVersion 1 -TarPath $tarPath
523
- $wslVersion = 1
524
547
  }
525
548
  }
526
549
 
527
- if ($wslVersion -eq 2) { Set-Wsl2MirroredNetworking }
528
-
529
550
  Write-Success "WSL is ready."
530
551
  Run-InstallInWsl
552
+
553
+ # For WSL2, configure mirrored networking AFTER install.sh succeeds (NAT is more
554
+ # reliable for outbound traffic during installation). The shutdown here is safe
555
+ # because installation is already complete.
556
+ if ($wslVersion -eq 2) { Set-Wsl2MirroredNetworking }
557
+
531
558
  Set-InstallReg -Name "WslVersion" -Value $wslVersion
532
559
  Show-PostInstall -WslVersion $wslVersion
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: openclacky
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.2.3
4
+ version: 1.2.4
5
5
  platform: ruby
6
6
  authors:
7
7
  - windy