openclacky 0.9.34 → 0.9.35

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 (63) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +30 -0
  3. data/lib/clacky/agent/cost_tracker.rb +1 -1
  4. data/lib/clacky/agent/llm_caller.rb +14 -10
  5. data/lib/clacky/agent/memory_updater.rb +1 -1
  6. data/lib/clacky/agent/session_serializer.rb +2 -0
  7. data/lib/clacky/agent/skill_manager.rb +1 -1
  8. data/lib/clacky/agent/tool_executor.rb +13 -16
  9. data/lib/clacky/agent/tool_registry.rb +0 -3
  10. data/lib/clacky/agent.rb +63 -38
  11. data/lib/clacky/agent_config.rb +5 -1
  12. data/lib/clacky/brand_config.rb +11 -27
  13. data/lib/clacky/cli.rb +36 -0
  14. data/lib/clacky/default_skills/channel-setup/SKILL.md +1 -1
  15. data/lib/clacky/default_skills/deploy/scripts/rails_deploy.rb +1 -1
  16. data/lib/clacky/default_skills/new/SKILL.md +1 -1
  17. data/lib/clacky/default_skills/product-help/SKILL.md +1 -1
  18. data/lib/clacky/default_skills/recall-memory/SKILL.md +1 -1
  19. data/lib/clacky/default_skills/skill-creator/SKILL.md +1 -1
  20. data/lib/clacky/idle_compression_timer.rb +8 -0
  21. data/lib/clacky/json_ui_controller.rb +2 -1
  22. data/lib/clacky/plain_ui_controller.rb +10 -3
  23. data/lib/clacky/platform_http_client.rb +161 -1
  24. data/lib/clacky/server/channel/channel_manager.rb +5 -3
  25. data/lib/clacky/server/channel/channel_ui_controller.rb +6 -2
  26. data/lib/clacky/server/http_server.rb +235 -40
  27. data/lib/clacky/server/scheduler.rb +17 -16
  28. data/lib/clacky/server/session_registry.rb +1 -5
  29. data/lib/clacky/server/web_ui_controller.rb +7 -6
  30. data/lib/clacky/session_manager.rb +22 -0
  31. data/lib/clacky/skill.rb +19 -3
  32. data/lib/clacky/skill_loader.rb +5 -59
  33. data/lib/clacky/tools/browser.rb +25 -73
  34. data/lib/clacky/tools/security.rb +326 -0
  35. data/lib/clacky/tools/terminal/output_cleaner.rb +63 -0
  36. data/lib/clacky/tools/terminal/persistent_session.rb +247 -0
  37. data/lib/clacky/tools/terminal/session_manager.rb +208 -0
  38. data/lib/clacky/tools/terminal.rb +818 -0
  39. data/lib/clacky/tools/todo_manager.rb +6 -16
  40. data/lib/clacky/tools/trash_manager.rb +2 -2
  41. data/lib/clacky/ui2/components/input_area.rb +11 -2
  42. data/lib/clacky/ui2/layout_manager.rb +438 -488
  43. data/lib/clacky/ui2/output_buffer.rb +310 -0
  44. data/lib/clacky/ui2/ui_controller.rb +72 -21
  45. data/lib/clacky/ui_interface.rb +1 -1
  46. data/lib/clacky/utils/encoding.rb +1 -1
  47. data/lib/clacky/utils/environment_detector.rb +43 -0
  48. data/lib/clacky/utils/model_pricing.rb +3 -3
  49. data/lib/clacky/version.rb +1 -1
  50. data/lib/clacky/web/app.css +479 -178
  51. data/lib/clacky/web/app.js +146 -4
  52. data/lib/clacky/web/auth.js +101 -0
  53. data/lib/clacky/web/i18n.js +35 -1
  54. data/lib/clacky/web/index.html +9 -2
  55. data/lib/clacky/web/sessions.js +254 -15
  56. data/lib/clacky/web/skills.js +20 -6
  57. data/lib/clacky/web/tasks.js +54 -2
  58. data/lib/clacky/web/theme.js +58 -20
  59. data/lib/clacky/web/ws.js +11 -2
  60. data/lib/clacky.rb +2 -2
  61. metadata +8 -3
  62. data/lib/clacky/tools/safe_shell.rb +0 -608
  63. data/lib/clacky/tools/shell.rb +0 -522
@@ -84,6 +84,14 @@ module Clacky
84
84
  @mutex.synchronize { @timer_thread&.alive? || @compress_thread&.alive? }
85
85
  end
86
86
 
87
+ # True only when compression work is actually in flight (not during the
88
+ # pre-compression idle countdown). Used by callers that want to treat
89
+ # Ctrl+C during active compression as "stop compressing" rather than
90
+ # "exit the program".
91
+ def compressing?
92
+ @mutex.synchronize { @compress_thread&.alive? || false }
93
+ end
94
+
87
95
  private def run_compression
88
96
  success = @agent.trigger_idle_compression
89
97
 
@@ -134,10 +134,11 @@ module Clacky
134
134
 
135
135
  # === State updates ===
136
136
 
137
- def update_sessionbar(tasks: nil, cost: nil, status: nil)
137
+ def update_sessionbar(tasks: nil, cost: nil, cost_source: nil, status: nil)
138
138
  data = {}
139
139
  data[:tasks] = tasks if tasks
140
140
  data[:cost] = cost if cost
141
+ data[:cost_source] = cost_source if cost_source
141
142
  data[:status] = status if status
142
143
  emit("session_update", **data) unless data.empty?
143
144
  end
@@ -43,9 +43,16 @@ module Clacky
43
43
  end
44
44
 
45
45
  display = case name
46
- when "shell", "safe_shell"
46
+ when "terminal"
47
47
  cmd = args_data.is_a?(Hash) ? (args_data[:command] || args_data["command"]) : args_data
48
- "$ #{cmd}"
48
+ sid = args_data.is_a?(Hash) ? (args_data[:session_id] || args_data["session_id"]) : nil
49
+ if cmd
50
+ "$ #{cmd}"
51
+ elsif sid
52
+ "$ (session ##{sid})"
53
+ else
54
+ "$ terminal"
55
+ end
49
56
  when "write"
50
57
  path = args_data.is_a?(Hash) ? (args_data[:path] || args_data["path"]) : args_data
51
58
  "Write → #{path}"
@@ -129,7 +136,7 @@ module Clacky
129
136
 
130
137
  # === State updates (no-ops) ===
131
138
 
132
- def update_sessionbar(tasks: nil, cost: nil, status: nil); end
139
+ def update_sessionbar(tasks: nil, cost: nil, cost_source: nil, status: nil); end
133
140
  def update_todos(todos); end
134
141
  def set_working_status; end
135
142
  def set_idle_status; end
@@ -3,6 +3,7 @@
3
3
  require "net/http"
4
4
  require "uri"
5
5
  require "json"
6
+ require "fileutils"
6
7
 
7
8
  module Clacky
8
9
  # PlatformHttpClient provides a resilient HTTP client for all calls to the
@@ -12,6 +13,8 @@ module Clacky
12
13
  # - Automatic retry with exponential back-off on transient failures
13
14
  # - Transparent domain failover: if the primary domain times out or returns a
14
15
  # 5xx error, the request is automatically retried against the fallback domain
16
+ # - Unified large-file download entry point (#download_file) that reuses the
17
+ # same primary → fallback failover policy as API calls
15
18
  # - Override via CLACKY_LICENSE_SERVER env var (used in development)
16
19
  #
17
20
  # Usage:
@@ -29,9 +32,13 @@ module Clacky
29
32
  ATTEMPTS_PER_HOST = 2
30
33
  # Initial back-off between retries within the same domain (seconds)
31
34
  INITIAL_BACKOFF = 0.5
32
- # Connection / read timeouts (seconds)
35
+ # Connection / read timeouts (seconds) for API calls
33
36
  OPEN_TIMEOUT = 8
34
37
  READ_TIMEOUT = 15
38
+ # Read timeout for streaming large file downloads (seconds)
39
+ DOWNLOAD_READ_TIMEOUT = 120
40
+ # Max HTTP redirects followed by #download_file per host attempt
41
+ DOWNLOAD_MAX_REDIRECTS = 10
35
42
 
36
43
  # API error code → human-readable message table (shared across all callers)
37
44
  API_ERROR_MESSAGES = {
@@ -108,6 +115,159 @@ module Clacky
108
115
  read_timeout_override: read_timeout)
109
116
  end
110
117
 
118
+ # Stream a remote URL to a local file path, with automatic primary → fallback
119
+ # host failover.
120
+ #
121
+ # This is the unified entry point for all large-file downloads (brand skill
122
+ # ZIPs, platform-hosted assets, etc.). Callers should NOT build their own
123
+ # Net::HTTP loops — failover, retry, redirects, and timeouts are handled here.
124
+ #
125
+ # Host failover policy:
126
+ # - If +url+'s host matches PRIMARY_HOST and the request fails with a
127
+ # retryable error (timeout, connection reset, SSL, 5xx), the URL is
128
+ # rewritten to FALLBACK_HOST (same path/query) and retried.
129
+ # - Both hosts serve the same Rails backend and share +secret_key_base+,
130
+ # so ActiveStorage signed_ids resolve identically on either.
131
+ # - Third-party hosts (e.g. S3 presigned URLs reached via redirect) are
132
+ # fetched as-is without host rewriting.
133
+ #
134
+ # Each host gets ATTEMPTS_PER_HOST attempts with exponential back-off.
135
+ # Up to DOWNLOAD_MAX_REDIRECTS redirects are followed per attempt.
136
+ #
137
+ # @param url [String] Full URL to download
138
+ # @param dest [String] Local path to write the response body into.
139
+ # The file is written atomically (temp path + rename)
140
+ # so a failed download cannot leave a half-written file.
141
+ # @param read_timeout [Integer] Override read timeout (seconds)
142
+ # @return [Hash] { success: Boolean, bytes: Integer, error: String }
143
+ def download_file(url, dest, read_timeout: DOWNLOAD_READ_TIMEOUT)
144
+ candidate_urls = [url]
145
+ # Only auto-add a fallback candidate when the URL is on our primary host.
146
+ # External hosts (S3, CDNs, user-provided URLs) are fetched as-is.
147
+ if primary_host_url?(url)
148
+ candidate_urls << swap_to_fallback_host(url)
149
+ end
150
+
151
+ last_error = nil
152
+ FileUtils.mkdir_p(File.dirname(dest))
153
+ tmp_dest = "#{dest}.part"
154
+
155
+ candidate_urls.each_with_index do |candidate, host_index|
156
+ ATTEMPTS_PER_HOST.times do |attempt|
157
+ begin
158
+ bytes = stream_download(candidate, tmp_dest, read_timeout: read_timeout)
159
+ File.rename(tmp_dest, dest)
160
+ return { success: true, bytes: bytes, error: nil }
161
+ rescue RetryableNetworkError => e
162
+ last_error = e
163
+ backoff = INITIAL_BACKOFF * (2**attempt)
164
+ Clacky::Logger.debug(
165
+ "[PlatformHTTP] DOWNLOAD #{candidate} attempt #{attempt + 1} failed: " \
166
+ "#{e.message} — retrying in #{backoff}s"
167
+ )
168
+ sleep(backoff)
169
+ end
170
+ end
171
+
172
+ if host_index + 1 < candidate_urls.size
173
+ Clacky::Logger.debug(
174
+ "[PlatformHTTP] Primary host exhausted for download, switching to fallback: " \
175
+ "#{candidate_urls[host_index + 1]}"
176
+ )
177
+ end
178
+ end
179
+
180
+ FileUtils.rm_f(tmp_dest)
181
+ { success: false, bytes: 0, error: "Download failed: #{last_error&.message || "unknown"}" }
182
+ end
183
+
184
+ # True when +url+ targets the primary platform host.
185
+ # Used by #download_file to decide whether fallback-host rewriting is safe.
186
+ private def primary_host_url?(url)
187
+ return false if url.nil? || url.empty?
188
+
189
+ uri = URI.parse(url)
190
+ primary = URI.parse(PRIMARY_HOST)
191
+ uri.host == primary.host
192
+ rescue URI::InvalidURIError
193
+ false
194
+ end
195
+
196
+ # Rewrite +url+ so its host is the fallback domain (same path + query).
197
+ # Callers must have already confirmed the URL's host is PRIMARY_HOST via
198
+ # #primary_host_url? — this method does not validate that precondition.
199
+ private def swap_to_fallback_host(url)
200
+ uri = URI.parse(url)
201
+ fallback = URI.parse(FALLBACK_HOST)
202
+ uri.scheme = fallback.scheme
203
+ uri.host = fallback.host
204
+ # Only apply an explicit port when fallback declares a non-default one
205
+ uri.port = fallback.port if fallback.port && fallback.port != fallback.default_port
206
+ uri.to_s
207
+ end
208
+
209
+ # Execute a streaming GET with redirect following, writing the response body
210
+ # to +dest+ as it arrives. Raises RetryableNetworkError on any transient
211
+ # failure so the caller can decide whether to retry / failover.
212
+ #
213
+ # @return [Integer] Number of bytes written
214
+ private def stream_download(url, dest, read_timeout:)
215
+ current_url = url
216
+ DOWNLOAD_MAX_REDIRECTS.times do
217
+ uri = URI.parse(current_url)
218
+ http = Net::HTTP.new(uri.host, uri.port)
219
+ http.use_ssl = uri.scheme == "https"
220
+ http.open_timeout = OPEN_TIMEOUT
221
+ http.read_timeout = read_timeout
222
+
223
+ req = Net::HTTP::Get.new(uri.request_uri)
224
+
225
+ written = 0
226
+ redirect_to = nil
227
+ http.start do |h|
228
+ h.request(req) do |resp|
229
+ case resp.code.to_i
230
+ when 200
231
+ File.open(dest, "wb") do |f|
232
+ resp.read_body do |chunk|
233
+ f.write(chunk)
234
+ written += chunk.bytesize
235
+ end
236
+ end
237
+ when 301, 302, 303, 307, 308
238
+ location = resp["location"]
239
+ raise RetryableNetworkError, "Redirect with no Location header" if location.nil? || location.empty?
240
+
241
+ redirect_to = location
242
+ else
243
+ # 5xx is retryable, 4xx is terminal — but we don't have separate
244
+ # handling in the existing API path and fallback is still useful
245
+ # for e.g. upstream 502/503, so treat everything non-2xx/3xx as
246
+ # retryable to match the spirit of request_with_failover.
247
+ raise RetryableNetworkError, "HTTP #{resp.code}"
248
+ end
249
+ end
250
+ end
251
+
252
+ return written if redirect_to.nil?
253
+
254
+ current_url = redirect_to
255
+ end
256
+
257
+ raise RetryableNetworkError, "Too many redirects"
258
+ rescue Net::OpenTimeout, Net::ReadTimeout => e
259
+ raise RetryableNetworkError, "Timeout: #{e.message}"
260
+ rescue Errno::ECONNREFUSED, Errno::EHOSTUNREACH, Errno::ENETUNREACH,
261
+ Errno::ECONNRESET, EOFError => e
262
+ raise RetryableNetworkError, "Connection error: #{e.message}"
263
+ rescue OpenSSL::SSL::SSLError => e
264
+ raise RetryableNetworkError, "SSL error: #{e.message}"
265
+ rescue RetryableNetworkError
266
+ raise
267
+ rescue StandardError => e
268
+ raise RetryableNetworkError, e.message
269
+ end
270
+
111
271
  private def request_with_failover(method, path, payload, extra_headers, read_timeout_override: nil)
112
272
  last_error = nil
113
273
 
@@ -167,10 +167,12 @@ module Clacky
167
167
 
168
168
  Clacky::Logger.info("[ChannelManager] Routing to session #{session_id[0, 8]} (status=#{session[:status]})")
169
169
 
170
+ # If session is running, interrupt it automatically (mimics CLI behavior)
170
171
  if session[:status] == :running
171
- Clacky::Logger.info("[ChannelManager] Session busy, rejecting message")
172
- adapter.send_text(event[:chat_id], "Still working on the previous task. Send `/stop` to interrupt.")
173
- return
172
+ Clacky::Logger.info("[ChannelManager] Session busy, interrupting previous task")
173
+ @interrupt_session.call(session_id)
174
+ # Wait briefly for the thread to catch the interrupt and update status
175
+ sleep 0.1
174
176
  end
175
177
 
176
178
  agent = session[:agent]
@@ -51,7 +51,11 @@ module Clacky
51
51
  def show_assistant_message(content, files:)
52
52
  flush_buffer
53
53
  Clacky::Logger.info("[ChannelUI] show_assistant_message files=#{files.size} content_len=#{content.to_s.length}")
54
- send_text(content) unless content.nil? || content.to_s.strip.empty?
54
+ # Strip file:// markdown links from the text sent to IM channels —
55
+ # the actual files are delivered via send_file() below, so the
56
+ # raw markdown links would just be noise in the chat.
57
+ text = content.to_s.gsub(/!?\[[^\]]*\]\(file:\/\/[^)]+\)/, "").strip
58
+ send_text(text) unless text.empty?
55
59
  files.each do |f|
56
60
  Clacky::Logger.info("[ChannelUI] sending file path=#{f[:path].inspect} name=#{f[:name].inspect}")
57
61
  send_file(f[:path], f[:name])
@@ -144,7 +148,7 @@ module Clacky
144
148
 
145
149
  # === State updates (no-ops for IM) ===
146
150
 
147
- def update_sessionbar(tasks: nil, cost: nil, status: nil); end
151
+ def update_sessionbar(tasks: nil, cost: nil, cost_source: nil, status: nil); end
148
152
  def update_todos(todos); end
149
153
  def set_working_status; end
150
154
  def set_idle_status; end