openclacky 1.2.2 → 1.2.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +36 -0
- data/lib/clacky/agent.rb +11 -0
- data/lib/clacky/client.rb +8 -4
- data/lib/clacky/default_skills/browser-setup/SKILL.md +90 -0
- 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 +5 -7
- 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/glob.rb +19 -4
- data/lib/clacky/tools/grep.rb +13 -1
- data/lib/clacky/tools/security.rb +1 -2
- data/lib/clacky/tools/terminal.rb +210 -14
- data/lib/clacky/ui2/ui_controller.rb +20 -2
- data/lib/clacky/utils/file_ignore_helper.rb +78 -5
- data/lib/clacky/utils/login_shell.rb +3 -1
- data/lib/clacky/utils/model_pricing.rb +28 -3
- data/lib/clacky/utils/scripts_manager.rb +1 -0
- 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/build/lib/apt.sh +71 -1
- data/scripts/build/src/install.sh.cc +1 -1
- data/scripts/build/src/install_rails_deps.sh.cc +4 -4
- data/scripts/build/src/install_system_deps.sh.cc +1 -1
- data/scripts/install.ps1 +44 -17
- data/scripts/install.sh +72 -2
- data/scripts/install_rails_deps.sh +75 -5
- data/scripts/install_system_deps.sh +72 -2
- data/scripts/wsl_network_doctor.ps1 +196 -0
- metadata +3 -2
|
@@ -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
|
|
data/lib/clacky/tools/glob.rb
CHANGED
|
@@ -49,6 +49,14 @@ module Clacky
|
|
|
49
49
|
return { error: "Base path does not exist: #{base_path}" }
|
|
50
50
|
end
|
|
51
51
|
|
|
52
|
+
if Clacky::Utils::FileIgnoreHelper.dangerous_root?(base_path)
|
|
53
|
+
return {
|
|
54
|
+
error: "Refusing to recursively glob from broad path '#{base_path}'. " \
|
|
55
|
+
"Narrow base_path to a specific subdirectory, " \
|
|
56
|
+
"or use '.' to search the working directory."
|
|
57
|
+
}
|
|
58
|
+
end
|
|
59
|
+
|
|
52
60
|
begin
|
|
53
61
|
expanded_path = base_path
|
|
54
62
|
|
|
@@ -70,7 +78,8 @@ module Clacky
|
|
|
70
78
|
fnmatch_flags = File::FNM_PATHNAME | File::FNM_DOTMATCH
|
|
71
79
|
|
|
72
80
|
matches = []
|
|
73
|
-
|
|
81
|
+
walk_status = {}
|
|
82
|
+
Clacky::Utils::FileIgnoreHelper.walk_files(expanded_path, skipped: skipped, status: walk_status) do |file|
|
|
74
83
|
relative = file[(expanded_path.length + 1)..]
|
|
75
84
|
|
|
76
85
|
unless File.fnmatch(effective_pattern, relative, fnmatch_flags)
|
|
@@ -101,11 +110,13 @@ module Clacky
|
|
|
101
110
|
# Convert to absolute paths
|
|
102
111
|
matches = matches.map { |path| File.expand_path(path) }
|
|
103
112
|
|
|
113
|
+
walk_truncated = walk_status[:truncated] == true
|
|
104
114
|
{
|
|
105
115
|
matches: matches,
|
|
106
116
|
total_matches: total_matches,
|
|
107
117
|
returned: matches.length,
|
|
108
|
-
truncated: total_matches > limit,
|
|
118
|
+
truncated: total_matches > limit || walk_truncated,
|
|
119
|
+
truncation_reason: walk_status[:truncation_reason],
|
|
109
120
|
skipped_files: skipped,
|
|
110
121
|
error: nil
|
|
111
122
|
}
|
|
@@ -129,9 +140,13 @@ module Clacky
|
|
|
129
140
|
count = result[:returned] || 0
|
|
130
141
|
total = result[:total_matches] || 0
|
|
131
142
|
truncated = result[:truncated] ? " (truncated)" : ""
|
|
132
|
-
|
|
143
|
+
|
|
133
144
|
msg = "[OK] Found #{count}/#{total} files#{truncated}"
|
|
134
|
-
|
|
145
|
+
|
|
146
|
+
if result[:truncation_reason]
|
|
147
|
+
msg += " [walk #{result[:truncation_reason]}]"
|
|
148
|
+
end
|
|
149
|
+
|
|
135
150
|
# Add skipped files info if present
|
|
136
151
|
if result[:skipped_files]
|
|
137
152
|
skipped = result[:skipped_files]
|
data/lib/clacky/tools/grep.rb
CHANGED
|
@@ -92,6 +92,14 @@ module Clacky
|
|
|
92
92
|
return { error: "Path does not exist: #{path}" }
|
|
93
93
|
end
|
|
94
94
|
|
|
95
|
+
if File.directory?(expanded_path) && Clacky::Utils::FileIgnoreHelper.dangerous_root?(expanded_path)
|
|
96
|
+
return {
|
|
97
|
+
error: "Refusing to recursively grep from broad path '#{path}'. " \
|
|
98
|
+
"Narrow the path to a specific subdirectory, " \
|
|
99
|
+
"or use '.' to search the working directory."
|
|
100
|
+
}
|
|
101
|
+
end
|
|
102
|
+
|
|
95
103
|
# Limit context_lines
|
|
96
104
|
context_lines = [[context_lines, 0].max, 10].min
|
|
97
105
|
|
|
@@ -115,10 +123,14 @@ module Clacky
|
|
|
115
123
|
else
|
|
116
124
|
fnmatch_flags = File::FNM_PATHNAME | File::FNM_DOTMATCH
|
|
117
125
|
collected = []
|
|
118
|
-
|
|
126
|
+
walk_status = {}
|
|
127
|
+
Clacky::Utils::FileIgnoreHelper.walk_files(expanded_path, skipped: skipped, status: walk_status) do |f|
|
|
119
128
|
relative = f[(expanded_path.length + 1)..]
|
|
120
129
|
collected << f if File.fnmatch(file_pattern, relative, fnmatch_flags)
|
|
121
130
|
end
|
|
131
|
+
if walk_status[:truncated]
|
|
132
|
+
truncation_reason ||= "walk #{walk_status[:truncation_reason]}"
|
|
133
|
+
end
|
|
122
134
|
collected
|
|
123
135
|
end
|
|
124
136
|
|
|
@@ -20,7 +20,7 @@ module Clacky
|
|
|
20
20
|
# to a shell / PTY for execution):
|
|
21
21
|
#
|
|
22
22
|
# 1. Block hard-dangerous commands: sudo, pkill clacky, eval, exec,
|
|
23
|
-
# `...`,
|
|
23
|
+
# `...`, | sh, | bash,
|
|
24
24
|
# redirect to /etc /usr /bin.
|
|
25
25
|
# 2. Rewrite `curl ... | bash` → save script to a file for manual
|
|
26
26
|
# review instead of exec.
|
|
@@ -246,7 +246,6 @@ module Clacky
|
|
|
246
246
|
/exec\s*\(/,
|
|
247
247
|
/system\s*\(/,
|
|
248
248
|
/`[^`]+`/,
|
|
249
|
-
/\$\([^)]+\)/,
|
|
250
249
|
/\|\s*sh\s*$/,
|
|
251
250
|
/\|\s*bash\s*$/,
|
|
252
251
|
/>\s*\/etc\//,
|
|
@@ -72,6 +72,9 @@ module Clacky
|
|
|
72
72
|
{session_id, input:"pw\n"} reply to prompt / poll (input:"")
|
|
73
73
|
{session_id, kill:true} stop
|
|
74
74
|
|
|
75
|
+
Single-line only. For multi-line scripts (heredoc, loops, multi-statement blocks)
|
|
76
|
+
write a file first, then run it: write(path:"/tmp/run.sh", content:...) → terminal(command:"bash /tmp/run.sh").
|
|
77
|
+
|
|
75
78
|
Response: exit_code = done; session_id = running (state: waiting/background/timeout).
|
|
76
79
|
If output exceeds the limit, `output` is truncated and `full_output_file` points
|
|
77
80
|
at a file on disk — use terminal(command: "grep ... <path>") to search it.
|
|
@@ -196,7 +199,7 @@ module Clacky
|
|
|
196
199
|
# ---------------------------------------------------------------------
|
|
197
200
|
def execute(command: nil, session_id: nil, input: nil, background: false,
|
|
198
201
|
cwd: nil, env: nil, timeout: nil, kill: nil, idle_ms: nil,
|
|
199
|
-
working_dir: nil, **_ignored)
|
|
202
|
+
working_dir: nil, on_output: nil, **_ignored)
|
|
200
203
|
# Auto-tune: if the caller didn't explicitly set a timeout/idle_ms
|
|
201
204
|
# AND the command is a well-known long-runner (rspec, bundle install,
|
|
202
205
|
# cargo build, etc.), we stretch the budget AND disable idle-return.
|
|
@@ -223,13 +226,25 @@ module Clacky
|
|
|
223
226
|
# Continue / poll a running session
|
|
224
227
|
if session_id
|
|
225
228
|
return { error: "input is required when session_id is given" } if input.nil?
|
|
226
|
-
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)
|
|
227
230
|
end
|
|
228
231
|
|
|
229
232
|
# Start a new command
|
|
230
233
|
if command && !command.to_s.strip.empty?
|
|
234
|
+
if multiline_command?(command)
|
|
235
|
+
return {
|
|
236
|
+
error: "Multi-line commands are unreliable in our PTY shell " \
|
|
237
|
+
"(heredocs / unclosed quotes / multi-line blocks can hang the session).",
|
|
238
|
+
hint: "Write the script to a file first, then execute it. " \
|
|
239
|
+
"Example: 1) write(path: \"/tmp/run.sh\", content: \"...\") " \
|
|
240
|
+
"2) terminal(command: \"bash /tmp/run.sh\")",
|
|
241
|
+
multiline_blocked: true
|
|
242
|
+
}
|
|
243
|
+
end
|
|
244
|
+
|
|
231
245
|
return do_start(command.to_s, cwd: cwd, env: env, timeout: timeout,
|
|
232
|
-
idle_ms: idle_ms, background: background ? true : false
|
|
246
|
+
idle_ms: idle_ms, background: background ? true : false,
|
|
247
|
+
on_output: on_output)
|
|
233
248
|
end
|
|
234
249
|
|
|
235
250
|
{ error: "terminal: must provide either `command`, or `session_id`+`input`, or `session_id`+`kill: true`." }
|
|
@@ -313,7 +328,7 @@ module Clacky
|
|
|
313
328
|
# ---------------------------------------------------------------------
|
|
314
329
|
# 1) Start a new command
|
|
315
330
|
# ---------------------------------------------------------------------
|
|
316
|
-
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)
|
|
317
332
|
if cwd && !Dir.exist?(cwd.to_s)
|
|
318
333
|
return { error: "cwd does not exist: #{cwd}" }
|
|
319
334
|
end
|
|
@@ -325,6 +340,16 @@ module Clacky
|
|
|
325
340
|
project_root: cwd || Dir.pwd
|
|
326
341
|
)
|
|
327
342
|
|
|
343
|
+
# WSL interop fix: Windows .exe processes inherit the PTY's stdin fd
|
|
344
|
+
# and attempt to use it as a Windows Console, causing them to hang
|
|
345
|
+
# indefinitely. Redirect stdin from /dev/null for any .exe invocation
|
|
346
|
+
# that doesn't already have an explicit stdin redirect.
|
|
347
|
+
safe_command = redirect_exe_stdin(safe_command)
|
|
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
|
+
|
|
328
353
|
# Background / dedicated path — never reuse the persistent shell,
|
|
329
354
|
# because these commands stay running and would occupy the slot.
|
|
330
355
|
if background
|
|
@@ -343,7 +368,8 @@ module Clacky
|
|
|
343
368
|
background: true,
|
|
344
369
|
persistent: false,
|
|
345
370
|
original_command: command,
|
|
346
|
-
rewritten_command: safe_command
|
|
371
|
+
rewritten_command: safe_command,
|
|
372
|
+
on_output: on_output
|
|
347
373
|
)
|
|
348
374
|
end
|
|
349
375
|
|
|
@@ -368,14 +394,15 @@ module Clacky
|
|
|
368
394
|
idle_ms: idle_ms,
|
|
369
395
|
persistent: persistent,
|
|
370
396
|
original_command: command,
|
|
371
|
-
rewritten_command: safe_command
|
|
397
|
+
rewritten_command: safe_command,
|
|
398
|
+
on_output: on_output
|
|
372
399
|
)
|
|
373
400
|
end
|
|
374
401
|
|
|
375
402
|
# ---------------------------------------------------------------------
|
|
376
403
|
# 2) Continue / poll an existing session
|
|
377
404
|
# ---------------------------------------------------------------------
|
|
378
|
-
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)
|
|
379
406
|
session = SessionManager.refresh(session_id)
|
|
380
407
|
return { error: "Session ##{session_id} not found (already finished or killed)." } unless session
|
|
381
408
|
|
|
@@ -386,7 +413,7 @@ module Clacky
|
|
|
386
413
|
|
|
387
414
|
session.mutex.synchronize { session.writer.write(normalize_input_for_pty(input.to_s)) } unless input.to_s.empty?
|
|
388
415
|
|
|
389
|
-
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)
|
|
390
417
|
end
|
|
391
418
|
|
|
392
419
|
# `\n` is a Unix newline, not the "Enter key". Inside cooked-mode PTYs
|
|
@@ -435,10 +462,11 @@ module Clacky
|
|
|
435
462
|
# :timeout | session_id, state=timeout | session_id, state=background
|
|
436
463
|
private def wait_and_package(session, timeout:, idle_ms: DEFAULT_IDLE_MS,
|
|
437
464
|
background: false, persistent: false,
|
|
438
|
-
original_command: nil, rewritten_command: nil
|
|
465
|
+
original_command: nil, rewritten_command: nil,
|
|
466
|
+
on_output: nil)
|
|
439
467
|
start_offset = session.read_offset
|
|
440
468
|
|
|
441
|
-
_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)
|
|
442
470
|
|
|
443
471
|
new_offset = log_size(session)
|
|
444
472
|
raw = read_log_slice(session.log_file, start_offset, new_offset)
|
|
@@ -768,7 +796,7 @@ module Clacky
|
|
|
768
796
|
|
|
769
797
|
spawn_env = {
|
|
770
798
|
"TERM" => "xterm-256color",
|
|
771
|
-
"PS1" => "",
|
|
799
|
+
"PS1" => " ",
|
|
772
800
|
# Prevent our sub-shell from polluting the user's ~/.zsh_history
|
|
773
801
|
# (or ~/.bash_history). We fork a full interactive login shell to
|
|
774
802
|
# get rbenv/nvm/brew-shellenv/mise loaded, but every command we
|
|
@@ -1046,10 +1074,16 @@ module Clacky
|
|
|
1046
1074
|
# whole persistent shell.
|
|
1047
1075
|
def source_rc_in_session(session, rc_files)
|
|
1048
1076
|
return if rc_files.empty?
|
|
1049
|
-
|
|
1077
|
+
sources = rc_files.map { |f|
|
|
1050
1078
|
escaped = f.gsub('"', '\"')
|
|
1051
1079
|
"source \"#{escaped}\" || true"
|
|
1052
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}
|
|
1053
1087
|
run_inline(session, cmd, timeout: 15)
|
|
1054
1088
|
end
|
|
1055
1089
|
|
|
@@ -1102,7 +1136,12 @@ module Clacky
|
|
|
1102
1136
|
# Poll the log file until a marker matches, idle-return fires, or timeout.
|
|
1103
1137
|
# Returns [raw_before_marker, exit_code_or_nil, state].
|
|
1104
1138
|
# state ∈ :matched, :idle, :timeout, :eof
|
|
1105
|
-
|
|
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)
|
|
1106
1145
|
return ["", nil, :eof] unless session.marker_regex
|
|
1107
1146
|
|
|
1108
1147
|
deadline = Time.now + timeout
|
|
@@ -1110,14 +1149,87 @@ module Clacky
|
|
|
1110
1149
|
start_size = session.read_offset
|
|
1111
1150
|
last_size = start_size
|
|
1112
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
|
|
1113
1213
|
|
|
1114
1214
|
loop do
|
|
1115
1215
|
current_size = log_size(session)
|
|
1116
1216
|
if current_size > last_size
|
|
1117
1217
|
slice = read_log_slice(session.log_file, session.read_offset, current_size)
|
|
1118
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
|
|
1119
1224
|
return [slice[0...m.begin(0)], m[1].to_i, :matched]
|
|
1120
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
|
+
|
|
1121
1233
|
last_size = current_size
|
|
1122
1234
|
last_change = Time.now
|
|
1123
1235
|
end
|
|
@@ -1126,16 +1238,31 @@ module Clacky
|
|
|
1126
1238
|
if session.status == "exited" || session.status == "killed"
|
|
1127
1239
|
slice = read_log_slice(session.log_file, session.read_offset, log_size(session))
|
|
1128
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
|
|
1129
1246
|
return [slice[0...m.begin(0)], m[1].to_i, :matched]
|
|
1130
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
|
|
1131
1252
|
return [slice, nil, :eof]
|
|
1132
1253
|
end
|
|
1133
1254
|
|
|
1134
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?
|
|
1135
1259
|
return ["", nil, :idle]
|
|
1136
1260
|
end
|
|
1137
1261
|
|
|
1138
|
-
|
|
1262
|
+
if Time.now >= deadline
|
|
1263
|
+
flush_stream.call("", force_partial: true) unless stream_pending.empty?
|
|
1264
|
+
return ["", nil, :timeout]
|
|
1265
|
+
end
|
|
1139
1266
|
sleep 0.05
|
|
1140
1267
|
end
|
|
1141
1268
|
end
|
|
@@ -1179,6 +1306,17 @@ module Clacky
|
|
|
1179
1306
|
SLOW_COMMAND_PATTERNS.any? { |pat| s.include?(pat) }
|
|
1180
1307
|
end
|
|
1181
1308
|
|
|
1309
|
+
# True when `command` spans multiple lines. Trailing newlines are
|
|
1310
|
+
# ignored — a single-line command terminated with "\n" is still
|
|
1311
|
+
# single-line. Multi-line commands frequently hang the persistent
|
|
1312
|
+
# PTY shell (incomplete heredoc, unclosed quote, multi-line block
|
|
1313
|
+
# without closer) — the agent should write a script file and
|
|
1314
|
+
# invoke it instead.
|
|
1315
|
+
private def multiline_command?(command)
|
|
1316
|
+
return false if command.nil?
|
|
1317
|
+
command.to_s.sub(/\n+\z/, "").include?("\n")
|
|
1318
|
+
end
|
|
1319
|
+
|
|
1182
1320
|
# Apply per-line truncation to a cleaned (post-OutputCleaner) string.
|
|
1183
1321
|
# If any single line exceeds MAX_LINE_CHARS, we chop it at that length
|
|
1184
1322
|
# and append `…[line truncated: <original> chars]` so the LLM knows
|
|
@@ -1358,6 +1496,64 @@ module Clacky
|
|
|
1358
1496
|
return "" if lines.empty?
|
|
1359
1497
|
lines.last(DISPLAY_TAIL_LINES).join("\n")
|
|
1360
1498
|
end
|
|
1499
|
+
|
|
1500
|
+
# WSL interop fix: Windows .exe processes inherit the PTY stdin fd
|
|
1501
|
+
# and try to use it as a Windows Console, which hangs indefinitely.
|
|
1502
|
+
# Detect .exe invocations and redirect stdin from /dev/null unless
|
|
1503
|
+
# the command already has an explicit stdin redirect.
|
|
1504
|
+
private def redirect_exe_stdin(command)
|
|
1505
|
+
return command unless command =~ /\.exe\b/i
|
|
1506
|
+
return command if command =~ /<\s*[^\s|&;]/
|
|
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
|
|
1556
|
+
end
|
|
1361
1557
|
end
|
|
1362
1558
|
end
|
|
1363
1559
|
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?
|