openclacky 1.2.2 → 1.2.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +36 -0
  3. data/lib/clacky/agent.rb +11 -0
  4. data/lib/clacky/client.rb +8 -4
  5. data/lib/clacky/default_skills/browser-setup/SKILL.md +90 -0
  6. data/lib/clacky/default_skills/channel-manager/SKILL.md +1 -0
  7. data/lib/clacky/default_skills/channel-manager/weixin_setup.rb +28 -8
  8. data/lib/clacky/mcp/stdio_transport.rb +6 -1
  9. data/lib/clacky/providers.rb +5 -7
  10. data/lib/clacky/server/browser_manager.rb +16 -3
  11. data/lib/clacky/server/channel/adapters/feishu/message_parser.rb +46 -1
  12. data/lib/clacky/server/channel/adapters/wecom/adapter.rb +38 -13
  13. data/lib/clacky/server/channel/channel_manager.rb +14 -4
  14. data/lib/clacky/server/channel/channel_ui_controller.rb +27 -11
  15. data/lib/clacky/server/http_server.rb +13 -3
  16. data/lib/clacky/tools/browser.rb +3 -3
  17. data/lib/clacky/tools/glob.rb +19 -4
  18. data/lib/clacky/tools/grep.rb +13 -1
  19. data/lib/clacky/tools/security.rb +1 -2
  20. data/lib/clacky/tools/terminal.rb +210 -14
  21. data/lib/clacky/ui2/ui_controller.rb +20 -2
  22. data/lib/clacky/utils/file_ignore_helper.rb +78 -5
  23. data/lib/clacky/utils/login_shell.rb +3 -1
  24. data/lib/clacky/utils/model_pricing.rb +28 -3
  25. data/lib/clacky/utils/scripts_manager.rb +1 -0
  26. data/lib/clacky/version.rb +1 -1
  27. data/lib/clacky/web/app.css +1 -0
  28. data/lib/clacky/web/settings.js +5 -0
  29. data/lib/clacky/web/weixin-qr.html +5 -4
  30. data/scripts/build/lib/apt.sh +71 -1
  31. data/scripts/build/src/install.sh.cc +1 -1
  32. data/scripts/build/src/install_rails_deps.sh.cc +4 -4
  33. data/scripts/build/src/install_system_deps.sh.cc +1 -1
  34. data/scripts/install.ps1 +44 -17
  35. data/scripts/install.sh +72 -2
  36. data/scripts/install_rails_deps.sh +75 -5
  37. data/scripts/install_system_deps.sh +72 -2
  38. data/scripts/wsl_network_doctor.ps1 +196 -0
  39. metadata +3 -2
@@ -368,6 +368,8 @@ module Clacky
368
368
  90
369
369
  elsif path == "/api/tool/browser"
370
370
  30
371
+ elsif path.end_with?("/benchmark")
372
+ 20
371
373
  else
372
374
  10
373
375
  end
@@ -1798,6 +1800,7 @@ module Clacky
1798
1800
 
1799
1801
  data["mcpServers"][name] = spec
1800
1802
  mcp_write_raw_config(data)
1803
+ @mcp_registry_mutex.synchronize { @mcp_registry&.reload }
1801
1804
  json_response(res, 200, { ok: true, name: name, config_path: mcp_config_path })
1802
1805
  end
1803
1806
 
@@ -1821,6 +1824,7 @@ module Clacky
1821
1824
 
1822
1825
  data["mcpServers"][name] = spec
1823
1826
  mcp_write_raw_config(data)
1827
+ @mcp_registry_mutex.synchronize { @mcp_registry&.reload }
1824
1828
  json_response(res, 200, { ok: true, name: name })
1825
1829
  end
1826
1830
 
@@ -1836,6 +1840,7 @@ module Clacky
1836
1840
 
1837
1841
  data["mcpServers"].delete(name)
1838
1842
  mcp_write_raw_config(data)
1843
+ @mcp_registry_mutex.synchronize { @mcp_registry&.reload }
1839
1844
  json_response(res, 200, { ok: true, name: name })
1840
1845
  end
1841
1846
 
@@ -3619,8 +3624,12 @@ module Clacky
3619
3624
  end
3620
3625
 
3621
3626
  results = threads.map do |t|
3622
- t.join(per_model_timeout + 3)
3623
- t.value rescue { ok: false, error: "thread failed" }
3627
+ if t.join(per_model_timeout + 3)
3628
+ t.value rescue { ok: false, error: "thread failed" }
3629
+ else
3630
+ t.kill
3631
+ { ok: false, error: "Request timed out" }
3632
+ end
3624
3633
  end
3625
3634
 
3626
3635
  json_response(res, 200, { ok: true, results: results })
@@ -3641,7 +3650,8 @@ module Clacky
3641
3650
  model_cfg["api_key"].to_s,
3642
3651
  base_url: model_cfg["base_url"].to_s,
3643
3652
  model: model_name,
3644
- anthropic_format: model_cfg["anthropic_format"] || false
3653
+ anthropic_format: model_cfg["anthropic_format"] || false,
3654
+ read_timeout: timeout_sec
3645
3655
  )
3646
3656
 
3647
3657
  # Override Faraday timeouts via a short-lived env var isn't ideal;
@@ -242,7 +242,7 @@ module Clacky
242
242
  { action: "tabs", success: true, profile: "user", output: format_tabs(pages), tabs: pages }
243
243
 
244
244
  when "snapshot"
245
- raw = mcp_call("take_snapshot")
245
+ raw = mcp_call("take_snapshot", with_page({}))
246
246
  text = build_ai_snapshot(extract_snapshot(raw),
247
247
  interactive: opts[:interactive] || opts["interactive"],
248
248
  compact: opts[:compact] || opts["compact"],
@@ -352,7 +352,7 @@ module Clacky
352
352
  sleep(ms.to_i / 1000.0)
353
353
  return { action: "act", success: true, profile: "user", output: "Waited #{ms}ms" }
354
354
  elsif sel
355
- mcp_call("wait_for", { text: [sel] })
355
+ mcp_call("wait_for", with_page({ text: [sel] }))
356
356
  else
357
357
  sleep(1)
358
358
  end
@@ -409,7 +409,7 @@ module Clacky
409
409
 
410
410
  call_args = { format: "png", fullPage: full_page }
411
411
  call_args[:uid] = uid if uid
412
- result = mcp_call("take_screenshot", call_args)
412
+ result = mcp_call("take_screenshot", with_page(call_args))
413
413
 
414
414
  image_block = Array(result["content"]).find { |b| b.is_a?(Hash) && b["type"] == "image" }
415
415
 
@@ -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
- Clacky::Utils::FileIgnoreHelper.walk_files(expanded_path, skipped: skipped) do |file|
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]
@@ -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
- Clacky::Utils::FileIgnoreHelper.walk_files(expanded_path, skipped: skipped) do |f|
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
- # `...`, $(...), | sh, | bash,
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
- cmd = rc_files.map { |f|
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
- private def read_until_marker(session, timeout:, idle_ms: DEFAULT_IDLE_MS)
1139
+ #
1140
+ # `on_output` (optional Proc): called as on_output.call(chunk_string) for
1141
+ # each new piece of output as it arrives, BEFORE the marker is detected.
1142
+ # The chunk has ANSI codes / wrapper echoes stripped so it's safe to
1143
+ # render in a UI. The completion marker itself is never passed through.
1144
+ private def read_until_marker(session, timeout:, idle_ms: DEFAULT_IDLE_MS, on_output: nil)
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
- return ["", nil, :timeout] if Time.now >= deadline
1262
+ if Time.now >= deadline
1263
+ flush_stream.call("", force_partial: true) unless stream_pending.empty?
1264
+ return ["", nil, :timeout]
1265
+ end
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
- @stdout_lines.concat(lines.map(&:chomp))
502
+ # Chunks may carry multiple newlines or trailing partial lines.
503
+ # Re-split on \n so the fullscreen view renders one logical line per row.
504
+ lines.each do |chunk|
505
+ next if chunk.nil? || chunk.empty?
506
+ chunk.to_s.split("\n", -1).each_with_index do |part, idx|
507
+ if idx == 0 && !@stdout_lines.empty? && @stdout_partial_tail
508
+ @stdout_lines[-1] = @stdout_lines[-1] + part
509
+ else
510
+ @stdout_lines << part
511
+ end
512
+ end
513
+ # Track whether the chunk ended on a partial line (no trailing \n)
514
+ # so the next chunk's first segment appends to it instead of
515
+ # starting a new row.
516
+ @stdout_partial_tail = !chunk.to_s.end_with?("\n")
517
+ end
501
518
  end
502
519
 
503
520
  # Show completion status (only for tasks with more than 5 iterations)
@@ -1354,6 +1371,7 @@ module Clacky
1354
1371
  # Also clear stdout buffer used by Ctrl+O (unrelated to progress, but
1355
1372
  # we don't want stale command output carried across user turns).
1356
1373
  @stdout_lines = nil
1374
+ @stdout_partial_tail = false
1357
1375
 
1358
1376
  # Render user message immediately before running agent
1359
1377
  unless data[:text].empty? && data[:files].empty?