openclacky 1.5.20 → 1.5.21

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 (72) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +49 -0
  3. data/README.md +18 -0
  4. data/lib/clacky/agent/chunk_index.rb +8 -1
  5. data/lib/clacky/agent/feedback.rb +30 -0
  6. data/lib/clacky/agent/history_navigation.rb +17 -3
  7. data/lib/clacky/agent/hook_manager.rb +6 -1
  8. data/lib/clacky/agent/llm_caller.rb +1 -0
  9. data/lib/clacky/agent/message_compressor_helper.rb +28 -2
  10. data/lib/clacky/agent/session_serializer.rb +20 -2
  11. data/lib/clacky/agent.rb +56 -43
  12. data/lib/clacky/agent_config.rb +82 -4
  13. data/lib/clacky/billing/platform_billing.rb +5 -1
  14. data/lib/clacky/brand_config.rb +1 -1
  15. data/lib/clacky/cli.rb +3 -2
  16. data/lib/clacky/client.rb +5 -5
  17. data/lib/clacky/deepseek_web_tools.rb +156 -0
  18. data/lib/clacky/default_extensions/ext-studio/api/handler.rb +8 -0
  19. data/lib/clacky/default_extensions/ext-studio/panels/studio/view.js +21 -1
  20. data/lib/clacky/extension/loader.rb +5 -3
  21. data/lib/clacky/locales/en.rb +5 -5
  22. data/lib/clacky/locales/zh.rb +5 -5
  23. data/lib/clacky/message_format/anthropic.rb +20 -0
  24. data/lib/clacky/message_format/open_ai.rb +31 -14
  25. data/lib/clacky/message_history.rb +21 -3
  26. data/lib/clacky/platform_http_client.rb +6 -2
  27. data/lib/clacky/providers.rb +23 -14
  28. data/lib/clacky/search_config.rb +47 -13
  29. data/lib/clacky/server/browser_manager.rb +35 -5
  30. data/lib/clacky/server/channel/channel_manager.rb +6 -6
  31. data/lib/clacky/server/discover.rb +5 -4
  32. data/lib/clacky/server/http_server.rb +194 -47
  33. data/lib/clacky/server/model_prices.rb +5 -3
  34. data/lib/clacky/server/pid_files.rb +25 -0
  35. data/lib/clacky/server/server_master.rb +9 -3
  36. data/lib/clacky/server/session_registry.rb +19 -7
  37. data/lib/clacky/server/web_ui_controller.rb +61 -38
  38. data/lib/clacky/session_manager.rb +7 -1
  39. data/lib/clacky/skill.rb +1 -1
  40. data/lib/clacky/tools/ask_user.rb +4 -1
  41. data/lib/clacky/tools/base.rb +3 -2
  42. data/lib/clacky/tools/browser.rb +18 -2
  43. data/lib/clacky/tools/file_reader.rb +1 -1
  44. data/lib/clacky/tools/terminal/session_manager.rb +19 -1
  45. data/lib/clacky/tools/terminal.rb +65 -23
  46. data/lib/clacky/tools/trash_manager.rb +5 -3
  47. data/lib/clacky/tools/web_fetch.rb +81 -22
  48. data/lib/clacky/tools/web_search.rb +45 -4
  49. data/lib/clacky/ui2/ui_controller.rb +1 -1
  50. data/lib/clacky/ui_interface.rb +4 -3
  51. data/lib/clacky/utils/file_processor.rb +51 -0
  52. data/lib/clacky/utils/model_pricing.rb +132 -13
  53. data/lib/clacky/version.rb +1 -1
  54. data/lib/clacky/web/app.css +366 -105
  55. data/lib/clacky/web/components/chat-navigator.js +8 -1
  56. data/lib/clacky/web/components/mentions.js +2 -13
  57. data/lib/clacky/web/components/model-picker.js +3 -1
  58. data/lib/clacky/web/components/onboard.js +13 -10
  59. data/lib/clacky/web/features/archive/store.js +30 -16
  60. data/lib/clacky/web/features/archive/view.js +20 -10
  61. data/lib/clacky/web/features/backup/view.js +5 -5
  62. data/lib/clacky/web/features/new-session/store.js +3 -3
  63. data/lib/clacky/web/features/trash/store.js +10 -7
  64. data/lib/clacky/web/features/trash/view.js +9 -4
  65. data/lib/clacky/web/i18n.js +106 -30
  66. data/lib/clacky/web/index.html +90 -60
  67. data/lib/clacky/web/projects.js +56 -1
  68. data/lib/clacky/web/sessions.js +244 -131
  69. data/lib/clacky/web/settings.js +143 -8
  70. data/lib/clacky/web/ws-dispatcher.js +9 -13
  71. data/lib/clacky.rb +1 -0
  72. metadata +4 -1
@@ -29,7 +29,6 @@ module Clacky
29
29
 
30
30
  # Pending confirmation state: { id => ConditionVariable, result => value }
31
31
  @pending_confirmations = {}
32
- @pending_feedback = nil
33
32
 
34
33
  # Channel subscribers: array of objects implementing UIInterface.
35
34
  # All emitted events are forwarded to each subscriber after WebSocket broadcast.
@@ -69,49 +68,68 @@ module Clacky
69
68
  end
70
69
  end
71
70
 
72
- def deliver_feedback(answer, **options)
71
+ # Use the same contract as the CLI. The server owns the deadline;
72
+ # typing cancels automatic continuation but keeps the answer wait alive.
73
+ def request_feedback_with_countdown(seconds: 20)
74
+ pending = { id: @feedback_request_id || SecureRandom.uuid, cond: ConditionVariable.new,
75
+ deadline_ms: (Time.now.to_f * 1000).to_i + seconds * 1000 }
76
+ pending[:seconds] = seconds
77
+ pending[:deadline] = Process.clock_gettime(Process::CLOCK_MONOTONIC) + seconds
73
78
  @mutex.synchronize do
74
- pending = @pending_feedback
75
- return false unless pending && !answer.strip.empty?
76
- pending[:answer], pending[:resolution], pending[:options] = answer, "human", options
77
- pending[:cond].signal
78
- true
79
- end
80
- end
81
-
82
- # The caller supplies a timeout for this wait; nil keeps manual feedback.
83
- def wait_for_feedback(timeout_seconds:, question: nil, recommended_label: nil)
84
- unless recommended_label && !timeout_seconds.nil?
85
- yield
86
- return
79
+ pending[:manual] = @feedback_permission_mode == :confirm_all
80
+ @pending_feedback = pending
87
81
  end
88
- raise ArgumentError, "Invalid feedback timeout" unless timeout_seconds.is_a?(Integer) && timeout_seconds >= 0
89
-
90
- pending = { id: SecureRandom.uuid, cond: ConditionVariable.new }
91
- countdown = { request_id: pending[:id], question: question, recommended_label: recommended_label,
92
- deadline_ms: (Time.now.to_f * 1000).to_i + timeout_seconds * 1000,
93
- duration_seconds: timeout_seconds }
94
- deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout_seconds
95
- pending[:countdown] = countdown
96
- @mutex.synchronize { @pending_feedback = pending }
97
- yield
98
- emit("feedback_countdown_started", **countdown)
82
+ emit("feedback_countdown", request_id: pending[:id], deadline_ms: pending[:manual] ? nil : pending[:deadline_ms])
99
83
  @mutex.synchronize do
100
- until pending[:answer]
101
- remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
102
- if remaining <= 0
103
- pending.merge!(answer: recommended_label, resolution: "timeout")
84
+ until pending[:result]
85
+ remaining = pending[:deadline] - Process.clock_gettime(Process::CLOCK_MONOTONIC)
86
+ if !pending[:manual] && remaining <= 0
87
+ pending[:result] = :timeout
104
88
  else
105
- pending[:cond].wait(@mutex, remaining)
89
+ pending[:cond].wait(@mutex, pending[:manual] ? nil : remaining)
106
90
  end
107
91
  end
108
- [pending[:answer], pending.fetch(:options, {})]
92
+ pending[:result]
109
93
  end
110
94
  ensure
111
- if pending
112
- @mutex.synchronize { @pending_feedback = nil }
113
- emit("feedback_countdown_resolved", request_id: pending[:id],
114
- resolution: pending[:resolution] || "cancelled", answer: pending[:answer])
95
+ cleared = @mutex.synchronize do
96
+ if pending && @pending_feedback.equal?(pending)
97
+ @pending_feedback = nil
98
+ true
99
+ end
100
+ end
101
+ if cleared
102
+ emit("feedback_countdown", request_id: pending[:id], resolved: true,
103
+ resolution: pending[:result] || :cancelled)
104
+ end
105
+ end
106
+
107
+ def update_permission_mode(mode)
108
+ countdown = @mutex.synchronize do
109
+ @feedback_permission_mode = mode.to_sym
110
+ pending = @pending_feedback
111
+ if pending && !pending[:result]
112
+ if mode.to_sym == :auto_approve && pending[:manual]
113
+ pending[:deadline] = Process.clock_gettime(Process::CLOCK_MONOTONIC) + pending[:seconds]
114
+ pending[:deadline_ms] = (Time.now.to_f * 1000).to_i + pending[:seconds] * 1000
115
+ end
116
+ pending[:manual] = mode.to_sym != :auto_approve
117
+ pending[:cond].signal
118
+ { request_id: pending[:id],
119
+ deadline_ms: pending[:manual] ? nil : pending[:deadline_ms] }
120
+ end
121
+ end
122
+ emit("feedback_countdown", **countdown) if countdown
123
+ end
124
+
125
+ def intervene_feedback(request_id)
126
+ @mutex.synchronize do
127
+ pending = @pending_feedback
128
+ return false unless pending && pending[:id] == request_id && !pending[:result]
129
+ pending[:result] = :intervened
130
+ pending[:cond].signal
131
+ emit("feedback_countdown", request_id: pending[:id], deadline_ms: nil)
132
+ true
115
133
  end
116
134
  end
117
135
 
@@ -176,7 +194,9 @@ module Clacky
176
194
  context = args_data.is_a?(Hash) ? (args_data[:context] || args_data["context"]).to_s : ""
177
195
  first = questions.first || {}
178
196
 
197
+ @feedback_request_id = Clacky::Tools::AskUser.fetch_key(args_data, :request_id)
179
198
  emit("request_feedback",
199
+ request_id: @feedback_request_id,
180
200
  question: first[:question].to_s,
181
201
  context: context,
182
202
  options: first[:options] || [],
@@ -383,8 +403,11 @@ module Clacky
383
403
  # The frontend's appendToolStdout will attach to the last visible .tool-item
384
404
  # even when _liveLastToolItem is null (after the tab re-loaded).
385
405
  def replay_live_state
386
- countdown = @mutex.synchronize { @pending_feedback&.dig(:countdown) }
387
- emit("feedback_countdown_started", **countdown) if countdown
406
+ countdown = @mutex.synchronize do
407
+ pending = @pending_feedback
408
+ pending ? { request_id: pending[:id], deadline_ms: pending[:manual] ? nil : pending[:deadline_ms] } : { resolved: true }
409
+ end
410
+ emit("feedback_countdown", **countdown)
388
411
  return unless @live_progress_state
389
412
 
390
413
  # Replay complete progress state (not just message).
@@ -216,7 +216,7 @@ module Clacky
216
216
  def all_sessions(current_dir: nil, limit: nil)
217
217
  sessions = Dir.glob(File.join(@sessions_dir, "*.json")).filter_map do |filepath|
218
218
  load_session_file(filepath)
219
- end.sort_by { |s| s[:updated_at] || s[:created_at] || "" }.reverse
219
+ end.sort_by { |s| [s[:updated_at] || s[:created_at] || "", s[:session_id].to_s] }.reverse
220
220
 
221
221
  if current_dir
222
222
  current_sessions = sessions.select { |s| s[:working_dir] == current_dir }
@@ -436,6 +436,12 @@ module Clacky
436
436
  Clacky::Tools::TrashManager.permanent_delete_trash_session(session_id, sessions_dir: @sessions_dir)
437
437
  end
438
438
 
439
+ # Permanently delete exactly these sessions from the trash. Returns the count deleted.
440
+ def permanent_delete_trash_sessions(session_ids)
441
+ require_relative "tools/trash_manager"
442
+ Clacky::Tools::TrashManager.empty_trash_sessions(sessions_dir: @sessions_dir, ids: session_ids)
443
+ end
444
+
439
445
  # Keep file-trash on the same rolling window as sessions-trash.
440
446
  def cleanup_file_trash(days: 8)
441
447
  require_relative "tools/trash_manager"
data/lib/clacky/skill.rb CHANGED
@@ -319,7 +319,7 @@ module Clacky
319
319
  > should either:
320
320
  >
321
321
  > 1. Ask the user to start the server in another terminal: `clacky server`
322
- > (then retry — the CLI auto-detects it via `/tmp/clacky-master-*.pid`), or
322
+ > (then retry — the CLI auto-detects it via `clacky-master-*.pid` in the configured PID directory), or
323
323
  > 2. If the task can be completed without the server API, skip those steps
324
324
  > and tell the user which parts require the server.
325
325
  >
@@ -110,7 +110,7 @@ module Clacky
110
110
  recommended = recommended.to_i if recommended.is_a?(Numeric) || recommended.to_s =~ /\A-?\d+\z/
111
111
  recommended = nil unless recommended.is_a?(Integer) && recommended >= 0 && recommended < options.size
112
112
 
113
- {
113
+ normalized = {
114
114
  question: text,
115
115
  description: fetch_key(item, :description).to_s.strip,
116
116
  options: options,
@@ -119,6 +119,9 @@ module Clacky
119
119
  allow_free_text: options.empty? || truthy?(fetch_key(item, :allow_free_text)),
120
120
  recommended: recommended
121
121
  }
122
+ metadata = fetch_key(item, :metadata)
123
+ normalized[:metadata] = metadata if metadata.is_a?(Hash)
124
+ normalized
122
125
  end
123
126
 
124
127
  def self.fetch_key(hash, key)
@@ -14,8 +14,9 @@ module Clacky
14
14
  attr_accessor :receives_tool_call_id
15
15
  end
16
16
 
17
- # Host agent, injected at registration for extension tools so they can
18
- # reach its public API (fan_out_labeled, fork_subagent, skill_loader).
17
+ # Host agent, injected at registration for extension tools and for built-ins
18
+ # that need its public API (config, fan_out_labeled, fork_subagent,
19
+ # skill_loader).
19
20
  attr_accessor :agent
20
21
 
21
22
  def name
@@ -543,12 +543,21 @@ module Clacky
543
543
  # Save original (full-resolution) PNG to disk before any downscaling
544
544
  original_path = save_screenshot_to_disk(image_block["data"], suffix: "original")
545
545
 
546
+ config = screenshot_config
547
+ limit = config["screenshot_max_base64_bytes"]
548
+ max_base64_bytes = limit.is_a?(Integer) && limit.positive? ? limit : SCREENSHOT_MAX_BASE64_BYTES
546
549
  image_data = png_downscale_base64(image_block["data"], SCREENSHOT_MAX_WIDTH)
550
+ if image_data.bytesize > max_base64_bytes
551
+ image_data = Clacky::Utils::FileProcessor.quantize_png_base64(image_data)
552
+ end
547
553
 
548
- if image_data.bytesize > SCREENSHOT_MAX_BASE64_BYTES
554
+ if image_data.bytesize > max_base64_bytes
549
555
  size_kb = image_data.bytesize / 1024
556
+ saved = original_path ? " The full-resolution file is saved at #{original_path}." : ""
550
557
  return { action: "screenshot", success: false, profile: "user",
551
- output: "Screenshot too large after resize (#{size_kb}KB). Use action=snapshot instead." }
558
+ original_path: original_path,
559
+ output: "Screenshot still too large after compression (#{size_kb}KB).#{saved} " \
560
+ "Use action=snapshot instead." }
552
561
  end
553
562
 
554
563
  # Save compressed (800px) PNG for AI reference
@@ -560,6 +569,13 @@ module Clacky
560
569
  output: "Screenshot captured." }
561
570
  end
562
571
 
572
+ private def screenshot_config
573
+ config = YAMLCompat.safe_load(File.read(BROWSER_CONFIG_PATH), permitted_classes: [Date, Time, Symbol])
574
+ config.is_a?(Hash) ? config : {}
575
+ rescue StandardError
576
+ {}
577
+ end
578
+
563
579
  private def png_downscale_base64(b64, max_width)
564
580
  Clacky::Utils::FileProcessor.downscale_image_base64(
565
581
  b64, "image/png", max_width: max_width
@@ -46,7 +46,7 @@ module Clacky
46
46
  MAX_CONTENT_CHARS = 60_000
47
47
 
48
48
  # Maximum characters per line (prevent single huge lines from bloating tokens)
49
- MAX_LINE_CHARS = 1000
49
+ MAX_LINE_CHARS = 8_000
50
50
 
51
51
  def execute(path:, max_lines: 1000, start_line: nil, end_line: nil, working_dir: nil, image_max_width: nil)
52
52
  # Expand path relative to working_dir when provided
@@ -43,7 +43,9 @@ module Clacky
43
43
  :read_offset, # Integer, bytes already returned by previous read calls
44
44
  :mutex, # per-session mutex for PTY writes
45
45
  :resource_group, # Optional cgroup for task limits and OOM attribution
46
+ :script_paths, # Internal script paths for cleanup and output masking
46
47
  :shell_name, # "zsh" | "bash" | "sh" — informs marker syntax & rc reload
48
+ :echo_head, # String, last `{ CMD` line written; strips its wrapped echo
47
49
  keyword_init: true
48
50
  )
49
51
 
@@ -77,6 +79,7 @@ module Clacky
77
79
  read_offset: 0,
78
80
  mutex: Mutex.new,
79
81
  resource_group: resource_group,
82
+ script_paths: [],
80
83
  shell_name: shell_name
81
84
  )
82
85
  @sessions[session.id] = session
@@ -116,7 +119,21 @@ module Clacky
116
119
  # Forget a session (after it has been killed/exited). Does NOT kill
117
120
  # the process — callers should kill first.
118
121
  def forget(id)
119
- @mutex.synchronize { @sessions.delete(id) }
122
+ session = @mutex.synchronize { @sessions.delete(id) }
123
+ cleanup_scripts(session) if session
124
+ end
125
+
126
+ # Cleanup must never replace the command's result with an unlink error.
127
+ def cleanup_scripts(session)
128
+ # The reader may clean up before buffered output is packaged.
129
+ # Preserve paths so error messages can still mask internal filenames.
130
+ Array(session.script_paths).each do |path|
131
+ begin
132
+ File.unlink(path)
133
+ rescue SystemCallError
134
+ # Best effort; retain the path for a later cleanup attempt.
135
+ end
136
+ end
120
137
  end
121
138
 
122
139
  # Refresh status of one session in-place (mutex-held).
@@ -188,6 +205,7 @@ module Clacky
188
205
  s.log_io&.close rescue nil
189
206
  s.writer&.close rescue nil
190
207
  s.reader&.close rescue nil
208
+ cleanup_scripts(s)
191
209
  end
192
210
  end
193
211
 
@@ -1,6 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "pty"
4
+ require "tempfile"
5
+ require "shellwords"
4
6
  require "securerandom"
5
7
  require "fileutils"
6
8
  require_relative "base"
@@ -73,9 +75,6 @@ module Clacky
73
75
  {session_id, input:"pw\n"} reply to prompt / poll (input:"")
74
76
  {session_id, kill:true} stop
75
77
 
76
- Single-line only. For multi-line scripts (heredoc, loops, multi-statement blocks)
77
- write a file first, then run it: write(path:"/tmp/run.sh", content:...) → terminal(command:"bash /tmp/run.sh").
78
-
79
78
  Response: exit_code = done; session_id = running (state: waiting/background/timeout).
80
79
  If output exceeds the limit, `output` is truncated and `full_output_file` points
81
80
  at a file on disk — use terminal(command: "grep ... <path>") to search it.
@@ -133,6 +132,7 @@ module Clacky
133
132
  BACKGROUND_COLLECT_SECONDS = 2
134
133
  # Sentinel: when passed as idle_ms, disables idle early-return.
135
134
  DISABLED_IDLE_MS = 10_000_000
135
+ PTY_COLS = 120
136
136
  # Gap between log-file polls while waiting for a command's end marker.
137
137
  MARKER_POLL_SECONDS = 0.05
138
138
  # A freshly spawned login shell prints its banner asynchronously; wait
@@ -244,17 +244,6 @@ module Clacky
244
244
 
245
245
  # Start a new command
246
246
  if command && !command.to_s.strip.empty?
247
- if multiline_command?(command)
248
- return {
249
- error: "Multi-line commands are unreliable in our PTY shell " \
250
- "(heredocs / unclosed quotes / multi-line blocks can hang the session).",
251
- hint: "Write the script to a file first, then execute it. " \
252
- "Example: 1) write(path: \"/tmp/run.sh\", content: \"...\") " \
253
- "2) terminal(command: \"bash /tmp/run.sh\")",
254
- multiline_blocked: true
255
- }
256
- end
257
-
258
247
  return do_start(command.to_s, cwd: cwd, env: env, timeout: timeout,
259
248
  idle_ms: idle_ms, background: background ? true : false,
260
249
  on_output: on_output)
@@ -402,6 +391,43 @@ module Clacky
402
391
  )
403
392
  end
404
393
 
394
+ SCRIPT_DIR = "/tmp/.clacky"
395
+ private def prepare_execution_command(session, command)
396
+ return command unless multiline_command?(command)
397
+
398
+ prepare_multiline_command(session, command)
399
+ end
400
+
401
+ # Keep script contents out of the interactive PTY input parser.
402
+ private def prepare_multiline_command(session, command)
403
+ session.script_paths.delete_if { |path| !File.exist?(path) }
404
+
405
+ script_dir = File.join(SCRIPT_DIR, session.id.to_s)
406
+ [SCRIPT_DIR, script_dir].each do |path|
407
+ FileUtils.mkdir_p(path, mode: 0700)
408
+ directory = File.lstat(path)
409
+ unless directory.directory? && directory.uid == Process.uid && (directory.mode & 0077).zero?
410
+ raise IOError, "#{path} must be a private directory owned by the current user"
411
+ end
412
+ end
413
+
414
+ file = Tempfile.create(["script-", ".sh"], script_dir)
415
+ session.script_paths << file.path
416
+ begin
417
+ file.write(command)
418
+ file.write("\n") unless command.end_with?("\n")
419
+ ensure
420
+ file.close
421
+ end
422
+ "( . #{Shellwords.escape(file.path)} )"
423
+ end
424
+
425
+ private def hide_script_paths(session, output)
426
+ Array(session.script_paths).reduce(output) do |text, path|
427
+ text.gsub(/#{Regexp.escape(path)}(?:: ?)?/, "")
428
+ end
429
+ end
430
+
405
431
  # ---------------------------------------------------------------------
406
432
  # 2) Continue / poll an existing session
407
433
  # ---------------------------------------------------------------------
@@ -475,11 +501,12 @@ module Clacky
475
501
  raw = read_log_slice(session.log_file, start_offset, new_offset)
476
502
  cleaned = OutputCleaner.clean(raw)
477
503
  cleaned = cleaned.sub(session.marker_regex, "").rstrip if session.marker_regex
478
- cleaned = strip_command_echo(cleaned, marker_token: session.marker_token)
504
+ cleaned = strip_command_echo(cleaned, marker_token: session.marker_token, echo_head: session.echo_head)
479
505
  # Per-line cap first: one minified JSON blob shouldn't blow the
480
506
  # whole 4 KB budget. MUST run before overflow spill so the file
481
507
  # on disk also has the long lines shortened (otherwise grep-ing
482
508
  # the spill file returns thousand-char lines the LLM chokes on).
509
+ cleaned = hide_script_paths(session, cleaned)
483
510
  cleaned = truncate_long_lines(cleaned)
484
511
  truncated = false
485
512
  overflow_file = nil
@@ -516,6 +543,7 @@ module Clacky
516
543
 
517
544
  case state
518
545
  when :matched, :eof
546
+ SessionManager.cleanup_scripts(session)
519
547
  exit_code = code || session.exit_code
520
548
  if persistent && state == :matched && session_healthy?(session)
521
549
  # Command finished cleanly — return the shell to the pool so
@@ -611,7 +639,7 @@ module Clacky
611
639
  # safe: the real completion marker was already removed via
612
640
  # session.marker_regex above, so any surviving occurrence of
613
641
  # __CLACKY_DONE_<token>_ is by definition an echoed wrapper.
614
- private def strip_command_echo(text, marker_token: nil)
642
+ private def strip_command_echo(text, marker_token: nil, echo_head: nil)
615
643
  return text if text.nil? || text.empty?
616
644
 
617
645
  # Pass 0: strip the hooks prefix echo if `stty -echo` failed and
@@ -641,6 +669,16 @@ module Clacky
641
669
  text = text.sub(/\A\{[^\n]*\n/, "")
642
670
  end
643
671
 
672
+ # Pass 1b: a wrapped `{ CMD` echo leaves its last screen row as a bare command suffix above the tail.
673
+ if echo_head && echo_head.bytesize > PTY_COLS
674
+ lines = text.lines
675
+ text = lines.reject.with_index do |ln, i|
676
+ body = ln.chomp
677
+ !body.empty? && body.size < echo_head.size && echo_head.end_with?(body) &&
678
+ lines[i + 1].to_s.match?(/\A\}[^\n;]*; *__clacky_ec=\$\?/)
679
+ end.join
680
+ end
681
+
644
682
  # Pass 2: token-aware global strip — remove any leftover wrapper
645
683
  # echo fragment, wherever it sits. Requires the session token so
646
684
  # we never touch unrelated user output that happens to mention
@@ -881,7 +919,7 @@ module Clacky
881
919
  end
882
920
 
883
921
  begin
884
- writer.winsize = [40, 120]
922
+ writer.winsize = [40, PTY_COLS]
885
923
  rescue StandardError
886
924
  # unsupported on some platforms
887
925
  end
@@ -1054,8 +1092,15 @@ module Clacky
1054
1092
  # non-WSL hosts (and on WSL when not invoking Windows binaries)
1055
1093
  # keep their PTY stdin and continue to behave as before.
1056
1094
  stdin_redirect = exe_needs_stdin_isolation?(command) ? " </dev/null" : ""
1095
+ # Inspect the original command before replacing multiline input with a script.
1096
+ # Internal shell setup must execute inline to update the session itself.
1097
+ command = prepare_execution_command(session, command) if with_hooks
1057
1098
  line = %Q|#{hooks_line}{ #{command}\n}#{stdin_redirect}; __clacky_ec=$?; printf "\n__CLACKY_DONE_#{token}_%s__\n" "$__clacky_ec"\n|
1099
+ session.echo_head = "{ #{command}".lines.last.chomp
1058
1100
  session.mutex.synchronize { session.writer.write(line) }
1101
+ rescue StandardError
1102
+ cleanup_session(session)
1103
+ raise
1059
1104
  end
1060
1105
 
1061
1106
  # True when the command should run with stdin redirected from
@@ -1277,7 +1322,7 @@ module Clacky
1277
1322
  cleaned = OutputCleaner.clean(buffered)
1278
1323
  # Strip any wrapper echo that still slipped through (e.g. when a
1279
1324
  # session is reused and ZLE re-echoes our wrapper mid-stream).
1280
- cleaned = strip_command_echo(cleaned, marker_token: session.marker_token)
1325
+ cleaned = strip_command_echo(cleaned, marker_token: session.marker_token, echo_head: session.echo_head)
1281
1326
  # Belt-and-braces: drop any line that still carries our internal
1282
1327
  # tokens.
1283
1328
  cleaned = cleaned.lines.reject do |ln|
@@ -1292,7 +1337,7 @@ module Clacky
1292
1337
  # doesn't produce a wall of whitespace in the streaming UI.
1293
1338
  cleaned = cleaned.gsub(/\n{3,}/, "\n\n")
1294
1339
  cleaned = cleaned.lstrip if cleaned.match?(/\A\n+\z/)
1295
- on_output.call(cleaned) unless cleaned.empty? || cleaned.match?(/\A\s*\z/)
1340
+ on_output.call(hide_script_paths(session, cleaned)) unless cleaned.empty? || cleaned.match?(/\A\s*\z/)
1296
1341
  rescue StandardError
1297
1342
  # Streaming is best-effort — never let a UI bug abort the command.
1298
1343
  end
@@ -1394,10 +1439,7 @@ module Clacky
1394
1439
 
1395
1440
  # True when `command` spans multiple lines. Trailing newlines are
1396
1441
  # ignored — a single-line command terminated with "\n" is still
1397
- # single-line. Multi-line commands frequently hang the persistent
1398
- # PTY shell (incomplete heredoc, unclosed quote, multi-line block
1399
- # without closer) — the agent should write a script file and
1400
- # invoke it instead.
1442
+ # single-line. Multi-line input is sourced in a subshell.
1401
1443
  private def multiline_command?(command)
1402
1444
  return false if command.nil?
1403
1445
  command.to_s.sub(/\n+\z/, "").include?("\n")
@@ -490,9 +490,10 @@ module Clacky
490
490
  true
491
491
  end
492
492
 
493
- # Permanently delete all sessions in the trash, or only those older than
494
- # :days days. Returns the count of permanently deleted sessions.
495
- def self.empty_trash_sessions(sessions_dir:, days: nil)
493
+ # Permanently delete all sessions in the trash, only those older than
494
+ # :days days, or only the sessions whose full id is in :ids.
495
+ # Returns the count of permanently deleted sessions.
496
+ def self.empty_trash_sessions(sessions_dir:, days: nil, ids: nil)
496
497
  trash_dir = Clacky::TrashDirectory.sessions_trash_dir
497
498
  return 0 unless Dir.exist?(trash_dir)
498
499
 
@@ -503,6 +504,7 @@ module Clacky
503
504
  Dir.glob(File.join(trash_dir, "*.json")).each do |filepath|
504
505
  session = sm.send(:load_session_file, filepath)
505
506
  next unless session
507
+ next if ids && !ids.include?(session[:session_id])
506
508
 
507
509
  if cutoff
508
510
  trash_time = session[:deleted_at] || session[:updated_at]
@@ -5,6 +5,7 @@ require "uri"
5
5
  require "tmpdir"
6
6
  require "fileutils"
7
7
  require_relative "../utils/encoding"
8
+ require_relative "../deepseek_web_tools"
8
9
 
9
10
  module Clacky
10
11
  module Tools
@@ -39,30 +40,88 @@ module Clacky
39
40
  return { error: "Invalid URL: #{e.message}" }
40
41
  end
41
42
 
42
- begin
43
- # Fetch the web page
44
- response = fetch_url(uri)
45
-
46
- # Extract content and force UTF-8 encoding at the source
47
- content = Clacky::Utils::Encoding.to_utf8(response.body)
48
- content_type = response["content-type"] || ""
49
-
50
- # Parse HTML if it's an HTML page
51
- if content_type.include?("text/html")
52
- result = parse_html(content, max_length, url)
53
- result[:url] = url
54
- result[:content_type] = content_type
55
- result[:status_code] = response.code.to_i
56
- result[:error] = nil
57
- result
58
- else
59
- # For non-HTML content, return raw text
60
- result = handle_raw_content(content, max_length, url, content_type, response.code.to_i)
61
- result
43
+ deepseek_error = nil
44
+ if deepseek_fetch_selected?
45
+ begin
46
+ return fetch_via_deepseek(url, max_length)
47
+ rescue StandardError => e
48
+ # A paid upstream failure must not cost the user the page, so keep
49
+ # going with the local scraper. The cooldown stops the next fetch
50
+ # from paying the same timeout.
51
+ deepseek_error = e
52
+ Clacky::DeepSeekWebTools.mark_unavailable!
62
53
  end
63
- rescue StandardError => e
64
- { error: "Failed to fetch URL: #{e.message}" }
65
54
  end
55
+
56
+ result = fetch_locally(uri, url, max_length)
57
+ if deepseek_error && result[:error]
58
+ result[:error] = "#{result[:error]} (DeepSeek: #{deepseek_error.message})"
59
+ end
60
+ result
61
+ end
62
+
63
+ private def fetch_locally(uri, url, max_length)
64
+ response = fetch_url(uri)
65
+
66
+ content = Clacky::Utils::Encoding.to_utf8(response.body)
67
+ content_type = response["content-type"] || ""
68
+
69
+ if content_type.include?("text/html")
70
+ result = parse_html(content, max_length, url)
71
+ result[:url] = url
72
+ result[:content_type] = content_type
73
+ result[:status_code] = response.code.to_i
74
+ result[:error] = nil
75
+ result
76
+ else
77
+ handle_raw_content(content, max_length, url, content_type, response.code.to_i)
78
+ end
79
+ rescue StandardError => e
80
+ { error: "Failed to fetch URL: #{e.message}" }
81
+ end
82
+
83
+ # DeepSeek renders pages to clean Markdown, which covers JS-heavy sites the
84
+ # local scraper cannot read. Runs only when the user picks it as the search
85
+ # provider; #execute falls back to the local scraper when it raises.
86
+ private def fetch_via_deepseek(url, max_length)
87
+ config = agent.config
88
+ key = config.deepseek_web_key
89
+ data = Clacky::DeepSeekWebTools.fetch(
90
+ url: url,
91
+ api_key: key,
92
+ base_url: config.deepseek_web_base_url(key)
93
+ )
94
+
95
+ text = data[:content].to_s
96
+ truncated = text.length > max_length
97
+ temp_file = nil
98
+ if truncated
99
+ temp_dir = Dir.mktmpdir
100
+ safe_name = extract_domain(url).gsub(/[^\w\-.]/, '_')[0...50]
101
+ timestamp = Time.now.strftime("%Y%m%d_%H%M%S")
102
+ temp_file = File.join(temp_dir, "#{safe_name}_#{timestamp}.md")
103
+ File.write(temp_file, text)
104
+ end
105
+
106
+ {
107
+ url: url,
108
+ title: data[:title].to_s.strip,
109
+ content_type: "text/markdown",
110
+ status_code: 200,
111
+ content: text[0, max_length],
112
+ truncated: truncated,
113
+ temp_file: temp_file,
114
+ error: nil
115
+ }
116
+ end
117
+
118
+ private def deepseek_fetch_selected?
119
+ return false unless agent
120
+ return false if Clacky::DeepSeekWebTools.cooling_down?
121
+
122
+ agent.config.deepseek_web_ready?
123
+ rescue StandardError
124
+ false
66
125
  end
67
126
 
68
127
  def handle_raw_content(content, max_length, url, content_type, status_code)