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 +4 -4
- data/CHANGELOG.md +24 -0
- data/lib/clacky/agent.rb +11 -0
- data/lib/clacky/client.rb +8 -4
- data/lib/clacky/default_skills/channel-manager/SKILL.md +1 -0
- data/lib/clacky/default_skills/channel-manager/weixin_setup.rb +28 -8
- data/lib/clacky/mcp/stdio_transport.rb +6 -1
- data/lib/clacky/providers.rb +3 -0
- data/lib/clacky/server/browser_manager.rb +16 -3
- data/lib/clacky/server/channel/adapters/feishu/message_parser.rb +46 -1
- data/lib/clacky/server/channel/adapters/wecom/adapter.rb +38 -13
- data/lib/clacky/server/channel/channel_manager.rb +14 -4
- data/lib/clacky/server/channel/channel_ui_controller.rb +27 -11
- data/lib/clacky/server/http_server.rb +13 -3
- data/lib/clacky/tools/browser.rb +3 -3
- data/lib/clacky/tools/terminal.rb +169 -14
- data/lib/clacky/ui2/ui_controller.rb +20 -2
- data/lib/clacky/utils/login_shell.rb +3 -1
- data/lib/clacky/version.rb +1 -1
- data/lib/clacky/web/app.css +1 -0
- data/lib/clacky/web/settings.js +5 -0
- data/lib/clacky/web/weixin-qr.html +5 -4
- data/scripts/install.ps1 +44 -17
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 80e1b5549b0e55760ec4e0e905eea3ab50bc9a66bd8c256e628a1afdb8531ba0
|
|
4
|
+
data.tar.gz: 54142ef66c3b74d3998214202fa73618dd9b22ebdb06586c8d6896aafcb56d9d
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
|
|
53
|
-
def
|
|
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
|
-
|
|
58
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
data/lib/clacky/providers.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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 =
|
|
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 =
|
|
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,
|
|
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,
|
|
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
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
@
|
|
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
|
-
|
|
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
|
-
|
|
196
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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;
|
data/lib/clacky/tools/browser.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
data/lib/clacky/version.rb
CHANGED
data/lib/clacky/web/app.css
CHANGED
data/lib/clacky/web/settings.js
CHANGED
|
@@ -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:
|
|
144
|
-
// We only show success if token_updated_at
|
|
145
|
-
//
|
|
146
|
-
|
|
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
|
-
#
|
|
15
|
-
# the script
|
|
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
|
|
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
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
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
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
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
|