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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +30 -0
- data/lib/clacky/agent/cost_tracker.rb +1 -1
- data/lib/clacky/agent/llm_caller.rb +14 -10
- data/lib/clacky/agent/memory_updater.rb +1 -1
- data/lib/clacky/agent/session_serializer.rb +2 -0
- data/lib/clacky/agent/skill_manager.rb +1 -1
- data/lib/clacky/agent/tool_executor.rb +13 -16
- data/lib/clacky/agent/tool_registry.rb +0 -3
- data/lib/clacky/agent.rb +63 -38
- data/lib/clacky/agent_config.rb +5 -1
- data/lib/clacky/brand_config.rb +11 -27
- data/lib/clacky/cli.rb +36 -0
- data/lib/clacky/default_skills/channel-setup/SKILL.md +1 -1
- data/lib/clacky/default_skills/deploy/scripts/rails_deploy.rb +1 -1
- data/lib/clacky/default_skills/new/SKILL.md +1 -1
- data/lib/clacky/default_skills/product-help/SKILL.md +1 -1
- data/lib/clacky/default_skills/recall-memory/SKILL.md +1 -1
- data/lib/clacky/default_skills/skill-creator/SKILL.md +1 -1
- data/lib/clacky/idle_compression_timer.rb +8 -0
- data/lib/clacky/json_ui_controller.rb +2 -1
- data/lib/clacky/plain_ui_controller.rb +10 -3
- data/lib/clacky/platform_http_client.rb +161 -1
- data/lib/clacky/server/channel/channel_manager.rb +5 -3
- data/lib/clacky/server/channel/channel_ui_controller.rb +6 -2
- data/lib/clacky/server/http_server.rb +235 -40
- data/lib/clacky/server/scheduler.rb +17 -16
- data/lib/clacky/server/session_registry.rb +1 -5
- data/lib/clacky/server/web_ui_controller.rb +7 -6
- data/lib/clacky/session_manager.rb +22 -0
- data/lib/clacky/skill.rb +19 -3
- data/lib/clacky/skill_loader.rb +5 -59
- data/lib/clacky/tools/browser.rb +25 -73
- data/lib/clacky/tools/security.rb +326 -0
- data/lib/clacky/tools/terminal/output_cleaner.rb +63 -0
- data/lib/clacky/tools/terminal/persistent_session.rb +247 -0
- data/lib/clacky/tools/terminal/session_manager.rb +208 -0
- data/lib/clacky/tools/terminal.rb +818 -0
- data/lib/clacky/tools/todo_manager.rb +6 -16
- data/lib/clacky/tools/trash_manager.rb +2 -2
- data/lib/clacky/ui2/components/input_area.rb +11 -2
- data/lib/clacky/ui2/layout_manager.rb +438 -488
- data/lib/clacky/ui2/output_buffer.rb +310 -0
- data/lib/clacky/ui2/ui_controller.rb +72 -21
- data/lib/clacky/ui_interface.rb +1 -1
- data/lib/clacky/utils/encoding.rb +1 -1
- data/lib/clacky/utils/environment_detector.rb +43 -0
- data/lib/clacky/utils/model_pricing.rb +3 -3
- data/lib/clacky/version.rb +1 -1
- data/lib/clacky/web/app.css +479 -178
- data/lib/clacky/web/app.js +146 -4
- data/lib/clacky/web/auth.js +101 -0
- data/lib/clacky/web/i18n.js +35 -1
- data/lib/clacky/web/index.html +9 -2
- data/lib/clacky/web/sessions.js +254 -15
- data/lib/clacky/web/skills.js +20 -6
- data/lib/clacky/web/tasks.js +54 -2
- data/lib/clacky/web/theme.js +58 -20
- data/lib/clacky/web/ws.js +11 -2
- data/lib/clacky.rb +2 -2
- metadata +8 -3
- data/lib/clacky/tools/safe_shell.rb +0 -608
- 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 "
|
|
46
|
+
when "terminal"
|
|
47
47
|
cmd = args_data.is_a?(Hash) ? (args_data[:command] || args_data["command"]) : args_data
|
|
48
|
-
"
|
|
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,
|
|
172
|
-
|
|
173
|
-
|
|
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
|
-
|
|
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
|