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
@@ -75,26 +75,38 @@ module Clacky
75
75
  ev[:skill_command_display] = skill_command_display if skill_command_display
76
76
  ev[:editable] = false unless editable
77
77
  ev[:references] = references unless Array(references).empty?
78
- rendered = Array(files).filter_map do |f|
78
+ rendered_images = []
79
+ rendered_attachments = []
80
+ Array(files).each do |f|
81
+ next if f[:reference] || f["reference"]
82
+
79
83
  url = f[:data_url] || f["data_url"]
80
84
  name = f[:name] || f["name"]
81
85
  path = f[:path] || f["path"]
82
86
  type = f[:type] || f["type"] || ""
87
+ mime = f[:mime_type] || f["mime_type"]
88
+ image = type.to_s == "image" || mime.to_s.start_with?("image/")
83
89
 
84
90
  if url
85
- url
86
- elsif type.to_s == "image" && path && File.exist?(path.to_s)
91
+ rendered_images << url
92
+ elsif image && path && File.exist?(path.to_s)
87
93
  # Serve via the /api/local-image proxy instead of inlining a base64
88
94
  # data URL. Inlining forced a synchronous disk-read + full base64
89
95
  # encode + downscale on every history replay (2-3s lag for sessions
90
96
  # with downgraded text-model images). The proxy lets the browser
91
97
  # lazy-load + cache the image, keeping the replay response tiny.
92
- "/api/local-image?path=#{CGI.escape(path.to_s)}&v=#{File.mtime(path.to_s).to_i}"
98
+ rendered_images << "/api/local-image?path=#{CGI.escape(path.to_s)}&v=#{File.mtime(path.to_s).to_i}"
99
+ elsif image && name
100
+ rendered_images << "expired:#{name}"
93
101
  elsif name
94
- type.to_s == "image" ? "expired:#{name}" : "pdf:#{name}"
102
+ attachment_type = type.to_s.empty? ? (mime || "file") : type
103
+ attachment = { name: name, type: attachment_type }
104
+ attachment[:path] = path if path && File.exist?(path.to_s)
105
+ rendered_attachments << attachment
95
106
  end
96
107
  end
97
- ev[:images] = rendered unless rendered.empty?
108
+ ev[:images] = rendered_images unless rendered_images.empty?
109
+ ev[:attachments] = rendered_attachments unless rendered_attachments.empty?
98
110
  @events << ev
99
111
  end
100
112
 
@@ -110,6 +122,15 @@ module Clacky
110
122
 
111
123
  def show_tool_call(name, args)
112
124
  args_data = args.is_a?(String) ? (JSON.parse(args) rescue args) : args
125
+ if Clacky::Tools::AskUser.feedback_tool?(name)
126
+ questions = Clacky::Tools::AskUser.normalize_questions(args_data)
127
+ first = questions.first || {}
128
+ @events << { type: "request_feedback", session_id: @session_id,
129
+ request_id: Clacky::Tools::AskUser.fetch_key(args_data, :request_id),
130
+ context: Clacky::Tools::AskUser.fetch_key(args_data, :context).to_s,
131
+ question: first[:question].to_s, options: first[:options] || [], questions: questions }
132
+ return
133
+ end
113
134
  summary = tool_call_summary(name, args_data)
114
135
  @events << stamp({ type: "tool_call", session_id: @session_id, name: name, args: args_data, summary: summary })
115
136
  end
@@ -700,6 +721,7 @@ module Clacky
700
721
  when ["POST", "/api/browser/configure"] then api_browser_configure(req, res)
701
722
  when ["POST", "/api/browser/reload"] then api_browser_reload(res)
702
723
  when ["POST", "/api/browser/toggle"] then api_browser_toggle(res)
724
+ when ["POST", "/api/browser/settings"] then api_browser_settings(req, res)
703
725
  when ["GET", "/api/backup/status"] then api_backup_status(res)
704
726
  when ["POST", "/api/backup/run"] then api_backup_run(res)
705
727
  when ["GET", "/api/backup/download"] then api_backup_download(res)
@@ -732,8 +754,8 @@ module Clacky
732
754
  when ["DELETE", "/api/trash"] then api_trash_delete(req, res)
733
755
  when ["GET", "/api/trash/sessions"] then api_trash_sessions(req, res)
734
756
  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)
757
+ when ["POST", "/api/trash/sessions/delete"] then api_trash_sessions_delete(req, res)
758
+ when ["POST", "/api/archive/sessions/delete"] then api_archive_sessions_delete(req, res)
737
759
  when ["GET", "/api/profile"] then api_profile_get(res)
738
760
  when ["PUT", "/api/profile"] then api_profile_put(req, res)
739
761
  when ["GET", "/api/memories"] then api_memories_list(res)
@@ -828,9 +850,6 @@ module Clacky
828
850
  session_id = path[%r{^/api/sessions/([^/]+)/git/}, 1]
829
851
  action = path[%r{/git/([a-z]+)$}, 1]
830
852
  api_session_git(session_id, action, req, res)
831
- elsif method == "POST" && path.match?(%r{^/api/sessions/[^/]+/git/commit$})
832
- session_id = path[%r{^/api/sessions/([^/]+)/git/}, 1]
833
- api_session_git_commit(session_id, req, res)
834
853
  elsif method == "POST" && path.match?(%r{^/api/sessions/[^/]+/git/restore$})
835
854
  session_id = path[%r{^/api/sessions/([^/]+)/git/}, 1]
836
855
  api_session_git_restore(session_id, req, res)
@@ -870,6 +889,9 @@ module Clacky
870
889
  elsif method == "PATCH" && path.match?(%r{^/api/sessions/[^/]+/project$})
871
890
  session_id = path.sub("/api/sessions/", "").sub("/project", "")
872
891
  api_update_session_project(session_id, req, res)
892
+ elsif method == "PATCH" && path.match?(%r{^/api/sessions/[^/]+/permission_mode$})
893
+ session_id = path.split("/")[3]
894
+ api_switch_session_permission_mode(session_id, req, res)
873
895
  elsif method == "PATCH" && path.match?(%r{^/api/sessions/[^/]+/model$})
874
896
  session_id = path.sub("/api/sessions/", "").sub("/model", "")
875
897
  api_switch_session_model(session_id, req, res)
@@ -947,9 +969,18 @@ module Clacky
947
969
  elsif method == "DELETE" && path.match?(%r{^/api/memories/[^/]+$})
948
970
  filename = URI.decode_www_form_component(path.sub("/api/memories/", ""))
949
971
  api_memories_delete(filename, res)
972
+ elsif method == "GET" && path.match?(%r{^/api/projects/[^/]+/git$})
973
+ project_id = path.sub("/api/projects/", "").sub("/git", "")
974
+ api_project_git(project_id, req, res)
975
+ elsif method == "POST" && path.match?(%r{^/api/projects/[^/]+/git/checkout$})
976
+ project_id = path.sub("/api/projects/", "").sub("/git/checkout", "")
977
+ api_project_git_checkout(project_id, req, res)
950
978
  elsif method == "PATCH" && path.match?(%r{^/api/projects/[^/]+$})
951
979
  project_id = path.sub("/api/projects/", "")
952
980
  api_update_project(project_id, req, res)
981
+ elsif method == "POST" && path.match?(%r{^/api/projects/[^/]+/sessions/archive$})
982
+ project_id = path.sub("/api/projects/", "").sub("/sessions/archive", "")
983
+ api_archive_project_sessions(project_id, res)
953
984
  elsif method == "DELETE" && path.match?(%r{^/api/projects/[^/]+/sessions$})
954
985
  project_id = path.sub("/api/projects/", "").sub("/sessions", "")
955
986
  api_delete_project_sessions(project_id, res)
@@ -968,6 +999,7 @@ module Clacky
968
999
  query = URI.decode_www_form(req.query_string.to_s).to_h
969
1000
  limit = [query["limit"].to_i.then { |n| n > 0 ? n : 15 }, 50].min
970
1001
  before = query["before"].to_s.strip.then { |v| v.empty? ? nil : v }
1002
+ before_id = query["before_id"].to_s.strip.then { |v| v.empty? ? nil : v }
971
1003
  q = query["q"].to_s.strip.then { |v| v.empty? ? nil : v }
972
1004
  q_scope = query["q_scope"].to_s.strip.then { |v| %w[name content].include?(v) ? v : "name" }
973
1005
  date = query["date"].to_s.strip.then { |v| v.empty? ? nil : v }
@@ -987,14 +1019,15 @@ module Clacky
987
1019
  # The archive view is the exception: it groups archived sessions by
988
1020
  # project by hand, so project membership must survive both filters.
989
1021
  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)
1022
+ 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
1023
 
992
1024
  pinned_part, non_pinned_part = sessions.partition { |s| s[:pinned] }
993
1025
  has_more = non_pinned_part.size > limit
994
1026
  non_pinned_part = non_pinned_part.first(limit)
995
1027
  sessions = pinned_part + non_pinned_part
996
1028
 
997
- payload = { sessions: sessions, has_more: has_more, groups: @registry.group_stats_all }
1029
+ payload = { sessions: sessions, has_more: has_more, next_cursor: non_pinned_part.last&.dig(:cursor),
1030
+ groups: @registry.group_stats_all }
998
1031
  # The archive page offers "delete all", so it needs the size of the whole
999
1032
  # archive — the paged `sessions` array only ever shows one page of it.
1000
1033
  payload[:archived_count] = @registry.archived_count if archived
@@ -1682,6 +1715,22 @@ module Clacky
1682
1715
  json_response(res, 500, { ok: false, error: e.message })
1683
1716
  end
1684
1717
 
1718
+ # POST /api/browser/settings
1719
+ # Body: { screenshot_max_base64_bytes: 300000 }
1720
+ # The field is optional — omitting it keeps the current value.
1721
+ def api_browser_settings(req, res)
1722
+ body = JSON.parse(req.body.to_s) rescue {}
1723
+
1724
+ settings = @browser_manager.update_screenshot_settings(
1725
+ max_base64_bytes: body["screenshot_max_base64_bytes"]
1726
+ )
1727
+ json_response(res, 200, { ok: true }.merge(settings))
1728
+ rescue ArgumentError => e
1729
+ json_response(res, 422, { ok: false, error: e.message })
1730
+ rescue StandardError => e
1731
+ json_response(res, 500, { ok: false, error: e.message })
1732
+ end
1733
+
1685
1734
  # GET /api/backup/status
1686
1735
  def api_backup_status(res)
1687
1736
  json_response(res, 200, BackupManager.status)
@@ -2287,45 +2336,77 @@ module Clacky
2287
2336
  json_response(res, 200, {
2288
2337
  ok: true,
2289
2338
  provider: config["provider"],
2290
- key_masked: config["key"].empty? ? nil : mask_api_key(config["key"]),
2339
+ keys_masked: masked_search_keys(config),
2291
2340
  available: Clacky::Utils::SearcherManager.available
2292
2341
  })
2293
2342
  end
2294
2343
 
2295
2344
  # PATCH /api/config/search
2296
- # Body: { provider: "tavily"|"", key?: "..." }
2345
+ # Body: { provider?: "tavily"|"deepseek_web"|"", key?: "..." }
2346
+ # Omitted fields keep their current value.
2297
2347
  def api_update_search_config(req, res)
2298
2348
  body = parse_json_body(req)
2299
2349
  return json_response(res, 400, { error: "Invalid JSON" }) unless body
2300
2350
 
2301
2351
  provider = body["provider"].to_s.strip
2302
- unless provider.empty? || Clacky::Utils::SearcherManager.path_for(provider)
2352
+ if body.key?("provider") && !search_provider_known?(provider)
2303
2353
  return json_response(res, 422, { error: "Unknown search provider: #{provider}" })
2304
2354
  end
2305
2355
 
2356
+ current = Clacky::SearchConfig.load
2357
+ final_provider = body.key?("provider") ? provider : current["provider"]
2358
+ # A masked value only ever stands for the same provider's stored key.
2306
2359
  key = body["key"].to_s
2307
- key = Clacky::SearchConfig.key if key.include?("****")
2360
+ key = Clacky::SearchConfig.key_for(final_provider) if key.include?("****")
2361
+ final_key = body.key?("key") ? key : Clacky::SearchConfig.key_for(final_provider)
2362
+
2363
+ # deepseek_web falls back to the configured model key, so it may be
2364
+ # saved without one. Every other searcher is a script that reads the
2365
+ # key from CLACKY_SEARCH_KEY and cannot run without it.
2366
+ if !final_provider.empty? &&
2367
+ final_provider != Clacky::SearchConfig::DEEPSEEK_WEB &&
2368
+ final_key.to_s.strip.empty?
2369
+ return json_response(res, 422, {
2370
+ error: "API key is required for search provider '#{final_provider}'"
2371
+ })
2372
+ end
2308
2373
 
2309
- Clacky::SearchConfig.save(provider: provider, key: key)
2310
- json_response(res, 200, { ok: true, provider: provider })
2374
+ Clacky::SearchConfig.save(
2375
+ provider: body.key?("provider") ? provider : nil,
2376
+ key: body.key?("key") ? key : nil
2377
+ )
2378
+ config = Clacky::SearchConfig.load
2379
+ json_response(res, 200, { ok: true, provider: config["provider"], keys_masked: masked_search_keys(config) })
2311
2380
  rescue => e
2312
2381
  json_response(res, 422, { error: e.message })
2313
2382
  end
2314
2383
 
2384
+ private def masked_search_keys(config)
2385
+ config["keys"].transform_values { |key| mask_api_key(key) }
2386
+ end
2387
+
2388
+ private def search_provider_known?(provider)
2389
+ return true if provider.empty?
2390
+ return true if provider == Clacky::SearchConfig::DEEPSEEK_WEB
2391
+
2392
+ !Clacky::Utils::SearcherManager.path_for(provider).nil?
2393
+ end
2394
+
2315
2395
  # 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.
2396
+ # Runs the configured engine with a fixed query so the user gets a
2397
+ # definitive answer before saving, rather than discovering a bad key mid-task.
2318
2398
  def api_test_search_config(req, res)
2319
2399
  body = parse_json_body(req) || {}
2320
2400
  provider = body["provider"].to_s.strip
2321
2401
  return json_response(res, 200, { ok: false, message: "provider is required" }) if provider.empty?
2322
2402
 
2403
+ key = body["key"].to_s
2404
+ key = Clacky::SearchConfig.key_for(provider) if key.empty? || key.include?("****")
2405
+ return test_deepseek_web_search(res, key) if provider == Clacky::SearchConfig::DEEPSEEK_WEB
2406
+
2323
2407
  script = Clacky::Utils::SearcherManager.path_for(provider)
2324
2408
  return json_response(res, 200, { ok: false, message: "searcher not found: #{provider}" }) unless script
2325
2409
 
2326
- key = body["key"].to_s
2327
- key = Clacky::SearchConfig.key if key.empty? || key.include?("****")
2328
-
2329
2410
  stdout, stderr, status = Clacky::Utils::ParserManager.capture3_with_timeout(
2330
2411
  { "CLACKY_SEARCH_KEY" => key },
2331
2412
  Clacky::Utils::SearcherManager.interpreter_for(script),
@@ -2353,6 +2434,23 @@ module Clacky
2353
2434
  json_response(res, 200, { ok: false, message: e.message })
2354
2435
  end
2355
2436
 
2437
+ private def test_deepseek_web_search(res, key)
2438
+ key = @agent_config.deepseek_web_key.to_s if key.empty?
2439
+ if key.empty?
2440
+ return json_response(res, 200, {
2441
+ ok: false, message: "No DeepSeek key: fill one in or configure a DeepSeek / OpenClacky model"
2442
+ })
2443
+ end
2444
+
2445
+ results = Clacky::DeepSeekWebTools.search(
2446
+ query: "openclacky", count: 1, api_key: key,
2447
+ base_url: @agent_config.deepseek_web_base_url(key)
2448
+ )
2449
+ json_response(res, 200, { ok: true, message: "Returned #{results.size} result(s)" })
2450
+ rescue => e
2451
+ json_response(res, 200, { ok: false, message: e.message })
2452
+ end
2453
+
2356
2454
  # POST /api/internal/ocr-image
2357
2455
  # Internal endpoint used by parser scripts (e.g. pdf_parser_vlm.py) to
2358
2456
  # transcribe a single image via the configured OCR sidecar. Localhost-
@@ -4511,10 +4609,12 @@ module Clacky
4511
4609
 
4512
4610
  path = Utils::EnvironmentDetector.resolve_local_path(raw_path)
4513
4611
  ext = File.extname(path).downcase.sub(/\A\./, "")
4612
+ # Extension-less files (Dockerfile, Makefile, Gemfile) declare no
4613
+ # document type, so no app lists them. Plain text is the closest
4614
+ # approximation and keeps the picker useful.
4615
+ ext = "txt" if ext.empty?
4514
4616
 
4515
- apps = if ext.empty?
4516
- []
4517
- elsif Utils::EnvironmentDetector.os_type == :wsl
4617
+ apps = if Utils::EnvironmentDetector.os_type == :wsl
4518
4618
  Utils::WindowsAppDetector.apps_for_ext(ext)
4519
4619
  else
4520
4620
  Utils::MacAppDetector.apps_for_ext(ext)
@@ -4531,9 +4631,7 @@ module Clacky
4531
4631
  return json_response(res, 400, { error: "path is required" }) if raw_path.empty?
4532
4632
 
4533
4633
  path = Utils::EnvironmentDetector.resolve_local_path(raw_path)
4534
- app = if File.extname(path).empty?
4535
- nil
4536
- elsif Utils::EnvironmentDetector.os_type == :wsl
4634
+ app = if Utils::EnvironmentDetector.os_type == :wsl
4537
4635
  Utils::WindowsAppDetector.default_app_for(path)
4538
4636
  else
4539
4637
  Utils::MacAppDetector.default_app_for(path)
@@ -4603,6 +4701,9 @@ module Clacky
4603
4701
  when "open"
4604
4702
  result = Utils::EnvironmentDetector.open_file(linux_path)
4605
4703
  return json_response(res, 501, { error: "unsupported OS" }) if result.nil?
4704
+ # system() returns false when the launcher exits non-zero (broken app
4705
+ # association, no handler). Surface it instead of a silent 200.
4706
+ return json_response(res, 500, { error: "failed to open file" }) unless result
4606
4707
  json_response(res, 200, { ok: true })
4607
4708
  when "open_with"
4608
4709
  result = if Utils::EnvironmentDetector.os_type == :wsl
@@ -5335,9 +5436,22 @@ module Clacky
5335
5436
  when "status"
5336
5437
  json_response(res, 200, { repo: true }.merge(Clacky::Server::GitPanel.status(dir)))
5337
5438
  when "diff"
5338
- json_response(res, 200, { repo: true, diff: Clacky::Server::GitPanel.diff(dir, file: query["file"]) })
5439
+ json_response(res, 200, { repo: true, diff: Clacky::Server::GitPanel.diff(
5440
+ dir, file: query["file"], context: query["context"],
5441
+ scope: query["scope"], base: query["base"]
5442
+ ) })
5443
+ when "review"
5444
+ if query["scope"].to_s == "lastTurn"
5445
+ json_response(res, 200, Clacky::Server::GitPanel.last_turn_review(git_session_agent(session_id)))
5446
+ else
5447
+ json_response(res, 200, Clacky::Server::GitPanel.review(
5448
+ dir, scope: query["scope"], base: query["base"]
5449
+ ))
5450
+ end
5339
5451
  when "log"
5340
- json_response(res, 200, { repo: true, commits: Clacky::Server::GitPanel.log(dir, limit: query["limit"] || 50) })
5452
+ json_response(res, 200, { repo: true, commits: Clacky::Server::GitPanel.log(
5453
+ dir, limit: query["limit"] || 50, unpushed: query["unpushed"].to_s == "1"
5454
+ ) })
5341
5455
  when "branches"
5342
5456
  json_response(res, 200, { repo: true, branches: Clacky::Server::GitPanel.branches(dir) })
5343
5457
  else
@@ -5345,24 +5459,6 @@ module Clacky
5345
5459
  end
5346
5460
  end
5347
5461
 
5348
- # POST /api/sessions/:id/git/commit — body: { message:, files: [..] }.
5349
- def api_session_git_commit(session_id, req, res)
5350
- dir = git_session_dir(session_id, res)
5351
- return unless dir
5352
-
5353
- unless Clacky::Server::GitPanel.repo?(dir)
5354
- return json_response(res, 400, { error: "Not a git repository" })
5355
- end
5356
-
5357
- body = parse_json_body(req)
5358
- result = Clacky::Server::GitPanel.commit(dir, message: body["message"], files: body["files"])
5359
- if result[:ok]
5360
- json_response(res, 200, result)
5361
- else
5362
- json_response(res, 422, { error: result[:error] })
5363
- end
5364
- end
5365
-
5366
5462
  # POST /api/sessions/:id/git/restore - body: { file: }.
5367
5463
  # Discards uncommitted changes to one file, back to its HEAD state.
5368
5464
  def api_session_git_restore(session_id, req, res)
@@ -5466,8 +5562,7 @@ module Clacky
5466
5562
  json_response(res, 404, { error: "Session not found" })
5467
5563
  return nil
5468
5564
  end
5469
- session = @registry.get(session_id)
5470
- agent = session && session[:agent]
5565
+ agent = git_session_agent(session_id)
5471
5566
  unless agent
5472
5567
  json_response(res, 404, { error: "Session not found" })
5473
5568
  return nil
@@ -5479,6 +5574,11 @@ module Clacky
5479
5574
  end
5480
5575
  dir
5481
5576
  end
5577
+
5578
+ private def git_session_agent(session_id)
5579
+ session = @registry.get(session_id)
5580
+ session && session[:agent]
5581
+ end
5482
5582
  # Lists one directory level inside the session's working_dir (lazy, per-layer).
5483
5583
  # Path traversal outside working_dir is rejected. Noisy dirs are hidden.
5484
5584
  IGNORED_FILE_ENTRIES = %w[.git .svn .hg node_modules .DS_Store .bundle vendor/bundle tmp .sass-cache].freeze
@@ -5492,7 +5592,11 @@ module Clacky
5492
5592
  return json_response(res, 404, { error: "Session not found" }) unless agent
5493
5593
 
5494
5594
  root = File.expand_path(agent.working_dir.to_s)
5495
- return json_response(res, 404, { error: "Working directory not found" }) unless Dir.exist?(root)
5595
+ unless Dir.exist?(root)
5596
+ # `code` lets the client tell "the folder was deleted" apart from any
5597
+ # other failure and explain it, instead of showing a generic error.
5598
+ return json_response(res, 404, { error: "Working directory not found", code: "working_dir_missing", path: root })
5599
+ end
5496
5600
 
5497
5601
  query = URI.decode_www_form(req.query_string.to_s).to_h
5498
5602
  rel = query["path"].to_s
@@ -5517,7 +5621,9 @@ module Clacky
5517
5621
  end
5518
5622
  end
5519
5623
 
5520
- return json_response(res, 404, { error: "Directory not found" }) unless Dir.exist?(target)
5624
+ unless Dir.exist?(target)
5625
+ return json_response(res, 404, { error: "Directory not found", code: "directory_missing" })
5626
+ end
5521
5627
  entries = Dir.children(target).reject do |name|
5522
5628
  IGNORED_FILE_ENTRIES.include?(name) || (!show_hidden && name.start_with?("."))
5523
5629
  end
@@ -6034,20 +6140,23 @@ module Clacky
6034
6140
  json_response(res, 200, { ok: true, session_id: session_id })
6035
6141
  end
6036
6142
 
6037
- # DELETE /api/trash/sessions?days_old=N
6038
- # Bulk: permanently delete sessions older than N days (default: 7).
6143
+ # POST /api/trash/sessions/delete
6144
+ # Body: { session_ids: ["...", ...] }
6145
+ # Bulk: permanently delete exactly these sessions. The caller decides the
6146
+ # scope (the page's filters live in the browser), so an empty list is
6147
+ # rejected rather than read as "everything".
6039
6148
  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
6149
+ raw = parse_json_body(req)["session_ids"]
6150
+ ids = raw.is_a?(Array) ? raw.map { |id| id.to_s.strip }.reject(&:empty?) : []
6043
6151
 
6044
- deleted = @session_manager.cleanup_trash(days: days_i)
6152
+ if ids.empty?
6153
+ json_response(res, 400, { ok: false, error: "session_ids is required" })
6154
+ return
6155
+ end
6045
6156
 
6046
- json_response(res, 200, {
6047
- ok: true,
6048
- deleted_count: deleted,
6049
- days_old: days_i
6050
- })
6157
+ deleted = @session_manager.permanent_delete_trash_sessions(ids)
6158
+
6159
+ json_response(res, 200, { ok: true, deleted_count: deleted })
6051
6160
  end
6052
6161
 
6053
6162
  # ── Trash helpers (private) ─────────────────────────────────────
@@ -7198,6 +7307,71 @@ module Clacky
7198
7307
  json_response(res, 500, { error: e.message })
7199
7308
  end
7200
7309
 
7310
+ # GET /api/projects/:id/git?dir= — branch state of a work tree, used by
7311
+ # the new-session branch picker. `dir` overrides the project's own
7312
+ # working dir, since a session may run in a different repo. `repo: false`
7313
+ # covers a missing working dir, a missing directory, and plain non-git
7314
+ # folders, so the picker can simply hide itself.
7315
+ def api_project_git(project_id, req, res)
7316
+ project = @project_manager.find(project_id)
7317
+ return json_response(res, 404, { error: "Project not found" }) unless project
7318
+
7319
+ dir = git_work_tree(project, git_dir_param(req))
7320
+ return json_response(res, 200, { repo: false }) unless dir
7321
+
7322
+ json_response(res, 200, {
7323
+ repo: true,
7324
+ branch: Clacky::Server::GitPanel.branch_name(dir),
7325
+ branches: Clacky::Server::GitPanel.branches(dir),
7326
+ })
7327
+ end
7328
+
7329
+ # POST /api/projects/:id/git/checkout — body: { branch:, create:?, from:?, dir:? }
7330
+ # Switches a work tree. `create: true` branches off `from`, which may be
7331
+ # a remote ref (origin/feature) to get a tracking branch.
7332
+ def api_project_git_checkout(project_id, req, res)
7333
+ project = @project_manager.find(project_id)
7334
+ return json_response(res, 404, { error: "Project not found" }) unless project
7335
+
7336
+ body = parse_json_body(req)
7337
+ dir = git_work_tree(project, body["dir"])
7338
+ return json_response(res, 400, { error: "Not a git repository" }) unless dir
7339
+
7340
+ result = Clacky::Server::GitPanel.checkout(
7341
+ dir,
7342
+ branch: body["branch"],
7343
+ create: body["create"] == true,
7344
+ from: body["from"]
7345
+ )
7346
+
7347
+ unless result[:ok]
7348
+ return json_response(res, 422, { error: result[:error] })
7349
+ end
7350
+
7351
+ json_response(res, 200, {
7352
+ ok: true,
7353
+ branch: Clacky::Server::GitPanel.branch_name(dir),
7354
+ branches: Clacky::Server::GitPanel.branches(dir),
7355
+ })
7356
+ end
7357
+
7358
+ def git_dir_param(req)
7359
+ URI.decode_www_form(req.query_string.to_s).to_h["dir"]
7360
+ rescue ArgumentError
7361
+ nil
7362
+ end
7363
+
7364
+ # Resolves the work tree a git request targets: the explicit `dir` when
7365
+ # given, otherwise the project's own working dir. Nil when the path is
7366
+ # not an existing git repository.
7367
+ def git_work_tree(project, dir_param)
7368
+ raw = dir_param.to_s.strip
7369
+ dir = raw.empty? ? project[:working_dir].to_s : File.expand_path(raw)
7370
+ return nil if dir.empty? || !Dir.exist?(dir)
7371
+ return nil unless Clacky::Server::GitPanel.repo?(dir)
7372
+ dir
7373
+ end
7374
+
7201
7375
  # PATCH /api/projects/:id - update a project
7202
7376
  # Body: { name:?, description:?, color:?, working_dir:?, pinned:? } - only provided fields are changed
7203
7377
  def api_update_project(project_id, req, res)
@@ -7250,6 +7424,45 @@ module Clacky
7250
7424
  json_response(res, 500, { error: e.message })
7251
7425
  end
7252
7426
 
7427
+ # POST /api/projects/:id/sessions/archive - archive every live session in a project
7428
+ def api_archive_project_sessions(project_id, res)
7429
+ return json_response(res, 404, { error: "Project not found" }) unless @project_manager.find(project_id)
7430
+
7431
+ archived = _archive_project_sessions(project_id)
7432
+ json_response(res, 200, { ok: true, archived: archived })
7433
+ rescue => e
7434
+ json_response(res, 500, { error: e.message })
7435
+ end
7436
+
7437
+ # Sessions already in the archive keep their original archived_at, so the
7438
+ # archive page stays ordered by when the user actually put them away.
7439
+ private def _archive_project_sessions(project_id)
7440
+ archived = 0
7441
+ now = Time.now.iso8601
7442
+
7443
+ @session_manager.all_sessions.each do |session_data|
7444
+ next unless session_data[:project_id].to_s == project_id
7445
+ next if session_data[:archived_at]
7446
+
7447
+ sid = session_data[:session_id]
7448
+ live = nil
7449
+ @registry.with_session(sid) { |s| live = s[:agent] }
7450
+
7451
+ if live
7452
+ live.archived_at = now
7453
+ @session_manager.save(live.to_session_data(updated_at: Time.now))
7454
+ else
7455
+ # Untouched on disk: patch the loaded row rather than restoring an
7456
+ # agent for every session in the project just to stamp one field.
7457
+ @session_manager.save(session_data.merge(archived_at: now, updated_at: now))
7458
+ end
7459
+
7460
+ broadcast(sid, { type: "session_updated", session_id: sid, archived_at: now })
7461
+ archived += 1
7462
+ end
7463
+ archived
7464
+ end
7465
+
7253
7466
  private def _delete_project_sessions(project_id)
7254
7467
  deleted = 0
7255
7468
  @session_manager.all_sessions.each do |session_data|
@@ -7346,6 +7559,24 @@ module Clacky
7346
7559
  json_response(res, 500, { error: e.message })
7347
7560
  end
7348
7561
 
7562
+ # PATCH /api/sessions/:id/permission_mode — session-only, never global.
7563
+ def api_switch_session_permission_mode(session_id, req, res)
7564
+ mode = parse_json_body(req)["permission_mode"].to_s
7565
+ unless %w[confirm_all auto_approve].include?(mode)
7566
+ return json_response(res, 400, { error: "Invalid permission mode" })
7567
+ end
7568
+ return json_response(res, 404, { error: "Session not found" }) unless @registry.ensure(session_id)
7569
+
7570
+ @registry.with_session(session_id) do |session|
7571
+ agent = session[:agent]
7572
+ return json_response(res, 404, { error: "Session not found" }) unless agent
7573
+ agent.permission_mode = mode
7574
+ @session_manager.save(agent.to_session_data(updated_at: Time.now))
7575
+ end
7576
+ broadcast_session_update(session_id)
7577
+ json_response(res, 200, { ok: true, permission_mode: mode })
7578
+ end
7579
+
7349
7580
  def api_switch_session_model(session_id, req, res)
7350
7581
  body = parse_json_body(req)
7351
7582
  model_id = body["model_id"].to_s.strip
@@ -7589,15 +7820,21 @@ module Clacky
7589
7820
  json_response(res, 201, { session: @registry.snapshot(fork_id) })
7590
7821
  end
7591
7822
 
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] }
7823
+ # POST /api/archive/sessions/delete — soft-deletes into the recycle bin.
7824
+ # With `session_ids`, only those still-archived chats go; without a body,
7825
+ # the whole archive (the page is paged, so only the server can list it).
7826
+ # An explicit empty list is rejected so "matches nothing" never means "all".
7827
+ def api_archive_sessions_delete(req, res)
7828
+ body = parse_json_body(req)
7829
+ ids = @registry.list(archived: true).map { |s| s[:id] }
7830
+
7831
+ if !body.is_a?(Hash) || body.key?("session_ids")
7832
+ raw = body.is_a?(Hash) ? body["session_ids"] : nil
7833
+ requested = raw.is_a?(Array) ? raw.map { |id| id.to_s.strip }.reject(&:empty?) : []
7834
+ return json_response(res, 400, { ok: false, error: "session_ids must not be empty" }) if requested.empty?
7835
+
7836
+ ids &= requested
7837
+ end
7601
7838
 
7602
7839
  # `list` just read these ids off disk, so skip the per-id existence
7603
7840
  # probe — `load` re-parses every session file for each call.
@@ -7841,14 +8078,16 @@ module Clacky
7841
8078
  raw_images = (msg["images"] || []).map do |data_url|
7842
8079
  { "data_url" => data_url, "name" => "image.jpg", "mime_type" => "image/jpeg" }
7843
8080
  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))
8081
+ handle_user_message(session_id, msg["content"].to_s, (msg["files"] || []) + raw_images, references: msg["references"] || [])
7845
8082
 
7846
- when "feedback_response"
8083
+ when "feedback_intervene"
7847
8084
  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"] || []))
8085
+ session = @registry.get(session_id)
8086
+ if session&.dig(:ui)&.intervene_feedback(msg["request_id"])
8087
+ # Finish the handoff before processing the next message on this connection.
8088
+ # An immediate submission then uses the ordinary awaiting-feedback path.
8089
+ session[:thread]&.join
8090
+ end
7852
8091
 
7853
8092
  when "confirmation"
7854
8093
  session_id = msg["session_id"] || conn.session_id
@@ -7868,7 +8107,9 @@ module Clacky
7868
8107
  pinned_part, non_pinned_part = page.partition { |s| s[:pinned] }
7869
8108
  non_pinned_limit = [10 - pinned_part.size, 0].max
7870
8109
  has_more = non_pinned_part.size > non_pinned_limit
7871
- all_sessions = pinned_part + non_pinned_part.first(non_pinned_limit)
8110
+ non_pinned_part = non_pinned_part.first(non_pinned_limit)
8111
+ next_cursor = non_pinned_part.last&.dig(:cursor)
8112
+ all_sessions = pinned_part + non_pinned_part
7872
8113
  projects = @project_manager.all
7873
8114
  if projects.any?
7874
8115
  project_ids = projects.map { |p| p[:id] }
@@ -7880,7 +8121,7 @@ module Clacky
7880
8121
  paged_ids = all_sessions.map { |s| s[:id] }.to_set
7881
8122
  all_sessions += project_sessions.reject { |s| paged_ids.include?(s[:id]) }
7882
8123
  end
7883
- conn.send_json(type: "session_list", sessions: all_sessions, has_more: has_more,
8124
+ conn.send_json(type: "session_list", sessions: all_sessions, has_more: has_more, next_cursor: next_cursor,
7884
8125
  groups: groups, projects: projects)
7885
8126
 
7886
8127
  when "run_task"
@@ -7948,12 +8189,10 @@ module Clacky
7948
8189
  end
7949
8190
  end
7950
8191
 
7951
- def handle_user_message(session_id, content, files = [], references: [], feedback: nil)
8192
+ def handle_user_message(session_id, content, files = [], references: [])
7952
8193
  return unless @registry.exist?(session_id)
7953
8194
 
7954
8195
  session = @registry.get(session_id)
7955
- feedback_options = feedback ? { feedback: feedback } : {}
7956
-
7957
8196
  mode = @agent_config.input_behavior
7958
8197
  queued = false
7959
8198
  queued_created_at = nil
@@ -7961,7 +8200,7 @@ module Clacky
7961
8200
  if s[:status] == :running && %w[queue steer].include?(mode)
7962
8201
  queued_created_at = Time.now.to_f
7963
8202
  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)
8203
+ reference_contexts: build_reference_contexts(references), created_at: queued_created_at)
7965
8204
  queued = true
7966
8205
  end
7967
8206
  end
@@ -7984,7 +8223,7 @@ module Clacky
7984
8223
  if old_thread && !old_thread.join(2)
7985
8224
  retained_created_at = Time.now.to_f
7986
8225
  session[:agent].enqueue_input(content, files: files, references_display: references,
7987
- reference_contexts: build_reference_contexts(references), created_at: retained_created_at, **feedback_options)
8226
+ reference_contexts: build_reference_contexts(references), created_at: retained_created_at)
7988
8227
  broadcast(session_id, { type: "input_enqueued", session_id: session_id, created_at: retained_created_at })
7989
8228
  broadcast(session_id, { type: "input_queue_notice", session_id: session_id, key: "chat.input.stoppingDelayed" })
7990
8229
  return
@@ -8042,7 +8281,7 @@ module Clacky
8042
8281
  # "Thinking..." forever. A web-originated turn bound to an IM channel gets a
8043
8282
  # card too, so tools/narration render like a native IM turn (#603).
8044
8283
  @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)
8284
+ agent.run(content, files: files, reference_contexts: reference_contexts, created_at: msg_created_at, references_display: references)
8046
8285
  end
8047
8286
  end
8048
8287
 
@@ -8256,6 +8495,7 @@ module Clacky
8256
8495
  Thread.current[:task_epoch] = epoch
8257
8496
  run_result = task.call
8258
8497
  awaiting = false
8498
+ web_ui = nil
8259
8499
  owns_epoch = true
8260
8500
  loop do
8261
8501
  owns_epoch = @registry.current_epoch(session_id).to_i == epoch.to_i
@@ -8265,6 +8505,7 @@ module Clacky
8265
8505
  @registry.with_session(session_id) do |s|
8266
8506
  owns_epoch = s[:epoch].to_i == epoch.to_i
8267
8507
  next unless owns_epoch
8508
+ web_ui = s[:ui]
8268
8509
  awaiting = run_result.is_a?(Hash) && run_result[:awaiting_user_feedback]
8269
8510
  pending = agent.take_pending_input if Agent.task_completed?(run_result)
8270
8511
  unless pending
@@ -8283,7 +8524,11 @@ module Clacky
8283
8524
  # session history: a chime is a live cue, never replayed on refresh.
8284
8525
  # Also fires when awaiting feedback — that is precisely when the user
8285
8526
  # most needs pulling back; the flag lets the client word it differently.
8286
- broadcast_all(type: "task_finished", session_id: session_id, awaiting_feedback: !!awaiting)
8527
+ finished = { type: "task_finished", session_id: session_id, awaiting_feedback: !!awaiting }
8528
+ # Lets clients not viewing this session keep the summary until it is first opened.
8529
+ complete = web_ui&.last_complete
8530
+ finished[:complete] = complete if complete&.dig(:task_id) && run_result.is_a?(Hash) && complete[:task_id] == run_result[:task_id]
8531
+ broadcast_all(finished)
8287
8532
  @session_manager.save(agent.to_session_data(status: :success, updated_at: Time.now))
8288
8533
  # Start idle compression timer now that the agent is idle
8289
8534
  idle_timer&.start