openclacky 1.2.3 → 1.2.5
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 +33 -0
- data/lib/clacky/agent.rb +11 -0
- data/lib/clacky/client.rb +8 -4
- data/lib/clacky/default_skills/browser-setup/SKILL.md +16 -90
- 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 +180 -14
- data/lib/clacky/ui2/ui_controller.rb +20 -2
- data/lib/clacky/utils/login_shell.rb +3 -1
- data/lib/clacky/utils/scripts_manager.rb +0 -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 -2
- data/scripts/wsl_network_doctor.ps1 +0 -196
|
@@ -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,98 @@ 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
|
+
#
|
|
1170
|
+
# However, when stty -echo is active (the normal case for our
|
|
1171
|
+
# persistent sessions), the wrapper is never echoed — so the tail
|
|
1172
|
+
# marker never appears. We detect this by checking: if we have a
|
|
1173
|
+
# complete line (\n present) and it does NOT contain the wrapper
|
|
1174
|
+
# fingerprint, echo was suppressed and we can start streaming
|
|
1175
|
+
# immediately.
|
|
1176
|
+
unless wrapper_swallowed
|
|
1177
|
+
tail_marker = '"$__clacky_ec"'
|
|
1178
|
+
tail_idx = stream_pending.index(tail_marker)
|
|
1179
|
+
if tail_idx
|
|
1180
|
+
eol_after = stream_pending.index("\n", tail_idx) || (stream_pending.bytesize - 1)
|
|
1181
|
+
stream_pending.replace(stream_pending.byteslice(eol_after + 1, stream_pending.bytesize - eol_after - 1).to_s)
|
|
1182
|
+
wrapper_swallowed = true
|
|
1183
|
+
elsif force_partial
|
|
1184
|
+
wrapper_swallowed = true
|
|
1185
|
+
elsif stream_pending.include?("\n") && !stream_pending.include?("__clacky_ec")
|
|
1186
|
+
# stty -echo suppressed the wrapper echo; real output is arriving.
|
|
1187
|
+
wrapper_swallowed = true
|
|
1188
|
+
else
|
|
1189
|
+
return
|
|
1190
|
+
end
|
|
1191
|
+
end
|
|
1192
|
+
|
|
1193
|
+
if force_partial
|
|
1194
|
+
buffered = stream_pending.dup
|
|
1195
|
+
stream_pending.clear
|
|
1196
|
+
else
|
|
1197
|
+
nl = stream_pending.rindex("\n")
|
|
1198
|
+
return if nl.nil?
|
|
1199
|
+
buffered = stream_pending.byteslice(0, nl + 1)
|
|
1200
|
+
stream_pending.replace(stream_pending.byteslice(nl + 1, stream_pending.bytesize - nl - 1).to_s)
|
|
1201
|
+
end
|
|
1202
|
+
cleaned = OutputCleaner.clean(buffered)
|
|
1203
|
+
# Strip any wrapper echo that still slipped through (e.g. when a
|
|
1204
|
+
# session is reused and ZLE re-echoes our wrapper mid-stream).
|
|
1205
|
+
cleaned = strip_command_echo(cleaned, marker_token: session.marker_token)
|
|
1206
|
+
# Belt-and-braces: drop any line that still carries our internal
|
|
1207
|
+
# tokens.
|
|
1208
|
+
cleaned = cleaned.lines.reject do |ln|
|
|
1209
|
+
ln.include?("__clacky_ec") ||
|
|
1210
|
+
ln.include?("__CLACKY_DONE_") ||
|
|
1211
|
+
ln.include?("__clacky_f") ||
|
|
1212
|
+
ln.include?("__clacky_pc") ||
|
|
1213
|
+
ln.match?(/\A\s*\}\s*>\s*\/dev\/null\s+2>&1;?\s*\z/)
|
|
1214
|
+
end.join
|
|
1215
|
+
# Collapse runs of 3+ blank lines into a single blank line so
|
|
1216
|
+
# PTY noise (cursor-positioning codes cleaned to empty lines)
|
|
1217
|
+
# doesn't produce a wall of whitespace in the streaming UI.
|
|
1218
|
+
cleaned = cleaned.gsub(/\n{3,}/, "\n\n")
|
|
1219
|
+
cleaned = cleaned.lstrip if cleaned.match?(/\A\n+\z/)
|
|
1220
|
+
on_output.call(cleaned) unless cleaned.empty? || cleaned.match?(/\A\s*\z/)
|
|
1221
|
+
rescue StandardError
|
|
1222
|
+
# Streaming is best-effort — never let a UI bug abort the command.
|
|
1223
|
+
end
|
|
1133
1224
|
|
|
1134
1225
|
loop do
|
|
1135
1226
|
current_size = log_size(session)
|
|
1136
1227
|
if current_size > last_size
|
|
1137
1228
|
slice = read_log_slice(session.log_file, session.read_offset, current_size)
|
|
1138
1229
|
if (m = slice.match(session.marker_regex))
|
|
1230
|
+
marker_abs = session.read_offset + m.begin(0)
|
|
1231
|
+
if marker_abs > streamed_to
|
|
1232
|
+
tail = read_log_slice(session.log_file, streamed_to, marker_abs)
|
|
1233
|
+
flush_stream.call(tail, force_partial: true)
|
|
1234
|
+
end
|
|
1139
1235
|
return [slice[0...m.begin(0)], m[1].to_i, :matched]
|
|
1140
1236
|
end
|
|
1237
|
+
|
|
1238
|
+
if current_size > streamed_to
|
|
1239
|
+
new_chunk = read_log_slice(session.log_file, streamed_to, current_size)
|
|
1240
|
+
flush_stream.call(new_chunk)
|
|
1241
|
+
streamed_to = current_size
|
|
1242
|
+
end
|
|
1243
|
+
|
|
1141
1244
|
last_size = current_size
|
|
1142
1245
|
last_change = Time.now
|
|
1143
1246
|
end
|
|
@@ -1146,16 +1249,31 @@ module Clacky
|
|
|
1146
1249
|
if session.status == "exited" || session.status == "killed"
|
|
1147
1250
|
slice = read_log_slice(session.log_file, session.read_offset, log_size(session))
|
|
1148
1251
|
if (m = slice.match(session.marker_regex))
|
|
1252
|
+
marker_abs = session.read_offset + m.begin(0)
|
|
1253
|
+
if marker_abs > streamed_to
|
|
1254
|
+
tail = read_log_slice(session.log_file, streamed_to, marker_abs)
|
|
1255
|
+
flush_stream.call(tail, force_partial: true)
|
|
1256
|
+
end
|
|
1149
1257
|
return [slice[0...m.begin(0)], m[1].to_i, :matched]
|
|
1150
1258
|
end
|
|
1259
|
+
final_size = log_size(session)
|
|
1260
|
+
if final_size > streamed_to
|
|
1261
|
+
flush_stream.call(read_log_slice(session.log_file, streamed_to, final_size), force_partial: true)
|
|
1262
|
+
end
|
|
1151
1263
|
return [slice, nil, :eof]
|
|
1152
1264
|
end
|
|
1153
1265
|
|
|
1154
1266
|
if last_size > start_size && (Time.now - last_change) >= idle_sec
|
|
1267
|
+
# Going idle: flush any partial buffered line (e.g. an in-progress
|
|
1268
|
+
# progress bar without trailing \n) so the UI sees current state.
|
|
1269
|
+
flush_stream.call("", force_partial: true) unless stream_pending.empty?
|
|
1155
1270
|
return ["", nil, :idle]
|
|
1156
1271
|
end
|
|
1157
1272
|
|
|
1158
|
-
|
|
1273
|
+
if Time.now >= deadline
|
|
1274
|
+
flush_stream.call("", force_partial: true) unless stream_pending.empty?
|
|
1275
|
+
return ["", nil, :timeout]
|
|
1276
|
+
end
|
|
1159
1277
|
sleep 0.05
|
|
1160
1278
|
end
|
|
1161
1279
|
end
|
|
@@ -1397,7 +1515,55 @@ module Clacky
|
|
|
1397
1515
|
private def redirect_exe_stdin(command)
|
|
1398
1516
|
return command unless command =~ /\.exe\b/i
|
|
1399
1517
|
return command if command =~ /<\s*[^\s|&;]/
|
|
1400
|
-
|
|
1518
|
+
|
|
1519
|
+
# If the command has a shell-level pipe, insert </dev/null before
|
|
1520
|
+
# the first pipe so only the .exe segment gets its stdin redirected,
|
|
1521
|
+
# rather than starving a downstream pipe reader (e.g. `tr`, `grep`).
|
|
1522
|
+
if command =~ /\|/
|
|
1523
|
+
command.sub(/\s*\|/, ' </dev/null |')
|
|
1524
|
+
else
|
|
1525
|
+
"#{command} </dev/null"
|
|
1526
|
+
end
|
|
1527
|
+
end
|
|
1528
|
+
|
|
1529
|
+
# PowerShell 5 on Chinese Windows defaults [Console]::OutputEncoding
|
|
1530
|
+
# to CP936/GBK; our PTY decodes as UTF-8 so non-ASCII output becomes
|
|
1531
|
+
# `???`. Inject UTF-8 setup into the user's PowerShell command so the
|
|
1532
|
+
# shell emits UTF-8 bytes regardless of host locale.
|
|
1533
|
+
POWERSHELL_PREAMBLE =
|
|
1534
|
+
"[Console]::OutputEncoding=[Text.Encoding]::UTF8;" \
|
|
1535
|
+
"$OutputEncoding=[Text.Encoding]::UTF8;"
|
|
1536
|
+
|
|
1537
|
+
# Only rewrites simple `powershell[.exe]` / `pwsh[.exe]` invocations.
|
|
1538
|
+
# Skips -File / -EncodedCommand / commands already handling encoding /
|
|
1539
|
+
# pipelines (anything risky to splice).
|
|
1540
|
+
private def force_powershell_utf8(command)
|
|
1541
|
+
cmd = command.to_s
|
|
1542
|
+
return command unless cmd =~ /\A\s*(?:powershell(?:\.exe)?|pwsh(?:\.exe)?)\b/i
|
|
1543
|
+
return command if cmd =~ /OutputEncoding/i
|
|
1544
|
+
return command if cmd =~ /\s-(?:File|EncodedCommand|enc|f)\b/i
|
|
1545
|
+
|
|
1546
|
+
# `-Command "..."` form: pipeline / chain characters inside the
|
|
1547
|
+
# quoted body are PowerShell-internal, not shell-level, so we splice
|
|
1548
|
+
# safely into the quoted string.
|
|
1549
|
+
if (m = cmd.match(/\A(\s*(?:powershell(?:\.exe)?|pwsh(?:\.exe)?)\s+(?:[^"'\s]+\s+)*?-(?:Command|c)\s+)(["'])(.*)\2(\s*(?:<\s*\S+\s*)?)\z/i))
|
|
1550
|
+
head, quote, body, tail = m[1], m[2], m[3], m[4].to_s
|
|
1551
|
+
return "#{head}#{quote}#{POWERSHELL_PREAMBLE}#{body}#{quote}#{tail}"
|
|
1552
|
+
end
|
|
1553
|
+
|
|
1554
|
+
# Outside the quoted-Command form, refuse to splice if there's any
|
|
1555
|
+
# shell-level pipe / chain — too risky to get the boundaries right.
|
|
1556
|
+
return command if cmd =~ /[|&;]/
|
|
1557
|
+
|
|
1558
|
+
if (m = cmd.match(/\A(\s*(?:powershell(?:\.exe)?|pwsh(?:\.exe)?))(.*)\z/i))
|
|
1559
|
+
exe, rest = m[1], m[2].to_s.strip
|
|
1560
|
+
return command if rest.start_with?("-") && rest !~ /\A-(?:Command|c)\b/i
|
|
1561
|
+
rest = rest.sub(/\A-(?:Command|c)\b\s*/i, "")
|
|
1562
|
+
inner = rest.empty? ? POWERSHELL_PREAMBLE.chomp(";") : "#{POWERSHELL_PREAMBLE}#{rest}"
|
|
1563
|
+
return %Q{#{exe} -Command "#{inner}"}
|
|
1564
|
+
end
|
|
1565
|
+
|
|
1566
|
+
command
|
|
1401
1567
|
end
|
|
1402
1568
|
end
|
|
1403
1569
|
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
|
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.
|
|
4
|
+
version: 1.2.5
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- windy
|
|
@@ -596,7 +596,6 @@ files:
|
|
|
596
596
|
- scripts/install_rails_deps.sh
|
|
597
597
|
- scripts/install_system_deps.sh
|
|
598
598
|
- scripts/uninstall.sh
|
|
599
|
-
- scripts/wsl_network_doctor.ps1
|
|
600
599
|
- sig/clacky.rbs
|
|
601
600
|
homepage: https://github.com/clacky-ai/openclacky
|
|
602
601
|
licenses:
|