openclacky 1.5.20 → 1.5.21

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +49 -0
  3. data/README.md +18 -0
  4. data/lib/clacky/agent/chunk_index.rb +8 -1
  5. data/lib/clacky/agent/feedback.rb +30 -0
  6. data/lib/clacky/agent/history_navigation.rb +17 -3
  7. data/lib/clacky/agent/hook_manager.rb +6 -1
  8. data/lib/clacky/agent/llm_caller.rb +1 -0
  9. data/lib/clacky/agent/message_compressor_helper.rb +28 -2
  10. data/lib/clacky/agent/session_serializer.rb +20 -2
  11. data/lib/clacky/agent.rb +56 -43
  12. data/lib/clacky/agent_config.rb +82 -4
  13. data/lib/clacky/billing/platform_billing.rb +5 -1
  14. data/lib/clacky/brand_config.rb +1 -1
  15. data/lib/clacky/cli.rb +3 -2
  16. data/lib/clacky/client.rb +5 -5
  17. data/lib/clacky/deepseek_web_tools.rb +156 -0
  18. data/lib/clacky/default_extensions/ext-studio/api/handler.rb +8 -0
  19. data/lib/clacky/default_extensions/ext-studio/panels/studio/view.js +21 -1
  20. data/lib/clacky/extension/loader.rb +5 -3
  21. data/lib/clacky/locales/en.rb +5 -5
  22. data/lib/clacky/locales/zh.rb +5 -5
  23. data/lib/clacky/message_format/anthropic.rb +20 -0
  24. data/lib/clacky/message_format/open_ai.rb +31 -14
  25. data/lib/clacky/message_history.rb +21 -3
  26. data/lib/clacky/platform_http_client.rb +6 -2
  27. data/lib/clacky/providers.rb +23 -14
  28. data/lib/clacky/search_config.rb +47 -13
  29. data/lib/clacky/server/browser_manager.rb +35 -5
  30. data/lib/clacky/server/channel/channel_manager.rb +6 -6
  31. data/lib/clacky/server/discover.rb +5 -4
  32. data/lib/clacky/server/http_server.rb +194 -47
  33. data/lib/clacky/server/model_prices.rb +5 -3
  34. data/lib/clacky/server/pid_files.rb +25 -0
  35. data/lib/clacky/server/server_master.rb +9 -3
  36. data/lib/clacky/server/session_registry.rb +19 -7
  37. data/lib/clacky/server/web_ui_controller.rb +61 -38
  38. data/lib/clacky/session_manager.rb +7 -1
  39. data/lib/clacky/skill.rb +1 -1
  40. data/lib/clacky/tools/ask_user.rb +4 -1
  41. data/lib/clacky/tools/base.rb +3 -2
  42. data/lib/clacky/tools/browser.rb +18 -2
  43. data/lib/clacky/tools/file_reader.rb +1 -1
  44. data/lib/clacky/tools/terminal/session_manager.rb +19 -1
  45. data/lib/clacky/tools/terminal.rb +65 -23
  46. data/lib/clacky/tools/trash_manager.rb +5 -3
  47. data/lib/clacky/tools/web_fetch.rb +81 -22
  48. data/lib/clacky/tools/web_search.rb +45 -4
  49. data/lib/clacky/ui2/ui_controller.rb +1 -1
  50. data/lib/clacky/ui_interface.rb +4 -3
  51. data/lib/clacky/utils/file_processor.rb +51 -0
  52. data/lib/clacky/utils/model_pricing.rb +132 -13
  53. data/lib/clacky/version.rb +1 -1
  54. data/lib/clacky/web/app.css +366 -105
  55. data/lib/clacky/web/components/chat-navigator.js +8 -1
  56. data/lib/clacky/web/components/mentions.js +2 -13
  57. data/lib/clacky/web/components/model-picker.js +3 -1
  58. data/lib/clacky/web/components/onboard.js +13 -10
  59. data/lib/clacky/web/features/archive/store.js +30 -16
  60. data/lib/clacky/web/features/archive/view.js +20 -10
  61. data/lib/clacky/web/features/backup/view.js +5 -5
  62. data/lib/clacky/web/features/new-session/store.js +3 -3
  63. data/lib/clacky/web/features/trash/store.js +10 -7
  64. data/lib/clacky/web/features/trash/view.js +9 -4
  65. data/lib/clacky/web/i18n.js +106 -30
  66. data/lib/clacky/web/index.html +90 -60
  67. data/lib/clacky/web/projects.js +56 -1
  68. data/lib/clacky/web/sessions.js +244 -131
  69. data/lib/clacky/web/settings.js +143 -8
  70. data/lib/clacky/web/ws-dispatcher.js +9 -13
  71. data/lib/clacky.rb +1 -0
  72. metadata +4 -1
@@ -110,6 +110,15 @@ module Clacky
110
110
 
111
111
  def show_tool_call(name, args)
112
112
  args_data = args.is_a?(String) ? (JSON.parse(args) rescue args) : args
113
+ if Clacky::Tools::AskUser.feedback_tool?(name)
114
+ questions = Clacky::Tools::AskUser.normalize_questions(args_data)
115
+ first = questions.first || {}
116
+ @events << { type: "request_feedback", session_id: @session_id,
117
+ request_id: Clacky::Tools::AskUser.fetch_key(args_data, :request_id),
118
+ context: Clacky::Tools::AskUser.fetch_key(args_data, :context).to_s,
119
+ question: first[:question].to_s, options: first[:options] || [], questions: questions }
120
+ return
121
+ end
113
122
  summary = tool_call_summary(name, args_data)
114
123
  @events << stamp({ type: "tool_call", session_id: @session_id, name: name, args: args_data, summary: summary })
115
124
  end
@@ -700,6 +709,7 @@ module Clacky
700
709
  when ["POST", "/api/browser/configure"] then api_browser_configure(req, res)
701
710
  when ["POST", "/api/browser/reload"] then api_browser_reload(res)
702
711
  when ["POST", "/api/browser/toggle"] then api_browser_toggle(res)
712
+ when ["POST", "/api/browser/settings"] then api_browser_settings(req, res)
703
713
  when ["GET", "/api/backup/status"] then api_backup_status(res)
704
714
  when ["POST", "/api/backup/run"] then api_backup_run(res)
705
715
  when ["GET", "/api/backup/download"] then api_backup_download(res)
@@ -732,8 +742,8 @@ module Clacky
732
742
  when ["DELETE", "/api/trash"] then api_trash_delete(req, res)
733
743
  when ["GET", "/api/trash/sessions"] then api_trash_sessions(req, res)
734
744
  when ["POST", "/api/trash/sessions/restore"] then api_trash_session_restore(req, res)
735
- when ["DELETE", "/api/trash/sessions"] then api_trash_sessions_delete(req, res)
736
- when ["DELETE", "/api/archive/sessions"] then api_archive_sessions_empty(res)
745
+ when ["POST", "/api/trash/sessions/delete"] then api_trash_sessions_delete(req, res)
746
+ when ["POST", "/api/archive/sessions/delete"] then api_archive_sessions_delete(req, res)
737
747
  when ["GET", "/api/profile"] then api_profile_get(res)
738
748
  when ["PUT", "/api/profile"] then api_profile_put(req, res)
739
749
  when ["GET", "/api/memories"] then api_memories_list(res)
@@ -870,6 +880,9 @@ module Clacky
870
880
  elsif method == "PATCH" && path.match?(%r{^/api/sessions/[^/]+/project$})
871
881
  session_id = path.sub("/api/sessions/", "").sub("/project", "")
872
882
  api_update_session_project(session_id, req, res)
883
+ elsif method == "PATCH" && path.match?(%r{^/api/sessions/[^/]+/permission_mode$})
884
+ session_id = path.split("/")[3]
885
+ api_switch_session_permission_mode(session_id, req, res)
873
886
  elsif method == "PATCH" && path.match?(%r{^/api/sessions/[^/]+/model$})
874
887
  session_id = path.sub("/api/sessions/", "").sub("/model", "")
875
888
  api_switch_session_model(session_id, req, res)
@@ -950,6 +963,9 @@ module Clacky
950
963
  elsif method == "PATCH" && path.match?(%r{^/api/projects/[^/]+$})
951
964
  project_id = path.sub("/api/projects/", "")
952
965
  api_update_project(project_id, req, res)
966
+ elsif method == "POST" && path.match?(%r{^/api/projects/[^/]+/sessions/archive$})
967
+ project_id = path.sub("/api/projects/", "").sub("/sessions/archive", "")
968
+ api_archive_project_sessions(project_id, res)
953
969
  elsif method == "DELETE" && path.match?(%r{^/api/projects/[^/]+/sessions$})
954
970
  project_id = path.sub("/api/projects/", "").sub("/sessions", "")
955
971
  api_delete_project_sessions(project_id, res)
@@ -968,6 +984,7 @@ module Clacky
968
984
  query = URI.decode_www_form(req.query_string.to_s).to_h
969
985
  limit = [query["limit"].to_i.then { |n| n > 0 ? n : 15 }, 50].min
970
986
  before = query["before"].to_s.strip.then { |v| v.empty? ? nil : v }
987
+ before_id = query["before_id"].to_s.strip.then { |v| v.empty? ? nil : v }
971
988
  q = query["q"].to_s.strip.then { |v| v.empty? ? nil : v }
972
989
  q_scope = query["q_scope"].to_s.strip.then { |v| %w[name content].include?(v) ? v : "name" }
973
990
  date = query["date"].to_s.strip.then { |v| v.empty? ? nil : v }
@@ -987,14 +1004,15 @@ module Clacky
987
1004
  # The archive view is the exception: it groups archived sessions by
988
1005
  # project by hand, so project membership must survive both filters.
989
1006
  exclude_project = archived ? false : (before ? true : !!type)
990
- sessions = @registry.list(limit: limit + 1, before: before, q: q, q_scope: q_scope, date: date, type: type, exclude_type: exclude_type, exclude_project: exclude_project, archived: archived)
1007
+ sessions = @registry.list(limit: limit + 1, before: before, before_id: before_id, q: q, q_scope: q_scope, date: date, type: type, exclude_type: exclude_type, exclude_project: exclude_project, archived: archived)
991
1008
 
992
1009
  pinned_part, non_pinned_part = sessions.partition { |s| s[:pinned] }
993
1010
  has_more = non_pinned_part.size > limit
994
1011
  non_pinned_part = non_pinned_part.first(limit)
995
1012
  sessions = pinned_part + non_pinned_part
996
1013
 
997
- payload = { sessions: sessions, has_more: has_more, groups: @registry.group_stats_all }
1014
+ payload = { sessions: sessions, has_more: has_more, next_cursor: non_pinned_part.last&.dig(:cursor),
1015
+ groups: @registry.group_stats_all }
998
1016
  # The archive page offers "delete all", so it needs the size of the whole
999
1017
  # archive — the paged `sessions` array only ever shows one page of it.
1000
1018
  payload[:archived_count] = @registry.archived_count if archived
@@ -1682,6 +1700,22 @@ module Clacky
1682
1700
  json_response(res, 500, { ok: false, error: e.message })
1683
1701
  end
1684
1702
 
1703
+ # POST /api/browser/settings
1704
+ # Body: { screenshot_max_base64_bytes: 300000 }
1705
+ # The field is optional — omitting it keeps the current value.
1706
+ def api_browser_settings(req, res)
1707
+ body = JSON.parse(req.body.to_s) rescue {}
1708
+
1709
+ settings = @browser_manager.update_screenshot_settings(
1710
+ max_base64_bytes: body["screenshot_max_base64_bytes"]
1711
+ )
1712
+ json_response(res, 200, { ok: true }.merge(settings))
1713
+ rescue ArgumentError => e
1714
+ json_response(res, 422, { ok: false, error: e.message })
1715
+ rescue StandardError => e
1716
+ json_response(res, 500, { ok: false, error: e.message })
1717
+ end
1718
+
1685
1719
  # GET /api/backup/status
1686
1720
  def api_backup_status(res)
1687
1721
  json_response(res, 200, BackupManager.status)
@@ -2293,38 +2327,66 @@ module Clacky
2293
2327
  end
2294
2328
 
2295
2329
  # PATCH /api/config/search
2296
- # Body: { provider: "tavily"|"", key?: "..." }
2330
+ # Body: { provider?: "tavily"|"deepseek_web"|"", key?: "..." }
2331
+ # Omitted fields keep their current value.
2297
2332
  def api_update_search_config(req, res)
2298
2333
  body = parse_json_body(req)
2299
2334
  return json_response(res, 400, { error: "Invalid JSON" }) unless body
2300
2335
 
2301
2336
  provider = body["provider"].to_s.strip
2302
- unless provider.empty? || Clacky::Utils::SearcherManager.path_for(provider)
2337
+ if body.key?("provider") && !search_provider_known?(provider)
2303
2338
  return json_response(res, 422, { error: "Unknown search provider: #{provider}" })
2304
2339
  end
2305
2340
 
2306
2341
  key = body["key"].to_s
2307
2342
  key = Clacky::SearchConfig.key if key.include?("****")
2308
2343
 
2309
- Clacky::SearchConfig.save(provider: provider, key: key)
2310
- json_response(res, 200, { ok: true, provider: provider })
2344
+ current = Clacky::SearchConfig.load
2345
+ final_provider = body.key?("provider") ? provider : current["provider"]
2346
+ final_key = body.key?("key") ? key : current["key"]
2347
+
2348
+ # deepseek_web falls back to the configured model key, so it may be
2349
+ # saved without one. Every other searcher is a script that reads the
2350
+ # key from CLACKY_SEARCH_KEY and cannot run without it.
2351
+ if !final_provider.empty? &&
2352
+ final_provider != Clacky::SearchConfig::DEEPSEEK_WEB &&
2353
+ final_key.to_s.strip.empty?
2354
+ return json_response(res, 422, {
2355
+ error: "API key is required for search provider '#{final_provider}'"
2356
+ })
2357
+ end
2358
+
2359
+ Clacky::SearchConfig.save(
2360
+ provider: body.key?("provider") ? provider : nil,
2361
+ key: body.key?("key") ? key : nil
2362
+ )
2363
+ config = Clacky::SearchConfig.load
2364
+ json_response(res, 200, { ok: true, provider: config["provider"] })
2311
2365
  rescue => e
2312
2366
  json_response(res, 422, { error: e.message })
2313
2367
  end
2314
2368
 
2369
+ private def search_provider_known?(provider)
2370
+ return true if provider.empty?
2371
+ return true if provider == Clacky::SearchConfig::DEEPSEEK_WEB
2372
+
2373
+ !Clacky::Utils::SearcherManager.path_for(provider).nil?
2374
+ end
2375
+
2315
2376
  # POST /api/config/search/test
2316
- # Runs the searcher with a fixed query so the user gets a definitive
2317
- # answer before saving, rather than discovering a bad key mid-task.
2377
+ # Runs the configured engine with a fixed query so the user gets a
2378
+ # definitive answer before saving, rather than discovering a bad key mid-task.
2318
2379
  def api_test_search_config(req, res)
2319
2380
  body = parse_json_body(req) || {}
2320
2381
  provider = body["provider"].to_s.strip
2321
2382
  return json_response(res, 200, { ok: false, message: "provider is required" }) if provider.empty?
2322
2383
 
2323
- script = Clacky::Utils::SearcherManager.path_for(provider)
2324
- return json_response(res, 200, { ok: false, message: "searcher not found: #{provider}" }) unless script
2325
-
2326
2384
  key = body["key"].to_s
2327
2385
  key = Clacky::SearchConfig.key if key.empty? || key.include?("****")
2386
+ return test_deepseek_web_search(res, key) if provider == Clacky::SearchConfig::DEEPSEEK_WEB
2387
+
2388
+ script = Clacky::Utils::SearcherManager.path_for(provider)
2389
+ return json_response(res, 200, { ok: false, message: "searcher not found: #{provider}" }) unless script
2328
2390
 
2329
2391
  stdout, stderr, status = Clacky::Utils::ParserManager.capture3_with_timeout(
2330
2392
  { "CLACKY_SEARCH_KEY" => key },
@@ -2353,6 +2415,23 @@ module Clacky
2353
2415
  json_response(res, 200, { ok: false, message: e.message })
2354
2416
  end
2355
2417
 
2418
+ private def test_deepseek_web_search(res, key)
2419
+ key = @agent_config.deepseek_web_key.to_s if key.empty?
2420
+ if key.empty?
2421
+ return json_response(res, 200, {
2422
+ ok: false, message: "No DeepSeek key: fill one in or configure a DeepSeek / OpenClacky model"
2423
+ })
2424
+ end
2425
+
2426
+ results = Clacky::DeepSeekWebTools.search(
2427
+ query: "openclacky", count: 1, api_key: key,
2428
+ base_url: @agent_config.deepseek_web_base_url(key)
2429
+ )
2430
+ json_response(res, 200, { ok: true, message: "Returned #{results.size} result(s)" })
2431
+ rescue => e
2432
+ json_response(res, 200, { ok: false, message: e.message })
2433
+ end
2434
+
2356
2435
  # POST /api/internal/ocr-image
2357
2436
  # Internal endpoint used by parser scripts (e.g. pdf_parser_vlm.py) to
2358
2437
  # transcribe a single image via the configured OCR sidecar. Localhost-
@@ -6034,20 +6113,23 @@ module Clacky
6034
6113
  json_response(res, 200, { ok: true, session_id: session_id })
6035
6114
  end
6036
6115
 
6037
- # DELETE /api/trash/sessions?days_old=N
6038
- # Bulk: permanently delete sessions older than N days (default: 7).
6116
+ # POST /api/trash/sessions/delete
6117
+ # Body: { session_ids: ["...", ...] }
6118
+ # Bulk: permanently delete exactly these sessions. The caller decides the
6119
+ # scope (the page's filters live in the browser), so an empty list is
6120
+ # rejected rather than read as "everything".
6039
6121
  private def api_trash_sessions_delete(req, res)
6040
- query = URI.decode_www_form(req.query_string.to_s).to_h
6041
- days_old = query["days_old"].to_s.strip
6042
- days_i = days_old.empty? ? 7 : days_old.to_i
6122
+ raw = parse_json_body(req)["session_ids"]
6123
+ ids = raw.is_a?(Array) ? raw.map { |id| id.to_s.strip }.reject(&:empty?) : []
6043
6124
 
6044
- deleted = @session_manager.cleanup_trash(days: days_i)
6125
+ if ids.empty?
6126
+ json_response(res, 400, { ok: false, error: "session_ids is required" })
6127
+ return
6128
+ end
6045
6129
 
6046
- json_response(res, 200, {
6047
- ok: true,
6048
- deleted_count: deleted,
6049
- days_old: days_i
6050
- })
6130
+ deleted = @session_manager.permanent_delete_trash_sessions(ids)
6131
+
6132
+ json_response(res, 200, { ok: true, deleted_count: deleted })
6051
6133
  end
6052
6134
 
6053
6135
  # ── Trash helpers (private) ─────────────────────────────────────
@@ -7250,6 +7332,45 @@ module Clacky
7250
7332
  json_response(res, 500, { error: e.message })
7251
7333
  end
7252
7334
 
7335
+ # POST /api/projects/:id/sessions/archive - archive every live session in a project
7336
+ def api_archive_project_sessions(project_id, res)
7337
+ return json_response(res, 404, { error: "Project not found" }) unless @project_manager.find(project_id)
7338
+
7339
+ archived = _archive_project_sessions(project_id)
7340
+ json_response(res, 200, { ok: true, archived: archived })
7341
+ rescue => e
7342
+ json_response(res, 500, { error: e.message })
7343
+ end
7344
+
7345
+ # Sessions already in the archive keep their original archived_at, so the
7346
+ # archive page stays ordered by when the user actually put them away.
7347
+ private def _archive_project_sessions(project_id)
7348
+ archived = 0
7349
+ now = Time.now.iso8601
7350
+
7351
+ @session_manager.all_sessions.each do |session_data|
7352
+ next unless session_data[:project_id].to_s == project_id
7353
+ next if session_data[:archived_at]
7354
+
7355
+ sid = session_data[:session_id]
7356
+ live = nil
7357
+ @registry.with_session(sid) { |s| live = s[:agent] }
7358
+
7359
+ if live
7360
+ live.archived_at = now
7361
+ @session_manager.save(live.to_session_data(updated_at: Time.now))
7362
+ else
7363
+ # Untouched on disk: patch the loaded row rather than restoring an
7364
+ # agent for every session in the project just to stamp one field.
7365
+ @session_manager.save(session_data.merge(archived_at: now, updated_at: now))
7366
+ end
7367
+
7368
+ broadcast(sid, { type: "session_updated", session_id: sid, archived_at: now })
7369
+ archived += 1
7370
+ end
7371
+ archived
7372
+ end
7373
+
7253
7374
  private def _delete_project_sessions(project_id)
7254
7375
  deleted = 0
7255
7376
  @session_manager.all_sessions.each do |session_data|
@@ -7346,6 +7467,24 @@ module Clacky
7346
7467
  json_response(res, 500, { error: e.message })
7347
7468
  end
7348
7469
 
7470
+ # PATCH /api/sessions/:id/permission_mode — session-only, never global.
7471
+ def api_switch_session_permission_mode(session_id, req, res)
7472
+ mode = parse_json_body(req)["permission_mode"].to_s
7473
+ unless %w[confirm_all auto_approve].include?(mode)
7474
+ return json_response(res, 400, { error: "Invalid permission mode" })
7475
+ end
7476
+ return json_response(res, 404, { error: "Session not found" }) unless @registry.ensure(session_id)
7477
+
7478
+ @registry.with_session(session_id) do |session|
7479
+ agent = session[:agent]
7480
+ return json_response(res, 404, { error: "Session not found" }) unless agent
7481
+ agent.permission_mode = mode
7482
+ @session_manager.save(agent.to_session_data(updated_at: Time.now))
7483
+ end
7484
+ broadcast_session_update(session_id)
7485
+ json_response(res, 200, { ok: true, permission_mode: mode })
7486
+ end
7487
+
7349
7488
  def api_switch_session_model(session_id, req, res)
7350
7489
  body = parse_json_body(req)
7351
7490
  model_id = body["model_id"].to_s.strip
@@ -7589,15 +7728,21 @@ module Clacky
7589
7728
  json_response(res, 201, { session: @registry.snapshot(fork_id) })
7590
7729
  end
7591
7730
 
7592
- # DELETE /api/archive/sessions
7593
- # Empties the archive: every archived chat is soft-deleted and lands in
7594
- # the recycle bin, never destroyed for good. Archiving keeps a chat out of
7595
- # the sidebar, so clearing the archive means the user is done with all of
7596
- # them. The toolbar's search and filters deliberately do not narrow this —
7597
- # the button says "delete all", and the confirmation counts what this
7598
- # call is about to take out.
7599
- def api_archive_sessions_empty(res)
7600
- ids = @registry.list(archived: true).map { |s| s[:id] }
7731
+ # POST /api/archive/sessions/delete — soft-deletes into the recycle bin.
7732
+ # With `session_ids`, only those still-archived chats go; without a body,
7733
+ # the whole archive (the page is paged, so only the server can list it).
7734
+ # An explicit empty list is rejected so "matches nothing" never means "all".
7735
+ def api_archive_sessions_delete(req, res)
7736
+ body = parse_json_body(req)
7737
+ ids = @registry.list(archived: true).map { |s| s[:id] }
7738
+
7739
+ if !body.is_a?(Hash) || body.key?("session_ids")
7740
+ raw = body.is_a?(Hash) ? body["session_ids"] : nil
7741
+ requested = raw.is_a?(Array) ? raw.map { |id| id.to_s.strip }.reject(&:empty?) : []
7742
+ return json_response(res, 400, { ok: false, error: "session_ids must not be empty" }) if requested.empty?
7743
+
7744
+ ids &= requested
7745
+ end
7601
7746
 
7602
7747
  # `list` just read these ids off disk, so skip the per-id existence
7603
7748
  # probe — `load` re-parses every session file for each call.
@@ -7841,14 +7986,16 @@ module Clacky
7841
7986
  raw_images = (msg["images"] || []).map do |data_url|
7842
7987
  { "data_url" => data_url, "name" => "image.jpg", "mime_type" => "image/jpeg" }
7843
7988
  end
7844
- handle_user_message(session_id, msg["content"].to_s, (msg["files"] || []) + raw_images, references: msg["references"] || [], feedback: msg["feedback"]&.transform_keys(&:to_sym))
7989
+ handle_user_message(session_id, msg["content"].to_s, (msg["files"] || []) + raw_images, references: msg["references"] || [])
7845
7990
 
7846
- when "feedback_response"
7991
+ when "feedback_intervene"
7847
7992
  session_id = msg["session_id"] || conn.session_id
7848
- @registry.get(session_id)&.dig(:ui)&.deliver_feedback(
7849
- msg["content"].to_s, files: msg["files"] || [], display_text: msg["display_text"],
7850
- references_display: msg["references"] || [],
7851
- reference_contexts: build_reference_contexts(msg["references"] || []))
7993
+ session = @registry.get(session_id)
7994
+ if session&.dig(:ui)&.intervene_feedback(msg["request_id"])
7995
+ # Finish the handoff before processing the next message on this connection.
7996
+ # An immediate submission then uses the ordinary awaiting-feedback path.
7997
+ session[:thread]&.join
7998
+ end
7852
7999
 
7853
8000
  when "confirmation"
7854
8001
  session_id = msg["session_id"] || conn.session_id
@@ -7868,7 +8015,9 @@ module Clacky
7868
8015
  pinned_part, non_pinned_part = page.partition { |s| s[:pinned] }
7869
8016
  non_pinned_limit = [10 - pinned_part.size, 0].max
7870
8017
  has_more = non_pinned_part.size > non_pinned_limit
7871
- all_sessions = pinned_part + non_pinned_part.first(non_pinned_limit)
8018
+ non_pinned_part = non_pinned_part.first(non_pinned_limit)
8019
+ next_cursor = non_pinned_part.last&.dig(:cursor)
8020
+ all_sessions = pinned_part + non_pinned_part
7872
8021
  projects = @project_manager.all
7873
8022
  if projects.any?
7874
8023
  project_ids = projects.map { |p| p[:id] }
@@ -7880,7 +8029,7 @@ module Clacky
7880
8029
  paged_ids = all_sessions.map { |s| s[:id] }.to_set
7881
8030
  all_sessions += project_sessions.reject { |s| paged_ids.include?(s[:id]) }
7882
8031
  end
7883
- conn.send_json(type: "session_list", sessions: all_sessions, has_more: has_more,
8032
+ conn.send_json(type: "session_list", sessions: all_sessions, has_more: has_more, next_cursor: next_cursor,
7884
8033
  groups: groups, projects: projects)
7885
8034
 
7886
8035
  when "run_task"
@@ -7948,12 +8097,10 @@ module Clacky
7948
8097
  end
7949
8098
  end
7950
8099
 
7951
- def handle_user_message(session_id, content, files = [], references: [], feedback: nil)
8100
+ def handle_user_message(session_id, content, files = [], references: [])
7952
8101
  return unless @registry.exist?(session_id)
7953
8102
 
7954
8103
  session = @registry.get(session_id)
7955
- feedback_options = feedback ? { feedback: feedback } : {}
7956
-
7957
8104
  mode = @agent_config.input_behavior
7958
8105
  queued = false
7959
8106
  queued_created_at = nil
@@ -7961,7 +8108,7 @@ module Clacky
7961
8108
  if s[:status] == :running && %w[queue steer].include?(mode)
7962
8109
  queued_created_at = Time.now.to_f
7963
8110
  s[:agent].enqueue_input(content, delivery: mode.to_sym, files: files, references_display: references,
7964
- reference_contexts: build_reference_contexts(references), created_at: queued_created_at, **feedback_options)
8111
+ reference_contexts: build_reference_contexts(references), created_at: queued_created_at)
7965
8112
  queued = true
7966
8113
  end
7967
8114
  end
@@ -7984,7 +8131,7 @@ module Clacky
7984
8131
  if old_thread && !old_thread.join(2)
7985
8132
  retained_created_at = Time.now.to_f
7986
8133
  session[:agent].enqueue_input(content, files: files, references_display: references,
7987
- reference_contexts: build_reference_contexts(references), created_at: retained_created_at, **feedback_options)
8134
+ reference_contexts: build_reference_contexts(references), created_at: retained_created_at)
7988
8135
  broadcast(session_id, { type: "input_enqueued", session_id: session_id, created_at: retained_created_at })
7989
8136
  broadcast(session_id, { type: "input_queue_notice", session_id: session_id, key: "chat.input.stoppingDelayed" })
7990
8137
  return
@@ -8042,7 +8189,7 @@ module Clacky
8042
8189
  # "Thinking..." forever. A web-originated turn bound to an IM channel gets a
8043
8190
  # card too, so tools/narration render like a native IM turn (#603).
8044
8191
  @channel_manager.channel_ui_for_session(session_id)&.start_task(reply_to: nil)
8045
- agent.run(content, files: files, reference_contexts: reference_contexts, created_at: msg_created_at, references_display: references, **feedback_options)
8192
+ agent.run(content, files: files, reference_contexts: reference_contexts, created_at: msg_created_at, references_display: references)
8046
8193
  end
8047
8194
  end
8048
8195
 
@@ -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
@@ -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