openclacky 1.5.20 → 1.5.22
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 +110 -0
- data/README.md +18 -0
- data/lib/clacky/agent/chunk_index.rb +8 -1
- data/lib/clacky/agent/feedback.rb +30 -0
- data/lib/clacky/agent/history_navigation.rb +17 -3
- data/lib/clacky/agent/hook_manager.rb +6 -1
- data/lib/clacky/agent/llm_caller.rb +1 -0
- data/lib/clacky/agent/message_compressor_helper.rb +28 -2
- data/lib/clacky/agent/session_serializer.rb +20 -2
- data/lib/clacky/agent.rb +56 -43
- data/lib/clacky/agent_config.rb +82 -4
- data/lib/clacky/billing/platform_billing.rb +8 -1
- data/lib/clacky/brand_config.rb +1 -1
- data/lib/clacky/cli.rb +3 -2
- data/lib/clacky/client.rb +5 -5
- data/lib/clacky/deepseek_web_tools.rb +156 -0
- data/lib/clacky/default_extensions/ext-studio/api/handler.rb +8 -0
- data/lib/clacky/default_extensions/ext-studio/panels/studio/view.js +21 -1
- data/lib/clacky/default_extensions/git/panels/git/view.js +1911 -398
- data/lib/clacky/default_extensions/preview/panels/preview/view.js +199 -34
- data/lib/clacky/extension/loader.rb +5 -3
- data/lib/clacky/locales/en.rb +5 -5
- data/lib/clacky/locales/zh.rb +5 -5
- data/lib/clacky/message_format/anthropic.rb +20 -0
- data/lib/clacky/message_format/open_ai.rb +31 -14
- data/lib/clacky/message_history.rb +21 -3
- data/lib/clacky/platform_http_client.rb +6 -2
- data/lib/clacky/providers.rb +66 -31
- data/lib/clacky/search_config.rb +71 -15
- data/lib/clacky/server/browser_manager.rb +35 -5
- data/lib/clacky/server/channel/channel_manager.rb +6 -6
- data/lib/clacky/server/discover.rb +5 -4
- data/lib/clacky/server/git_panel.rb +360 -34
- data/lib/clacky/server/http_server.rb +334 -89
- data/lib/clacky/server/model_prices.rb +5 -3
- data/lib/clacky/server/pid_files.rb +25 -0
- data/lib/clacky/server/preview.rb +118 -17
- data/lib/clacky/server/scheduler.rb +81 -2
- data/lib/clacky/server/server_master.rb +9 -3
- data/lib/clacky/server/session_registry.rb +19 -7
- data/lib/clacky/server/web_ui_controller.rb +78 -47
- data/lib/clacky/session_manager.rb +58 -5
- data/lib/clacky/skill.rb +1 -1
- data/lib/clacky/tools/ask_user.rb +4 -1
- data/lib/clacky/tools/base.rb +3 -2
- data/lib/clacky/tools/browser.rb +18 -2
- data/lib/clacky/tools/file_reader.rb +1 -1
- data/lib/clacky/tools/terminal/session_manager.rb +19 -1
- data/lib/clacky/tools/terminal.rb +65 -23
- data/lib/clacky/tools/trash_manager.rb +5 -3
- data/lib/clacky/tools/web_fetch.rb +81 -22
- data/lib/clacky/tools/web_search.rb +45 -4
- data/lib/clacky/ui2/ui_controller.rb +1 -1
- data/lib/clacky/ui_interface.rb +4 -3
- data/lib/clacky/utils/file_processor.rb +51 -0
- data/lib/clacky/utils/mac_app_detector.rb +8 -7
- data/lib/clacky/utils/model_pricing.rb +192 -15
- data/lib/clacky/utils/windows_app_detector.rb +3 -1
- data/lib/clacky/version.rb +1 -1
- data/lib/clacky/web/app.css +1019 -204
- data/lib/clacky/web/app.js +59 -6
- data/lib/clacky/web/auth.js +1 -1
- data/lib/clacky/web/components/chat-navigator.js +8 -1
- data/lib/clacky/web/components/composer.js +107 -8
- data/lib/clacky/web/components/image-zoom.js +165 -0
- data/lib/clacky/web/components/mentions.js +2 -13
- data/lib/clacky/web/components/model-picker.js +9 -5
- data/lib/clacky/web/components/notify.js +2 -2
- data/lib/clacky/web/components/onboard.js +13 -10
- data/lib/clacky/web/core/aside.js +60 -3
- data/lib/clacky/web/features/archive/store.js +30 -16
- data/lib/clacky/web/features/archive/view.js +20 -10
- data/lib/clacky/web/features/backup/view.js +5 -5
- data/lib/clacky/web/features/billing/view.js +2 -2
- data/lib/clacky/web/features/channels/view.js +5 -2
- data/lib/clacky/web/features/extensions/view.js +2 -2
- data/lib/clacky/web/features/new-session/store.js +82 -5
- data/lib/clacky/web/features/new-session/view.js +496 -58
- data/lib/clacky/web/features/skills/store.js +1 -1
- data/lib/clacky/web/features/tasks/view.js +162 -51
- data/lib/clacky/web/features/trash/store.js +10 -7
- data/lib/clacky/web/features/trash/view.js +9 -4
- data/lib/clacky/web/features/workspace/store.js +13 -6
- data/lib/clacky/web/features/workspace/view.js +153 -163
- data/lib/clacky/web/i18n.js +342 -84
- data/lib/clacky/web/index.html +262 -216
- data/lib/clacky/web/projects.js +93 -5
- data/lib/clacky/web/sessions.js +423 -234
- data/lib/clacky/web/settings.js +169 -16
- data/lib/clacky/web/skills.js +2 -2
- data/lib/clacky/web/theme.js +17 -2
- data/lib/clacky/web/utils.js +17 -2
- data/lib/clacky/web/weixin-qr.html +2 -1
- data/lib/clacky/web/ws-dispatcher.js +17 -42
- data/lib/clacky.rb +1 -0
- metadata +5 -1
|
@@ -6,7 +6,8 @@ module Clacky
|
|
|
6
6
|
# Resolves each model's input/output price (USD per 1M tokens) from
|
|
7
7
|
# Clacky::ModelPricing plus a cost ratio vs the baseline model, so the
|
|
8
8
|
# web UI never hardcodes prices - updating PRICING_TABLE is enough.
|
|
9
|
-
# Prices are
|
|
9
|
+
# Prices are what the platform actually bills: post-promotion rates plus
|
|
10
|
+
# any lane markup. `discount` is present when a promotion applied,
|
|
10
11
|
# carrying both the rate and its reason ("channel" or "promo") so the UI
|
|
11
12
|
# can pick the right explanation.
|
|
12
13
|
module ModelPrices
|
|
@@ -25,12 +26,13 @@ module Clacky
|
|
|
25
26
|
next unless pricing
|
|
26
27
|
info = Clacky::ModelPricing.discount_info(name)
|
|
27
28
|
rate = info ? info[:rate] : 1.0
|
|
29
|
+
markup = Clacky::ModelPricing.markup_rate(name)
|
|
28
30
|
# Rates are not powers of two, so the discounted product carries float
|
|
29
31
|
# noise (3.0 * 0.95 == 2.8499999999999996) that would surface verbatim
|
|
30
32
|
# in the picker tooltips. Four decimals is already finer than any
|
|
31
33
|
# listed price.
|
|
32
|
-
pin = (pricing[:input][:default] * rate).round(4)
|
|
33
|
-
pout = (pricing[:output][:default] * rate).round(4)
|
|
34
|
+
pin = (pricing[:input][:default] * rate * markup).round(4)
|
|
35
|
+
pout = (pricing[:output][:default] * rate * markup).round(4)
|
|
34
36
|
entry = { in: pin, out: pout, ratio: (pin + pout) / base_total }
|
|
35
37
|
entry[:discount] = { rate: rate, reason: info[:reason] } if rate < 1.0
|
|
36
38
|
prices[name] = entry
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "tmpdir"
|
|
4
|
+
require_relative "../agent_config"
|
|
5
|
+
|
|
6
|
+
module Clacky
|
|
7
|
+
module Server
|
|
8
|
+
# Shared directory policy for master lifecycle and CLI discovery.
|
|
9
|
+
module PidFiles
|
|
10
|
+
module_function
|
|
11
|
+
|
|
12
|
+
def directory
|
|
13
|
+
override = ENV["CLACKY_PID_DIR"]
|
|
14
|
+
configured = if override && !override.strip.empty?
|
|
15
|
+
override
|
|
16
|
+
else
|
|
17
|
+
AgentConfig.load.pid_dir
|
|
18
|
+
end
|
|
19
|
+
return Dir.tmpdir if configured.nil? || configured.strip.empty?
|
|
20
|
+
|
|
21
|
+
File.expand_path(configured)
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
require "net/http"
|
|
4
4
|
require "socket"
|
|
5
5
|
require "timeout"
|
|
6
|
+
require "uri"
|
|
6
7
|
|
|
7
8
|
module Clacky
|
|
8
9
|
module Server
|
|
@@ -47,18 +48,53 @@ module Clacky
|
|
|
47
48
|
".ogg" => "audio/ogg"
|
|
48
49
|
}.freeze
|
|
49
50
|
|
|
50
|
-
# Injected into preview HTML responses. The preview iframe is
|
|
51
|
-
#
|
|
52
|
-
#
|
|
53
|
-
#
|
|
54
|
-
|
|
51
|
+
# Injected into preview HTML responses. The preview iframe is cross-origin
|
|
52
|
+
# to the host page, so the host cannot read its location (and the iframe's
|
|
53
|
+
# src property does not follow internal navigations). Each served page
|
|
54
|
+
# therefore announces its own URL to the parent. Re-reporting is not just
|
|
55
|
+
# the initial load: Turbo/htmx/React-Router navigations swap the DOM
|
|
56
|
+
# without a full page load, so the same script also listens for their
|
|
57
|
+
# events, patches the History API and falls back to a slow poll.
|
|
58
|
+
PREVIEW_NAV_REPORT = <<~JS.freeze
|
|
59
|
+
<script>(function(){
|
|
60
|
+
var last = null;
|
|
61
|
+
function report(){
|
|
62
|
+
try {
|
|
63
|
+
if (location.href === last) return;
|
|
64
|
+
last = location.href;
|
|
65
|
+
parent.postMessage({__clackyPreview:1,href:location.href},"*");
|
|
66
|
+
} catch(e){}
|
|
67
|
+
}
|
|
68
|
+
report();
|
|
69
|
+
["turbo:load","turbo:render","turbo:frame-load","htmx:afterSwap","pjax:end","DOMContentLoaded"].forEach(function(n){
|
|
70
|
+
document.addEventListener(n, report);
|
|
71
|
+
});
|
|
72
|
+
window.addEventListener("popstate", report);
|
|
73
|
+
window.addEventListener("hashchange", report);
|
|
74
|
+
var push = history.pushState, replace = history.replaceState;
|
|
75
|
+
history.pushState = function(){ push.apply(history, arguments); report(); };
|
|
76
|
+
history.replaceState = function(){ replace.apply(history, arguments); report(); };
|
|
77
|
+
setInterval(report, 500);
|
|
78
|
+
})()</script>
|
|
79
|
+
JS
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
# Must match ISOLATED_PREVIEW_HOST in
|
|
83
|
+
# default_extensions/preview/panels/preview/view.js. Chrome resolves
|
|
84
|
+
# every *.localhost name to loopback, so the preview frame runs on this
|
|
85
|
+
# origin while the UI stays on 127.0.0.1/localhost.
|
|
86
|
+
ISOLATED_PREVIEW_HOST = "preview.localhost"
|
|
87
|
+
|
|
88
|
+
# Hostnames a dev server may redirect to with an absolute URL. Only these
|
|
89
|
+
# are folded back into the proxy prefix; anything else is a deliberate
|
|
90
|
+
# off-box redirect and is passed through untouched.
|
|
91
|
+
LOOPBACK_HOSTS = %w[127.0.0.1 localhost ::1].freeze
|
|
55
92
|
|
|
56
93
|
# Headers dropped when proxying a localhost dev server through the
|
|
57
|
-
# preview panel. X-Frame-Options is what blocks the sandboxed iframe
|
|
58
|
-
# hop-by-hop headers
|
|
59
|
-
# (
|
|
60
|
-
#
|
|
61
|
-
# rest of the policy survives.
|
|
94
|
+
# preview panel. X-Frame-Options is what blocks the sandboxed iframe and
|
|
95
|
+
# hop-by-hop headers are recomputed by WEBrick. Set-Cookie is handled
|
|
96
|
+
# separately (see #preview_cookie) because the isolated preview origin
|
|
97
|
+
# needs the upstream session to survive inside a third-party frame.
|
|
62
98
|
PROXY_SKIP_HEADERS = %w[
|
|
63
99
|
x-frame-options
|
|
64
100
|
transfer-encoding
|
|
@@ -70,7 +106,6 @@ module Clacky
|
|
|
70
106
|
te
|
|
71
107
|
trailer
|
|
72
108
|
upgrade
|
|
73
|
-
set-cookie
|
|
74
109
|
].freeze
|
|
75
110
|
|
|
76
111
|
# Connection-stage failures when dialing the localhost dev server. The
|
|
@@ -189,11 +224,8 @@ module Clacky
|
|
|
189
224
|
# the document start) so the reporter runs before page scripts while a
|
|
190
225
|
# leading <!DOCTYPE> stays first — prepending would flip the document
|
|
191
226
|
# into quirks mode.
|
|
192
|
-
private def
|
|
193
|
-
|
|
194
|
-
return body unless (PREVIEW_CONTENT_TYPES[File.extname(abs).downcase] || "").start_with?("text/html")
|
|
195
|
-
|
|
196
|
-
html = body.force_encoding(Encoding::UTF_8)
|
|
227
|
+
private def inject_nav_report(html)
|
|
228
|
+
html = html.force_encoding(Encoding::UTF_8)
|
|
197
229
|
if (m = html.match(/<head[^>]*>/i))
|
|
198
230
|
html[m.end(0), 0] = PREVIEW_NAV_REPORT
|
|
199
231
|
elsif (m = html.match(/<html[^>]*>/i))
|
|
@@ -206,6 +238,13 @@ module Clacky
|
|
|
206
238
|
html
|
|
207
239
|
end
|
|
208
240
|
|
|
241
|
+
private def preview_body(abs)
|
|
242
|
+
body = File.binread(abs)
|
|
243
|
+
return body unless (PREVIEW_CONTENT_TYPES[File.extname(abs).downcase] || "").start_with?("text/html")
|
|
244
|
+
|
|
245
|
+
inject_nav_report(body)
|
|
246
|
+
end
|
|
247
|
+
|
|
209
248
|
# Decode %XX escapes in a preview path segment without treating "+" as
|
|
210
249
|
# space: "+" is a legal literal character in file names.
|
|
211
250
|
private def preview_decode_path(seg)
|
|
@@ -285,6 +324,15 @@ module Clacky
|
|
|
285
324
|
next if value.nil? || value.empty?
|
|
286
325
|
end
|
|
287
326
|
next if PROXY_SKIP_HEADERS.include?(down)
|
|
327
|
+
if down == "set-cookie"
|
|
328
|
+
cookie = preview_cookie(value)
|
|
329
|
+
res.cookies << cookie if cookie && isolated_preview_request?(req)
|
|
330
|
+
next
|
|
331
|
+
end
|
|
332
|
+
if down == "location"
|
|
333
|
+
res[name] = rewrite_preview_location(value, port, req)
|
|
334
|
+
next
|
|
335
|
+
end
|
|
288
336
|
if down == "content-type"
|
|
289
337
|
is_html = value.start_with?("text/html")
|
|
290
338
|
is_css = value.start_with?("text/css")
|
|
@@ -300,7 +348,7 @@ module Clacky
|
|
|
300
348
|
if req.request_method != "HEAD"
|
|
301
349
|
body = resp.body.to_s
|
|
302
350
|
if is_html
|
|
303
|
-
body = rewrite_preview_paths(body, port)
|
|
351
|
+
body = inject_nav_report(rewrite_preview_paths(body, port))
|
|
304
352
|
elsif is_css
|
|
305
353
|
body = rewrite_preview_css(body, port)
|
|
306
354
|
end
|
|
@@ -319,6 +367,59 @@ module Clacky
|
|
|
319
367
|
stripped.empty? ? nil : stripped
|
|
320
368
|
end
|
|
321
369
|
|
|
370
|
+
# Rewrites an upstream Set-Cookie so it survives inside the preview
|
|
371
|
+
# frame. The frame is a third-party context for the host UI, where Chrome
|
|
372
|
+
# drops SameSite=Lax/Strict cookies outright, so the session cookie the
|
|
373
|
+
# dev server just set would be discarded and every page would look like a
|
|
374
|
+
# fresh, cookie-less visit. SameSite=None needs Secure, which Chrome
|
|
375
|
+
# accepts over http for *.localhost because it is a trustworthy origin.
|
|
376
|
+
# Domain is dropped so the cookie stays pinned to the preview origin and
|
|
377
|
+
# can never be read by the host UI.
|
|
378
|
+
private def preview_cookie(value)
|
|
379
|
+
parts = value.to_s.split(";").map(&:strip).reject(&:empty?)
|
|
380
|
+
return nil if parts.empty?
|
|
381
|
+
|
|
382
|
+
kept = parts[1..-1].reject do |attr|
|
|
383
|
+
attr =~ /\A(?:domain|samesite)\s*=/i || attr =~ /\Asecure\z/i
|
|
384
|
+
end
|
|
385
|
+
([parts.first] + kept + ["SameSite=None", "Secure"]).join("; ")
|
|
386
|
+
end
|
|
387
|
+
|
|
388
|
+
private def isolated_preview_request?(req)
|
|
389
|
+
req["Host"].to_s.split(":").first.to_s.downcase == ISOLATED_PREVIEW_HOST
|
|
390
|
+
end
|
|
391
|
+
|
|
392
|
+
# An upstream 3xx points at the dev server root (/dashboard). Inside the
|
|
393
|
+
# isolated preview frame that resolves against the proxy origin and loses
|
|
394
|
+
# the /preview/p/<port> prefix, so the redirect lands on a 404 — and the
|
|
395
|
+
# address bar never sees the new page. Keep root-relative redirects inside
|
|
396
|
+
# the prefix and fold absolute ones that point back at the same dev
|
|
397
|
+
# server. Off-box and already-prefixed URLs pass through untouched.
|
|
398
|
+
private def rewrite_preview_location(value, port, req)
|
|
399
|
+
return value unless isolated_preview_request?(req)
|
|
400
|
+
|
|
401
|
+
prefix = "/preview/p/#{port}"
|
|
402
|
+
if value.start_with?("/") && !value.start_with?("//")
|
|
403
|
+
return value if value == prefix || value.start_with?("#{prefix}/", "#{prefix}?")
|
|
404
|
+
|
|
405
|
+
return "#{prefix}#{value}"
|
|
406
|
+
end
|
|
407
|
+
|
|
408
|
+
uri = begin
|
|
409
|
+
URI.parse(value)
|
|
410
|
+
rescue URI::InvalidURIError
|
|
411
|
+
return value
|
|
412
|
+
end
|
|
413
|
+
return value unless uri.host
|
|
414
|
+
return value unless LOOPBACK_HOSTS.include?(uri.host.downcase)
|
|
415
|
+
return value unless uri.port == port
|
|
416
|
+
|
|
417
|
+
path = uri.path.to_s.empty? ? "/" : uri.path
|
|
418
|
+
path += "?#{uri.query}" if uri.query
|
|
419
|
+
path += "##{uri.fragment}" if uri.fragment
|
|
420
|
+
"#{prefix}#{path}"
|
|
421
|
+
end
|
|
422
|
+
|
|
322
423
|
# Rewrites absolute-path resource URLs (/assets/app.js) in a proxied HTML
|
|
323
424
|
# page to the proxy prefix so the browser resolves them against the dev
|
|
324
425
|
# server instead of the host origin. Protocol-relative (//), already
|
|
@@ -141,13 +141,15 @@ module Clacky
|
|
|
141
141
|
list_tasks.map do |task_name|
|
|
142
142
|
content = begin; read_task(task_name); rescue StandardError; ""; end
|
|
143
143
|
schedule = schedule_map[task_name] || {}
|
|
144
|
+
cron = schedule["cron"]
|
|
144
145
|
{
|
|
145
146
|
"name" => task_name,
|
|
146
147
|
"content" => content,
|
|
147
|
-
"cron" =>
|
|
148
|
+
"cron" => cron,
|
|
148
149
|
"enabled" => schedule.fetch("enabled", true),
|
|
149
150
|
"scheduled" => !schedule.empty?,
|
|
150
|
-
"last_run" => last_runs[task_name]
|
|
151
|
+
"last_run" => last_runs[task_name],
|
|
152
|
+
"next_run" => cron ? cron_next_run(cron) : nil
|
|
151
153
|
}
|
|
152
154
|
end
|
|
153
155
|
end
|
|
@@ -383,6 +385,83 @@ module Clacky
|
|
|
383
385
|
|
|
384
386
|
# ── Cron parsing ─────────────────────────────────────────────────────────
|
|
385
387
|
|
|
388
|
+
# First time the 5-field cron expression fires strictly after +from+.
|
|
389
|
+
# Walks day by day (two years max) and only expands the hour/minute grid
|
|
390
|
+
# for days that match, so a yearly task costs ~365 cheap comparisons.
|
|
391
|
+
# Returns nil for malformed expressions and impossible ones (Feb 30).
|
|
392
|
+
private def cron_next_run(expr, from: Time.now)
|
|
393
|
+
fields = expr.to_s.strip.split(/\s+/)
|
|
394
|
+
return nil unless fields.size == 5
|
|
395
|
+
|
|
396
|
+
sets = [
|
|
397
|
+
cron_field_values(fields[0], 0, 59),
|
|
398
|
+
cron_field_values(fields[1], 0, 23),
|
|
399
|
+
cron_field_values(fields[2], 1, 31),
|
|
400
|
+
cron_field_values(fields[3], 1, 12),
|
|
401
|
+
cron_field_values(fields[4], 0, 6)
|
|
402
|
+
]
|
|
403
|
+
return nil if sets.any?(&:nil?)
|
|
404
|
+
|
|
405
|
+
minutes, hours, doms, months, dows = sets
|
|
406
|
+
return nil if minutes.empty? || hours.empty? || doms.empty? || months.empty? || dows.empty?
|
|
407
|
+
|
|
408
|
+
# Anchor at noon so a day-step never lands on the wrong date across a
|
|
409
|
+
# DST boundary (Time#+ is absolute seconds, not calendar days).
|
|
410
|
+
noon = Time.local(from.year, from.month, from.day, 12)
|
|
411
|
+
# Minute-of-day of the first candidate; same-day scans start here.
|
|
412
|
+
floor = from.hour * 60 + from.min + 1
|
|
413
|
+
|
|
414
|
+
(0..731).each do |offset|
|
|
415
|
+
day = noon + offset * 86_400
|
|
416
|
+
next unless months.include?(day.month) && doms.include?(day.day) && dows.include?(day.wday)
|
|
417
|
+
|
|
418
|
+
limit = offset.zero? ? floor : 0
|
|
419
|
+
hours.each do |h|
|
|
420
|
+
next if h * 60 + minutes.last < limit
|
|
421
|
+
|
|
422
|
+
minutes.each do |m|
|
|
423
|
+
next if h * 60 + m < limit
|
|
424
|
+
|
|
425
|
+
return Time.local(day.year, day.month, day.day, h, m, 0)
|
|
426
|
+
end
|
|
427
|
+
end
|
|
428
|
+
end
|
|
429
|
+
|
|
430
|
+
nil
|
|
431
|
+
end
|
|
432
|
+
|
|
433
|
+
# Concrete values a single cron field can take, mirroring the semantics of
|
|
434
|
+
# #cron_part_matches?. Returns nil when the field is unparseable.
|
|
435
|
+
private def cron_field_values(field, min, max)
|
|
436
|
+
values = []
|
|
437
|
+
field.to_s.split(",").each do |part|
|
|
438
|
+
part = part.strip
|
|
439
|
+
base, step = part.split("/")
|
|
440
|
+
step = step ? step.to_i : 1
|
|
441
|
+
return nil if step <= 0
|
|
442
|
+
|
|
443
|
+
lo, hi = if base == "*"
|
|
444
|
+
[min, max]
|
|
445
|
+
elsif base.include?("-")
|
|
446
|
+
base.split("-").map(&:to_i)
|
|
447
|
+
else
|
|
448
|
+
value = Integer(base, exception: false)
|
|
449
|
+
return nil if value.nil?
|
|
450
|
+
[value, value]
|
|
451
|
+
end
|
|
452
|
+
return nil if lo.nil? || hi.nil? || hi < lo
|
|
453
|
+
|
|
454
|
+
(lo..hi).each do |v|
|
|
455
|
+
next unless v.between?(min, max)
|
|
456
|
+
# `*` steps from zero (matches value % step), ranges step from lo.
|
|
457
|
+
next unless base == "*" ? (v % step).zero? : ((v - lo) % step).zero?
|
|
458
|
+
|
|
459
|
+
values << v
|
|
460
|
+
end
|
|
461
|
+
end
|
|
462
|
+
values.uniq.sort
|
|
463
|
+
end
|
|
464
|
+
|
|
386
465
|
# Returns true if the 5-field cron expression matches the given Time.
|
|
387
466
|
# Fields: minute hour day-of-month month day-of-week
|
|
388
467
|
private def cron_matches?(expr, time)
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "socket"
|
|
4
|
-
|
|
4
|
+
require_relative "pid_files"
|
|
5
5
|
require_relative "../banner"
|
|
6
6
|
require_relative "../version"
|
|
7
7
|
require_relative "../agent_config"
|
|
@@ -248,13 +248,19 @@ module Clacky
|
|
|
248
248
|
end
|
|
249
249
|
|
|
250
250
|
def pid_file_path
|
|
251
|
-
File.join(
|
|
251
|
+
File.join(pid_directory, "clacky-master-#{@port}.pid")
|
|
252
252
|
end
|
|
253
253
|
|
|
254
254
|
def write_pid_file
|
|
255
|
+
FileUtils.mkdir_p(pid_directory)
|
|
255
256
|
File.write(pid_file_path, Process.pid.to_s)
|
|
256
257
|
end
|
|
257
258
|
|
|
259
|
+
# Keep cleanup tied to the directory used at startup, even if config changes.
|
|
260
|
+
private def pid_directory
|
|
261
|
+
@pid_directory ||= PidFiles.directory
|
|
262
|
+
end
|
|
263
|
+
|
|
258
264
|
def remove_pid_file
|
|
259
265
|
# A replacement may already own the port after this master was stopped.
|
|
260
266
|
if File.exist?(pid_file_path) && File.read(pid_file_path).strip == Process.pid.to_s
|
|
@@ -318,7 +324,7 @@ module Clacky
|
|
|
318
324
|
end
|
|
319
325
|
|
|
320
326
|
private def kill_master_on_port(port)
|
|
321
|
-
path = File.join(
|
|
327
|
+
path = File.join(pid_directory, "clacky-master-#{port}.pid")
|
|
322
328
|
return unless File.exist?(path)
|
|
323
329
|
|
|
324
330
|
pid = File.read(path).strip.to_i
|
|
@@ -194,7 +194,8 @@ module Clacky
|
|
|
194
194
|
# profile: "general"|"coding"|nil
|
|
195
195
|
# nil = no agent_profile filter
|
|
196
196
|
# limit: max sessions to return (applies to NON-PINNED only; see below)
|
|
197
|
-
# before:
|
|
197
|
+
# before, before_id: cursor — only sessions sorting after (before, before_id)
|
|
198
|
+
# in the newest-first order; take it from a row's `cursor`
|
|
198
199
|
# (also applies to NON-PINNED only; pinned items are a separate
|
|
199
200
|
# logical section, they should never be paginated away)
|
|
200
201
|
# include_pinned: when true (default), all matching pinned sessions are
|
|
@@ -207,13 +208,13 @@ module Clacky
|
|
|
207
208
|
# archived: false (default) → archived sessions are excluded from the
|
|
208
209
|
# result; true → ONLY archived sessions are returned. The two
|
|
209
210
|
# sets are disjoint, so every other caller keeps seeing exactly
|
|
210
|
-
# the sessions it saw before archiving existed.
|
|
211
|
+
# the sessions it saw before archiving existed. nil → no filter.
|
|
211
212
|
#
|
|
212
213
|
# Ordering of the returned array:
|
|
213
214
|
# [ ...all_pinned_matching (newest-first), ...non_pinned (newest-first, limited) ]
|
|
214
215
|
#
|
|
215
216
|
# source and profile are orthogonal — either can be nil independently.
|
|
216
|
-
def list(limit: nil, before: nil, q: nil, q_scope: "name", date: nil, type: nil, exclude_type: nil, include_pinned: true, project_id: nil, exclude_project: false, archived: false)
|
|
217
|
+
def list(limit: nil, before: nil, before_id: nil, q: nil, q_scope: "name", date: nil, type: nil, exclude_type: nil, include_pinned: true, project_id: nil, exclude_project: false, archived: false)
|
|
217
218
|
return [] unless @session_manager
|
|
218
219
|
|
|
219
220
|
live = @mutex.synchronize do
|
|
@@ -227,6 +228,7 @@ module Clacky
|
|
|
227
228
|
model: model_info&.dig(:model), model_id: model_info&.dig(:id), name: live_name,
|
|
228
229
|
total_tasks: s[:agent]&.total_tasks, total_cost: s[:agent]&.total_cost,
|
|
229
230
|
cost_source: live_cost_source,
|
|
231
|
+
permission_mode: s[:agent]&.permission_mode,
|
|
230
232
|
reasoning_effort: s[:agent]&.reasoning_effort,
|
|
231
233
|
latest_latency: s[:agent]&.latest_latency,
|
|
232
234
|
card_model: model_info&.dig(:card_model),
|
|
@@ -238,7 +240,7 @@ module Clacky
|
|
|
238
240
|
|
|
239
241
|
all = @session_manager.all_sessions # already sorted newest-first
|
|
240
242
|
|
|
241
|
-
all = all.select { |s| archived ? s[:archived_at].to_s != "" : s[:archived_at].to_s == "" }
|
|
243
|
+
all = all.select { |s| archived ? s[:archived_at].to_s != "" : s[:archived_at].to_s == "" } unless archived.nil?
|
|
242
244
|
|
|
243
245
|
# ── type filter (replaces old source/profile split) ──────────────────
|
|
244
246
|
# type=coding → agent_profile == "coding"
|
|
@@ -297,9 +299,11 @@ module Clacky
|
|
|
297
299
|
pinned, non_pinned = all.partition { |s| s[:pinned] }
|
|
298
300
|
|
|
299
301
|
# `before` cursor ONLY applies to non-pinned (paginated) sessions.
|
|
300
|
-
# Cursor
|
|
301
|
-
|
|
302
|
-
|
|
302
|
+
# Cursor must match the sort key in all_sessions; the id breaks same-second ties.
|
|
303
|
+
if before
|
|
304
|
+
boundary = [before, before_id.to_s]
|
|
305
|
+
non_pinned = non_pinned.select { |s| (sort_key(s) <=> boundary) < 0 }
|
|
306
|
+
end
|
|
303
307
|
non_pinned = non_pinned.first(limit) if limit
|
|
304
308
|
|
|
305
309
|
# Pinned section: only included on the first page (before == nil) so
|
|
@@ -343,6 +347,7 @@ module Clacky
|
|
|
343
347
|
model: model_info&.dig(:model), model_id: model_info&.dig(:id),
|
|
344
348
|
name: live_name, total_tasks: s[:agent]&.total_tasks,
|
|
345
349
|
total_cost: s[:agent]&.total_cost, cost_source: s[:agent]&.cost_source,
|
|
350
|
+
permission_mode: s[:agent]&.permission_mode,
|
|
346
351
|
reasoning_effort: s[:agent]&.reasoning_effort,
|
|
347
352
|
latest_latency: s[:agent]&.latest_latency,
|
|
348
353
|
card_model: model_info&.dig(:card_model),
|
|
@@ -361,6 +366,10 @@ module Clacky
|
|
|
361
366
|
# Merge a single disk-side session hash with the corresponding live
|
|
362
367
|
# in-memory agent fields (may be nil) into the row shape the frontend
|
|
363
368
|
# consumes.
|
|
369
|
+
private def sort_key(s)
|
|
370
|
+
[s[:updated_at] || s[:created_at] || "", s[:session_id].to_s]
|
|
371
|
+
end
|
|
372
|
+
|
|
364
373
|
private def build_enriched_row(s, ls)
|
|
365
374
|
id = s[:session_id]
|
|
366
375
|
{
|
|
@@ -373,6 +382,7 @@ module Clacky
|
|
|
373
382
|
raw_message: ls&.dig(:raw_message),
|
|
374
383
|
model: ls&.dig(:model),
|
|
375
384
|
model_id: ls&.dig(:model_id),
|
|
385
|
+
permission_mode: ls&.dig(:permission_mode) || s.dig(:config, :permission_mode_override) || "confirm_all",
|
|
376
386
|
card_model: ls&.dig(:card_model),
|
|
377
387
|
sub_model: ls&.dig(:sub_model),
|
|
378
388
|
sub_model_options: ls&.dig(:sub_model_options) || [],
|
|
@@ -395,6 +405,8 @@ module Clacky
|
|
|
395
405
|
channel_info: s[:channel_info],
|
|
396
406
|
project_id: s[:project_id],
|
|
397
407
|
todos: ls&.dig(:todos) || s[:todos] || [],
|
|
408
|
+
# Rows report live activity in updated_at; paging needs the on-disk sort key.
|
|
409
|
+
cursor: sort_key(s).then { |at, sid| { before: at, before_id: sid } },
|
|
398
410
|
}
|
|
399
411
|
end
|
|
400
412
|
|
|
@@ -20,7 +20,7 @@ module Clacky
|
|
|
20
20
|
@broadcaster.call(@session_id, { type: "input_queue", session_id: @session_id, entries: entries })
|
|
21
21
|
end
|
|
22
22
|
|
|
23
|
-
attr_reader :session_id
|
|
23
|
+
attr_reader :session_id, :last_complete
|
|
24
24
|
|
|
25
25
|
def initialize(session_id, broadcaster)
|
|
26
26
|
@session_id = session_id
|
|
@@ -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,62 @@ module Clacky
|
|
|
69
68
|
end
|
|
70
69
|
end
|
|
71
70
|
|
|
72
|
-
|
|
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
|
+
# Leaving auto mode ends the countdown like an intervention, so the task
|
|
74
|
+
# finishes as awaiting_feedback instead of blocking while marked running.
|
|
75
|
+
def request_feedback_with_countdown(seconds: 20)
|
|
76
|
+
pending = { id: @feedback_request_id || SecureRandom.uuid, cond: ConditionVariable.new,
|
|
77
|
+
deadline_ms: (Time.now.to_f * 1000).to_i + seconds * 1000 }
|
|
78
|
+
pending[:deadline] = Process.clock_gettime(Process::CLOCK_MONOTONIC) + seconds
|
|
73
79
|
@mutex.synchronize do
|
|
74
|
-
|
|
75
|
-
|
|
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
|
|
80
|
+
return :intervened if @feedback_permission_mode == :confirm_all
|
|
81
|
+
@pending_feedback = pending
|
|
87
82
|
end
|
|
88
|
-
|
|
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)
|
|
83
|
+
emit("feedback_countdown", request_id: pending[:id], deadline_ms: pending[:deadline_ms])
|
|
99
84
|
@mutex.synchronize do
|
|
100
|
-
until pending[:
|
|
101
|
-
remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
85
|
+
until pending[:result]
|
|
86
|
+
remaining = pending[:deadline] - Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
102
87
|
if remaining <= 0
|
|
103
|
-
pending
|
|
88
|
+
pending[:result] = :timeout
|
|
104
89
|
else
|
|
105
90
|
pending[:cond].wait(@mutex, remaining)
|
|
106
91
|
end
|
|
107
92
|
end
|
|
108
|
-
|
|
93
|
+
pending[:result]
|
|
109
94
|
end
|
|
110
95
|
ensure
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
96
|
+
cleared = @mutex.synchronize do
|
|
97
|
+
if pending && @pending_feedback.equal?(pending)
|
|
98
|
+
@pending_feedback = nil
|
|
99
|
+
true
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
if cleared
|
|
103
|
+
emit("feedback_countdown", request_id: pending[:id], resolved: true,
|
|
104
|
+
resolution: pending[:result] || :cancelled)
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def update_permission_mode(mode)
|
|
109
|
+
@mutex.synchronize do
|
|
110
|
+
@feedback_permission_mode = mode.to_sym
|
|
111
|
+
pending = @pending_feedback
|
|
112
|
+
if pending && !pending[:result] && mode.to_sym != :auto_approve
|
|
113
|
+
pending[:result] = :intervened
|
|
114
|
+
pending[:cond].signal
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def intervene_feedback(request_id)
|
|
120
|
+
@mutex.synchronize do
|
|
121
|
+
pending = @pending_feedback
|
|
122
|
+
return false unless pending && pending[:id] == request_id && !pending[:result]
|
|
123
|
+
pending[:result] = :intervened
|
|
124
|
+
pending[:cond].signal
|
|
125
|
+
emit("feedback_countdown", request_id: pending[:id], deadline_ms: nil)
|
|
126
|
+
true
|
|
115
127
|
end
|
|
116
128
|
end
|
|
117
129
|
|
|
@@ -124,24 +136,37 @@ module Clacky
|
|
|
124
136
|
data[:skill_command] = skill_command if skill_command
|
|
125
137
|
data[:skill_command_display] = skill_command_display if skill_command_display
|
|
126
138
|
data[:references] = Array(references) unless Array(references).empty?
|
|
127
|
-
#
|
|
128
|
-
#
|
|
129
|
-
#
|
|
130
|
-
#
|
|
131
|
-
|
|
139
|
+
# Images keep using the lightbox renderer. Other attachments carry their
|
|
140
|
+
# path separately so the browser can open them through the Files viewer.
|
|
141
|
+
# A missing path is deliberately omitted: the card remains visible but
|
|
142
|
+
# is no longer presented as an actionable control.
|
|
143
|
+
rendered_images = []
|
|
144
|
+
rendered_attachments = []
|
|
145
|
+
Array(files).each do |f|
|
|
146
|
+
next if f[:reference] || f["reference"]
|
|
147
|
+
|
|
132
148
|
url = f[:data_url] || f["data_url"]
|
|
133
149
|
name = f[:name] || f["name"]
|
|
134
150
|
type = f[:type] || f["type"]
|
|
135
151
|
path = f[:path] || f["path"]
|
|
152
|
+
mime = f[:mime_type] || f["mime_type"]
|
|
153
|
+
image = type.to_s == "image" || mime.to_s.start_with?("image/")
|
|
154
|
+
|
|
136
155
|
if url
|
|
137
|
-
url
|
|
138
|
-
elsif
|
|
139
|
-
"/api/local-image?path=#{CGI.escape(path.to_s)}&v=#{File.mtime(path.to_s).to_i}"
|
|
156
|
+
rendered_images << url
|
|
157
|
+
elsif image && path && File.exist?(path.to_s)
|
|
158
|
+
rendered_images << "/api/local-image?path=#{CGI.escape(path.to_s)}&v=#{File.mtime(path.to_s).to_i}"
|
|
159
|
+
elsif image && name
|
|
160
|
+
rendered_images << "expired:#{name}"
|
|
140
161
|
elsif name
|
|
141
|
-
"
|
|
162
|
+
attachment_type = type.to_s.empty? ? (mime || "file") : type
|
|
163
|
+
attachment = { name: name, type: attachment_type }
|
|
164
|
+
attachment[:path] = path if path && File.exist?(path.to_s)
|
|
165
|
+
rendered_attachments << attachment
|
|
142
166
|
end
|
|
143
167
|
end
|
|
144
|
-
data[:images] =
|
|
168
|
+
data[:images] = rendered_images unless rendered_images.empty?
|
|
169
|
+
data[:attachments] = rendered_attachments unless rendered_attachments.empty?
|
|
145
170
|
emit("history_user_message", **data)
|
|
146
171
|
# Only forward to channel subscribers when the message originated from the WebUI,
|
|
147
172
|
# to avoid echoing channel messages back to the same channel.
|
|
@@ -176,7 +201,9 @@ module Clacky
|
|
|
176
201
|
context = args_data.is_a?(Hash) ? (args_data[:context] || args_data["context"]).to_s : ""
|
|
177
202
|
first = questions.first || {}
|
|
178
203
|
|
|
204
|
+
@feedback_request_id = Clacky::Tools::AskUser.fetch_key(args_data, :request_id)
|
|
179
205
|
emit("request_feedback",
|
|
206
|
+
request_id: @feedback_request_id,
|
|
180
207
|
question: first[:question].to_s,
|
|
181
208
|
context: context,
|
|
182
209
|
options: first[:options] || [],
|
|
@@ -250,6 +277,7 @@ module Clacky
|
|
|
250
277
|
data[:cache_stats] = cache_stats if cache_stats
|
|
251
278
|
data[:awaiting_user_feedback] = awaiting_user_feedback if awaiting_user_feedback
|
|
252
279
|
data[:cost_source] = cost_source.to_s if cost_source
|
|
280
|
+
@last_complete = data
|
|
253
281
|
emit("complete", **data)
|
|
254
282
|
forward_to_subscribers do |sub|
|
|
255
283
|
sub.show_complete(iterations: iterations, cost: cost, duration: duration,
|
|
@@ -383,8 +411,11 @@ module Clacky
|
|
|
383
411
|
# The frontend's appendToolStdout will attach to the last visible .tool-item
|
|
384
412
|
# even when _liveLastToolItem is null (after the tab re-loaded).
|
|
385
413
|
def replay_live_state
|
|
386
|
-
countdown = @mutex.synchronize
|
|
387
|
-
|
|
414
|
+
countdown = @mutex.synchronize do
|
|
415
|
+
pending = @pending_feedback
|
|
416
|
+
pending ? { request_id: pending[:id], deadline_ms: pending[:deadline_ms] } : { resolved: true }
|
|
417
|
+
end
|
|
418
|
+
emit("feedback_countdown", **countdown)
|
|
388
419
|
return unless @live_progress_state
|
|
389
420
|
|
|
390
421
|
# Replay complete progress state (not just message).
|