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.
Files changed (97) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +110 -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 +8 -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/default_extensions/git/panels/git/view.js +1911 -398
  21. data/lib/clacky/default_extensions/preview/panels/preview/view.js +199 -34
  22. data/lib/clacky/extension/loader.rb +5 -3
  23. data/lib/clacky/locales/en.rb +5 -5
  24. data/lib/clacky/locales/zh.rb +5 -5
  25. data/lib/clacky/message_format/anthropic.rb +20 -0
  26. data/lib/clacky/message_format/open_ai.rb +31 -14
  27. data/lib/clacky/message_history.rb +21 -3
  28. data/lib/clacky/platform_http_client.rb +6 -2
  29. data/lib/clacky/providers.rb +66 -31
  30. data/lib/clacky/search_config.rb +71 -15
  31. data/lib/clacky/server/browser_manager.rb +35 -5
  32. data/lib/clacky/server/channel/channel_manager.rb +6 -6
  33. data/lib/clacky/server/discover.rb +5 -4
  34. data/lib/clacky/server/git_panel.rb +360 -34
  35. data/lib/clacky/server/http_server.rb +334 -89
  36. data/lib/clacky/server/model_prices.rb +5 -3
  37. data/lib/clacky/server/pid_files.rb +25 -0
  38. data/lib/clacky/server/preview.rb +118 -17
  39. data/lib/clacky/server/scheduler.rb +81 -2
  40. data/lib/clacky/server/server_master.rb +9 -3
  41. data/lib/clacky/server/session_registry.rb +19 -7
  42. data/lib/clacky/server/web_ui_controller.rb +78 -47
  43. data/lib/clacky/session_manager.rb +58 -5
  44. data/lib/clacky/skill.rb +1 -1
  45. data/lib/clacky/tools/ask_user.rb +4 -1
  46. data/lib/clacky/tools/base.rb +3 -2
  47. data/lib/clacky/tools/browser.rb +18 -2
  48. data/lib/clacky/tools/file_reader.rb +1 -1
  49. data/lib/clacky/tools/terminal/session_manager.rb +19 -1
  50. data/lib/clacky/tools/terminal.rb +65 -23
  51. data/lib/clacky/tools/trash_manager.rb +5 -3
  52. data/lib/clacky/tools/web_fetch.rb +81 -22
  53. data/lib/clacky/tools/web_search.rb +45 -4
  54. data/lib/clacky/ui2/ui_controller.rb +1 -1
  55. data/lib/clacky/ui_interface.rb +4 -3
  56. data/lib/clacky/utils/file_processor.rb +51 -0
  57. data/lib/clacky/utils/mac_app_detector.rb +8 -7
  58. data/lib/clacky/utils/model_pricing.rb +192 -15
  59. data/lib/clacky/utils/windows_app_detector.rb +3 -1
  60. data/lib/clacky/version.rb +1 -1
  61. data/lib/clacky/web/app.css +1019 -204
  62. data/lib/clacky/web/app.js +59 -6
  63. data/lib/clacky/web/auth.js +1 -1
  64. data/lib/clacky/web/components/chat-navigator.js +8 -1
  65. data/lib/clacky/web/components/composer.js +107 -8
  66. data/lib/clacky/web/components/image-zoom.js +165 -0
  67. data/lib/clacky/web/components/mentions.js +2 -13
  68. data/lib/clacky/web/components/model-picker.js +9 -5
  69. data/lib/clacky/web/components/notify.js +2 -2
  70. data/lib/clacky/web/components/onboard.js +13 -10
  71. data/lib/clacky/web/core/aside.js +60 -3
  72. data/lib/clacky/web/features/archive/store.js +30 -16
  73. data/lib/clacky/web/features/archive/view.js +20 -10
  74. data/lib/clacky/web/features/backup/view.js +5 -5
  75. data/lib/clacky/web/features/billing/view.js +2 -2
  76. data/lib/clacky/web/features/channels/view.js +5 -2
  77. data/lib/clacky/web/features/extensions/view.js +2 -2
  78. data/lib/clacky/web/features/new-session/store.js +82 -5
  79. data/lib/clacky/web/features/new-session/view.js +496 -58
  80. data/lib/clacky/web/features/skills/store.js +1 -1
  81. data/lib/clacky/web/features/tasks/view.js +162 -51
  82. data/lib/clacky/web/features/trash/store.js +10 -7
  83. data/lib/clacky/web/features/trash/view.js +9 -4
  84. data/lib/clacky/web/features/workspace/store.js +13 -6
  85. data/lib/clacky/web/features/workspace/view.js +153 -163
  86. data/lib/clacky/web/i18n.js +342 -84
  87. data/lib/clacky/web/index.html +262 -216
  88. data/lib/clacky/web/projects.js +93 -5
  89. data/lib/clacky/web/sessions.js +423 -234
  90. data/lib/clacky/web/settings.js +169 -16
  91. data/lib/clacky/web/skills.js +2 -2
  92. data/lib/clacky/web/theme.js +17 -2
  93. data/lib/clacky/web/utils.js +17 -2
  94. data/lib/clacky/web/weixin-qr.html +2 -1
  95. data/lib/clacky/web/ws-dispatcher.js +17 -42
  96. data/lib/clacky.rb +1 -0
  97. 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 post-promotion; `discount` is present when a rule applied,
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 sandboxed
51
- # without allow-same-origin, so the host page cannot read its location
52
- # (and the iframe's src property does not follow internal navigations).
53
- # Each served page therefore announces its own URL to the parent.
54
- PREVIEW_NAV_REPORT = '<script>(function(){try{parent.postMessage({__clackyPreview:1,href:location.href},"*")}catch(e){}})()</script>'
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 and Set-Cookie must not cross the proxy boundary
59
- # (WEBrick recomputes the former, cookies would leak the upstream's
60
- # domain into ours). CSP frame-ancestors is handled separately so the
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 preview_body(abs)
193
- body = File.binread(abs)
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" => schedule["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
- require "tmpdir"
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(Dir.tmpdir, "clacky-master-#{@port}.pid")
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(Dir.tmpdir, "clacky-master-#{port}.pid")
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: ISO8601 cursor — only sessions with created_at < 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 field must match the sort key in all_sessions (updated_at,
301
- # falling back to created_at for legacy rows).
302
- non_pinned = non_pinned.select { |s| (s[:updated_at] || s[:created_at] || "") < before } if before
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
- 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
+ # 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
- 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
80
+ return :intervened if @feedback_permission_mode == :confirm_all
81
+ @pending_feedback = pending
87
82
  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)
83
+ emit("feedback_countdown", request_id: pending[:id], deadline_ms: pending[:deadline_ms])
99
84
  @mutex.synchronize do
100
- until pending[:answer]
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.merge!(answer: recommended_label, resolution: "timeout")
88
+ pending[:result] = :timeout
104
89
  else
105
90
  pending[:cond].wait(@mutex, remaining)
106
91
  end
107
92
  end
108
- [pending[:answer], pending.fetch(:options, {})]
93
+ pending[:result]
109
94
  end
110
95
  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])
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
- # Build ev.images for the frontend renderer (history_user_message):
128
- # - Images with data_url → pass the data_url directly (<img> thumbnail)
129
- # - Disk image files (type=="image", has path) → /api/local-image proxy URL
130
- # - Disk files (PDF, doc, etc., no data_url, no image path) → "pdf:name" sentinel
131
- rendered = Array(files).filter_map do |f|
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 type.to_s == "image" && path && File.exist?(path.to_s)
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
- "pdf:#{name}"
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] = rendered unless rendered.empty?
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 { @pending_feedback&.dig(:countdown) }
387
- emit("feedback_countdown_started", **countdown) if countdown
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).