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.
@@ -368,6 +368,8 @@ module Clacky
368
368
  90
369
369
  elsif path == "/api/tool/browser"
370
370
  30
371
+ elsif path.end_with?("/benchmark")
372
+ 20
371
373
  else
372
374
  10
373
375
  end
@@ -1798,6 +1800,7 @@ module Clacky
1798
1800
 
1799
1801
  data["mcpServers"][name] = spec
1800
1802
  mcp_write_raw_config(data)
1803
+ @mcp_registry_mutex.synchronize { @mcp_registry&.reload }
1801
1804
  json_response(res, 200, { ok: true, name: name, config_path: mcp_config_path })
1802
1805
  end
1803
1806
 
@@ -1821,6 +1824,7 @@ module Clacky
1821
1824
 
1822
1825
  data["mcpServers"][name] = spec
1823
1826
  mcp_write_raw_config(data)
1827
+ @mcp_registry_mutex.synchronize { @mcp_registry&.reload }
1824
1828
  json_response(res, 200, { ok: true, name: name })
1825
1829
  end
1826
1830
 
@@ -1836,6 +1840,7 @@ module Clacky
1836
1840
 
1837
1841
  data["mcpServers"].delete(name)
1838
1842
  mcp_write_raw_config(data)
1843
+ @mcp_registry_mutex.synchronize { @mcp_registry&.reload }
1839
1844
  json_response(res, 200, { ok: true, name: name })
1840
1845
  end
1841
1846
 
@@ -3619,8 +3624,12 @@ module Clacky
3619
3624
  end
3620
3625
 
3621
3626
  results = threads.map do |t|
3622
- t.join(per_model_timeout + 3)
3623
- t.value rescue { ok: false, error: "thread failed" }
3627
+ if t.join(per_model_timeout + 3)
3628
+ t.value rescue { ok: false, error: "thread failed" }
3629
+ else
3630
+ t.kill
3631
+ { ok: false, error: "Request timed out" }
3632
+ end
3624
3633
  end
3625
3634
 
3626
3635
  json_response(res, 200, { ok: true, results: results })
@@ -3641,7 +3650,8 @@ module Clacky
3641
3650
  model_cfg["api_key"].to_s,
3642
3651
  base_url: model_cfg["base_url"].to_s,
3643
3652
  model: model_name,
3644
- anthropic_format: model_cfg["anthropic_format"] || false
3653
+ anthropic_format: model_cfg["anthropic_format"] || false,
3654
+ read_timeout: timeout_sec
3645
3655
  )
3646
3656
 
3647
3657
  # Override Faraday timeouts via a short-lived env var isn't ideal;
@@ -242,7 +242,7 @@ module Clacky
242
242
  { action: "tabs", success: true, profile: "user", output: format_tabs(pages), tabs: pages }
243
243
 
244
244
  when "snapshot"
245
- raw = mcp_call("take_snapshot")
245
+ raw = mcp_call("take_snapshot", with_page({}))
246
246
  text = build_ai_snapshot(extract_snapshot(raw),
247
247
  interactive: opts[:interactive] || opts["interactive"],
248
248
  compact: opts[:compact] || opts["compact"],
@@ -352,7 +352,7 @@ module Clacky
352
352
  sleep(ms.to_i / 1000.0)
353
353
  return { action: "act", success: true, profile: "user", output: "Waited #{ms}ms" }
354
354
  elsif sel
355
- mcp_call("wait_for", { text: [sel] })
355
+ mcp_call("wait_for", with_page({ text: [sel] }))
356
356
  else
357
357
  sleep(1)
358
358
  end
@@ -409,7 +409,7 @@ module Clacky
409
409
 
410
410
  call_args = { format: "png", fullPage: full_page }
411
411
  call_args[:uid] = uid if uid
412
- result = mcp_call("take_screenshot", call_args)
412
+ result = mcp_call("take_screenshot", with_page(call_args))
413
413
 
414
414
  image_block = Array(result["content"]).find { |b| b.is_a?(Hash) && b["type"] == "image" }
415
415
 
@@ -199,7 +199,7 @@ module Clacky
199
199
  # ---------------------------------------------------------------------
200
200
  def execute(command: nil, session_id: nil, input: nil, background: false,
201
201
  cwd: nil, env: nil, timeout: nil, kill: nil, idle_ms: nil,
202
- working_dir: nil, **_ignored)
202
+ working_dir: nil, on_output: nil, **_ignored)
203
203
  # Auto-tune: if the caller didn't explicitly set a timeout/idle_ms
204
204
  # AND the command is a well-known long-runner (rspec, bundle install,
205
205
  # cargo build, etc.), we stretch the budget AND disable idle-return.
@@ -226,7 +226,7 @@ module Clacky
226
226
  # Continue / poll a running session
227
227
  if session_id
228
228
  return { error: "input is required when session_id is given" } if input.nil?
229
- return do_continue(session_id.to_i, input.to_s, timeout: timeout, idle_ms: idle_ms)
229
+ return do_continue(session_id.to_i, input.to_s, timeout: timeout, idle_ms: idle_ms, on_output: on_output)
230
230
  end
231
231
 
232
232
  # Start a new command
@@ -243,7 +243,8 @@ module Clacky
243
243
  end
244
244
 
245
245
  return do_start(command.to_s, cwd: cwd, env: env, timeout: timeout,
246
- idle_ms: idle_ms, background: background ? true : false)
246
+ idle_ms: idle_ms, background: background ? true : false,
247
+ on_output: on_output)
247
248
  end
248
249
 
249
250
  { error: "terminal: must provide either `command`, or `session_id`+`input`, or `session_id`+`kill: true`." }
@@ -327,7 +328,7 @@ module Clacky
327
328
  # ---------------------------------------------------------------------
328
329
  # 1) Start a new command
329
330
  # ---------------------------------------------------------------------
330
- private def do_start(command, cwd:, env:, timeout:, background:, idle_ms: DEFAULT_IDLE_MS)
331
+ private def do_start(command, cwd:, env:, timeout:, background:, idle_ms: DEFAULT_IDLE_MS, on_output: nil)
331
332
  if cwd && !Dir.exist?(cwd.to_s)
332
333
  return { error: "cwd does not exist: #{cwd}" }
333
334
  end
@@ -345,6 +346,10 @@ module Clacky
345
346
  # that doesn't already have an explicit stdin redirect.
346
347
  safe_command = redirect_exe_stdin(safe_command)
347
348
 
349
+ # PowerShell 5 on Chinese Windows emits CP936/GBK by default; force
350
+ # UTF-8 so our PTY (which decodes as UTF-8) doesn't see ??? bytes.
351
+ safe_command = force_powershell_utf8(safe_command)
352
+
348
353
  # Background / dedicated path — never reuse the persistent shell,
349
354
  # because these commands stay running and would occupy the slot.
350
355
  if background
@@ -363,7 +368,8 @@ module Clacky
363
368
  background: true,
364
369
  persistent: false,
365
370
  original_command: command,
366
- rewritten_command: safe_command
371
+ rewritten_command: safe_command,
372
+ on_output: on_output
367
373
  )
368
374
  end
369
375
 
@@ -388,14 +394,15 @@ module Clacky
388
394
  idle_ms: idle_ms,
389
395
  persistent: persistent,
390
396
  original_command: command,
391
- rewritten_command: safe_command
397
+ rewritten_command: safe_command,
398
+ on_output: on_output
392
399
  )
393
400
  end
394
401
 
395
402
  # ---------------------------------------------------------------------
396
403
  # 2) Continue / poll an existing session
397
404
  # ---------------------------------------------------------------------
398
- private def do_continue(session_id, input, timeout:, idle_ms: DEFAULT_IDLE_MS)
405
+ private def do_continue(session_id, input, timeout:, idle_ms: DEFAULT_IDLE_MS, on_output: nil)
399
406
  session = SessionManager.refresh(session_id)
400
407
  return { error: "Session ##{session_id} not found (already finished or killed)." } unless session
401
408
 
@@ -406,7 +413,7 @@ module Clacky
406
413
 
407
414
  session.mutex.synchronize { session.writer.write(normalize_input_for_pty(input.to_s)) } unless input.to_s.empty?
408
415
 
409
- wait_and_package(session, timeout: timeout, idle_ms: idle_ms)
416
+ wait_and_package(session, timeout: timeout, idle_ms: idle_ms, on_output: on_output)
410
417
  end
411
418
 
412
419
  # `\n` is a Unix newline, not the "Enter key". Inside cooked-mode PTYs
@@ -455,10 +462,11 @@ module Clacky
455
462
  # :timeout | session_id, state=timeout | session_id, state=background
456
463
  private def wait_and_package(session, timeout:, idle_ms: DEFAULT_IDLE_MS,
457
464
  background: false, persistent: false,
458
- original_command: nil, rewritten_command: nil)
465
+ original_command: nil, rewritten_command: nil,
466
+ on_output: nil)
459
467
  start_offset = session.read_offset
460
468
 
461
- _before, code, state = read_until_marker(session, timeout: timeout, idle_ms: idle_ms)
469
+ _before, code, state = read_until_marker(session, timeout: timeout, idle_ms: idle_ms, on_output: on_output)
462
470
 
463
471
  new_offset = log_size(session)
464
472
  raw = read_log_slice(session.log_file, start_offset, new_offset)
@@ -1066,10 +1074,16 @@ module Clacky
1066
1074
  # whole persistent shell.
1067
1075
  def source_rc_in_session(session, rc_files)
1068
1076
  return if rc_files.empty?
1069
- cmd = rc_files.map { |f|
1077
+ sources = rc_files.map { |f|
1070
1078
  escaped = f.gsub('"', '\"')
1071
1079
  "source \"#{escaped}\" || true"
1072
1080
  }.join("; ")
1081
+ # rc files often gate interactive-only setup (mise activate, direnv
1082
+ # hook, nvm, pyenv, oh-my-zsh) on `[ -z "$PS1" ]` / `[[ -o interactive ]]`.
1083
+ # We normally keep PS1="" to suppress prompt noise in captured output,
1084
+ # but that makes those gates fail when we re-source rc here. Set a
1085
+ # placeholder PS1 just for the duration of the source, then restore "".
1086
+ cmd = %Q{__clacky_old_ps1="$PS1"; PS1="__CLACKY_PS1__"; #{sources}; PS1="$__clacky_old_ps1"; unset __clacky_old_ps1}
1073
1087
  run_inline(session, cmd, timeout: 15)
1074
1088
  end
1075
1089
 
@@ -1122,7 +1136,12 @@ module Clacky
1122
1136
  # Poll the log file until a marker matches, idle-return fires, or timeout.
1123
1137
  # Returns [raw_before_marker, exit_code_or_nil, state].
1124
1138
  # state ∈ :matched, :idle, :timeout, :eof
1125
- private def read_until_marker(session, timeout:, idle_ms: DEFAULT_IDLE_MS)
1139
+ #
1140
+ # `on_output` (optional Proc): called as on_output.call(chunk_string) for
1141
+ # each new piece of output as it arrives, BEFORE the marker is detected.
1142
+ # The chunk has ANSI codes / wrapper echoes stripped so it's safe to
1143
+ # render in a UI. The completion marker itself is never passed through.
1144
+ private def read_until_marker(session, timeout:, idle_ms: DEFAULT_IDLE_MS, on_output: nil)
1126
1145
  return ["", nil, :eof] unless session.marker_regex
1127
1146
 
1128
1147
  deadline = Time.now + timeout
@@ -1130,14 +1149,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
- return ["", nil, :timeout] if Time.now >= deadline
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
- "#{command} </dev/null"
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
- @stdout_lines.concat(lines.map(&:chomp))
502
+ # Chunks may carry multiple newlines or trailing partial lines.
503
+ # Re-split on \n so the fullscreen view renders one logical line per row.
504
+ lines.each do |chunk|
505
+ next if chunk.nil? || chunk.empty?
506
+ chunk.to_s.split("\n", -1).each_with_index do |part, idx|
507
+ if idx == 0 && !@stdout_lines.empty? && @stdout_partial_tail
508
+ @stdout_lines[-1] = @stdout_lines[-1] + part
509
+ else
510
+ @stdout_lines << part
511
+ end
512
+ end
513
+ # Track whether the chunk ended on a partial line (no trailing \n)
514
+ # so the next chunk's first segment appends to it instead of
515
+ # starting a new row.
516
+ @stdout_partial_tail = !chunk.to_s.end_with?("\n")
517
+ end
501
518
  end
502
519
 
503
520
  # Show completion status (only for tasks with more than 5 iterations)
@@ -1354,6 +1371,7 @@ module Clacky
1354
1371
  # Also clear stdout buffer used by Ctrl+O (unrelated to progress, but
1355
1372
  # we don't want stale command output carried across user turns).
1356
1373
  @stdout_lines = nil
1374
+ @stdout_partial_tail = false
1357
1375
 
1358
1376
  # Render user message immediately before running agent
1359
1377
  unless data[:text].empty? && data[:files].empty?
@@ -51,7 +51,9 @@ module Clacky
51
51
 
52
52
  # { rc_sources; } 1>&2 — send rc-time stdout to stderr so target's
53
53
  # stdout is pristine. `exec` replaces the shell with target.
54
- script = "{ #{rc_sources}; } 1>&2; exec #{command}"
54
+ # PS1 trick: Ubuntu .bashrc has `[ -z "$PS1" ] && return` guard that
55
+ # skips the entire file in non-interactive shells. Setting PS1 defeats it.
56
+ script = "PS1='$ '; { #{rc_sources}; } 1>&2; exec #{command}"
55
57
  [shell, "-c", script]
56
58
  end
57
59
 
@@ -21,7 +21,6 @@ module Clacky
21
21
  install_browser.sh
22
22
  install_system_deps.sh
23
23
  install_rails_deps.sh
24
- wsl_network_doctor.ps1
25
24
  ].freeze
26
25
 
27
26
  # Copy bundled scripts to ~/.clacky/scripts/ if missing or outdated.
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Clacky
4
- VERSION = "1.2.3"
4
+ VERSION = "1.2.5"
5
5
  end
@@ -3649,6 +3649,7 @@ body {
3649
3649
  .model-card-grid-actions {
3650
3650
  display: flex;
3651
3651
  flex-direction: row;
3652
+ flex-wrap: wrap;
3652
3653
  gap: 0.5rem;
3653
3654
  padding-top: 0.625rem;
3654
3655
  border-top: 1px solid var(--color-border-primary);
@@ -169,6 +169,11 @@ const Settings = (() => {
169
169
  providerValue.classList.add("placeholder");
170
170
  }
171
171
 
172
+ // Reset save button
173
+ const saveBtn = document.getElementById("model-modal-save");
174
+ saveBtn.textContent = I18n.t("settings.models.btn.save");
175
+ saveBtn.disabled = false;
176
+
172
177
  // Clear test result
173
178
  document.getElementById("model-modal-test-result").textContent = "";
174
179
  document.getElementById("model-modal-test-result").className = "model-test-result";
@@ -140,10 +140,11 @@
140
140
  <script>
141
141
  const params = new URLSearchParams(location.search);
142
142
  const url = params.get("url");
143
- // since: Unix timestamp (seconds) passed by the setup skill when opening this page.
144
- // We only show success if token_updated_at > since, preventing false positives
145
- // when the user already had a token from a previous login.
146
- const since = parseInt(params.get("since") || "0", 10);
143
+ // since: the moment this page loaded, in Unix seconds.
144
+ // We only show success if token_updated_at >= since, so a pre-existing token
145
+ // never triggers the overlay.
146
+ // Intentionally NOT taken from the URL param — the page is the source of truth.
147
+ const since = Math.floor(Date.now() / 1000);
147
148
  const el = document.getElementById("qrcode");
148
149
 
149
150
  if (!url) {
data/scripts/install.ps1 CHANGED
@@ -11,8 +11,8 @@
11
11
  # -BrandName Display name shown in prompts (default: OpenClacky)
12
12
  # -CommandName CLI command name after install (default: openclacky)
13
13
  #
14
- # WSL2 is preferred. If virtualisation is unavailable (e.g. running inside a VM),
15
- # the script automatically falls back to WSL1.
14
+ # WSL1 is preferred (shares Windows network stack no mirrored networking needed).
15
+ # If WSL1 import fails, the script falls back to WSL2 with mirrored networking.
16
16
  # If WSL is not installed at all, the script enables it and asks you to reboot.
17
17
  # After rebooting, run the same command again to complete installation.
18
18
  #
@@ -501,32 +501,59 @@ if ($installPhase -eq "wsl-pending" -and $wslCode -eq 1) {
501
501
  # wslCode != 1 (0, -1, -444, 50, etc.): WSL is functional, continue.
502
502
  Remove-InstallReg -Name "InstallPhase"
503
503
 
504
- # Step 2: Install Ubuntu, preferring WSL2 when the real rootfs imports cleanly.
504
+ # Step 2: Install Ubuntu, preferring WSL1 (shares Windows network no mirrored needed).
505
+ # If WSL1 import fails, fall back to WSL2.
506
+ # If the distro already exists, keep whatever version was previously installed.
505
507
  if (Test-UbuntuInstalled) {
506
508
  Write-Info "Ubuntu (WSL) already installed — skipping import."
507
509
  $wslVersion = Get-InstallReg -Name "WslVersion" -Default 2
508
510
  } else {
509
511
  $tarPath = Get-UbuntuRootfs
510
- if (Test-VirtualisationSupported -TarPath $tarPath) {
511
- wsl.exe --set-default-version 2 >$null 2>$null
512
- Install-UbuntuRootfs -WslVersion 2 -TarPath $tarPath
513
- $wslVersion = 2
512
+
513
+ # Try WSL1 first
514
+ Write-Info "Attempting WSL1 import..."
515
+ $wsl1Ok = $false
516
+ try {
517
+ New-Item -ItemType Directory -Force -Path $UBUNTU_WSL_DIR | Out-Null
518
+ wsl.exe --import Ubuntu $UBUNTU_WSL_DIR $tarPath --version 1 >$null 2>$null
519
+ $wsl1Ok = ($LASTEXITCODE -eq 0)
520
+ } catch {
521
+ $wsl1Ok = $false
522
+ }
523
+
524
+ if ($wsl1Ok) {
525
+ Write-Success "Ubuntu (WSL1) imported successfully."
526
+ $wslVersion = 1
514
527
  } else {
515
- if ($wslFeaturesEnabled -ne "1") {
516
- # WSL components were never fully prepared — run Enable-WslFeatures and reboot.
517
- Write-Warn "WSL2 is not available and WSL components have not been fully set up."
518
- Enable-WslFeatures
519
- # Always exits (prompts reboot)
528
+ # Clean up failed WSL1 attempt
529
+ wsl.exe --unregister Ubuntu 2>$null | Out-Null
530
+ Remove-Item -Force -Recurse -ErrorAction SilentlyContinue $UBUNTU_WSL_DIR
531
+
532
+ Write-Info "WSL1 import failed, trying WSL2..."
533
+ if (Test-VirtualisationSupported -TarPath $tarPath) {
534
+ wsl.exe --set-default-version 2 >$null 2>$null
535
+ Install-UbuntuRootfs -WslVersion 2 -TarPath $tarPath
536
+ $wslVersion = 2
537
+ } else {
538
+ if ($wslFeaturesEnabled -ne "1") {
539
+ Write-Warn "Neither WSL1 nor WSL2 is available. Enabling WSL components..."
540
+ Enable-WslFeatures
541
+ # Always exits (prompts reboot)
542
+ }
543
+ Write-Fail "Failed to import Ubuntu into both WSL1 and WSL2."
544
+ Write-Fail "Please ensure Windows Subsystem for Linux is enabled and try again."
545
+ exit 1
520
546
  }
521
- Write-Info "[main] WSL2 unavailable, falling back to WSL1..."
522
- Install-UbuntuRootfs -WslVersion 1 -TarPath $tarPath
523
- $wslVersion = 1
524
547
  }
525
548
  }
526
549
 
527
- if ($wslVersion -eq 2) { Set-Wsl2MirroredNetworking }
528
-
529
550
  Write-Success "WSL is ready."
530
551
  Run-InstallInWsl
552
+
553
+ # For WSL2, configure mirrored networking AFTER install.sh succeeds (NAT is more
554
+ # reliable for outbound traffic during installation). The shutdown here is safe
555
+ # because installation is already complete.
556
+ if ($wslVersion -eq 2) { Set-Wsl2MirroredNetworking }
557
+
531
558
  Set-InstallReg -Name "WslVersion" -Value $wslVersion
532
559
  Show-PostInstall -WslVersion $wslVersion
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: openclacky
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.2.3
4
+ version: 1.2.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: