openclacky 1.5.3 → 1.5.5

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 (67) hide show
  1. checksums.yaml +4 -4
  2. data/.dockerignore +13 -0
  3. data/CHANGELOG.md +57 -0
  4. data/Dockerfile +21 -1
  5. data/README.md +26 -7
  6. data/README_CN.md +26 -7
  7. data/README_JA.md +26 -7
  8. data/lib/clacky/agent/message_compressor.rb +4 -29
  9. data/lib/clacky/agent/message_compressor_helper.rb +17 -16
  10. data/lib/clacky/agent/session_serializer.rb +7 -0
  11. data/lib/clacky/agent.rb +14 -2
  12. data/lib/clacky/agent_config.rb +10 -5
  13. data/lib/clacky/agent_profile.rb +25 -3
  14. data/lib/clacky/brand_config.rb +32 -12
  15. data/lib/clacky/default_extensions/ext-studio/agents/ext-developer/system_prompt.md +15 -0
  16. data/lib/clacky/default_extensions/ext-studio/api/handler.rb +1 -5
  17. data/lib/clacky/default_extensions/ext-studio/ext.yml +2 -2
  18. data/lib/clacky/default_extensions/ext-studio/panels/studio/view.js +43 -34
  19. data/lib/clacky/default_extensions/ext-studio/skills/ext-develop/SKILL.md +9 -3
  20. data/lib/clacky/default_extensions/git/ext.yml +3 -0
  21. data/lib/clacky/default_extensions/meeting/ext.yml +3 -0
  22. data/lib/clacky/default_extensions/time_machine/ext.yml +4 -0
  23. data/lib/clacky/default_parsers/xlsx_parser.py +66 -0
  24. data/lib/clacky/default_skills/new/SKILL.md +17 -12
  25. data/lib/clacky/extension/api_extension.rb +5 -2
  26. data/lib/clacky/extension/loader.rb +1 -0
  27. data/lib/clacky/extension/packager.rb +60 -15
  28. data/lib/clacky/extension/verifier.rb +1 -1
  29. data/lib/clacky/message_format/anthropic.rb +1 -1
  30. data/lib/clacky/message_format/bedrock.rb +1 -1
  31. data/lib/clacky/message_format/open_ai.rb +34 -3
  32. data/lib/clacky/providers.rb +77 -8
  33. data/lib/clacky/server/http_server.rb +495 -39
  34. data/lib/clacky/server/project_manager.rb +150 -0
  35. data/lib/clacky/server/server_master.rb +7 -0
  36. data/lib/clacky/server/session_registry.rb +14 -1
  37. data/lib/clacky/server/web_ui_controller.rb +12 -2
  38. data/lib/clacky/session_manager.rb +6 -4
  39. data/lib/clacky/tools/file_reader.rb +2 -1
  40. data/lib/clacky/tools/terminal.rb +30 -3
  41. data/lib/clacky/utils/model_pricing.rb +69 -18
  42. data/lib/clacky/utils/parser_manager.rb +115 -7
  43. data/lib/clacky/version.rb +1 -1
  44. data/lib/clacky/web/app.css +1216 -318
  45. data/lib/clacky/web/app.js +27 -4
  46. data/lib/clacky/web/components/notify.js +139 -22
  47. data/lib/clacky/web/core/aside.js +21 -0
  48. data/lib/clacky/web/features/brand/store.js +3 -1
  49. data/lib/clacky/web/features/brand/view.js +29 -5
  50. data/lib/clacky/web/features/extensions/store.js +214 -9
  51. data/lib/clacky/web/features/extensions/view.js +156 -11
  52. data/lib/clacky/web/features/new-session/store.js +25 -3
  53. data/lib/clacky/web/features/new-session/view.js +339 -142
  54. data/lib/clacky/web/features/skills/view.js +1 -1
  55. data/lib/clacky/web/features/trash/store.js +23 -9
  56. data/lib/clacky/web/features/trash/view.js +51 -0
  57. data/lib/clacky/web/i18n.js +124 -15
  58. data/lib/clacky/web/index.html +105 -79
  59. data/lib/clacky/web/projects.js +1143 -0
  60. data/lib/clacky/web/sessions.js +320 -538
  61. data/lib/clacky/web/settings.js +138 -5
  62. data/lib/clacky/web/utils.js +1 -0
  63. data/lib/clacky/web/ws-dispatcher.js +98 -4
  64. data/scripts/build/src/install_system_deps.sh.cc +11 -3
  65. data/scripts/install_system_deps.sh +11 -3
  66. metadata +5 -2
  67. data/lib/clacky/default_parsers/xlsx_parser.rb +0 -121
@@ -17,6 +17,7 @@ require "yaml"
17
17
  require "date"
18
18
  require "open3"
19
19
  require_relative "session_registry"
20
+ require_relative "project_manager"
20
21
  require_relative "git_panel"
21
22
  require_relative "web_ui_controller"
22
23
  require_relative "scheduler"
@@ -187,6 +188,7 @@ module Clacky
187
188
  @restart_script = File.expand_path($0)
188
189
  @restart_argv = ARGV.dup
189
190
  @session_manager = Clacky::SessionManager.new(sessions_dir: sessions_dir)
191
+ @project_manager = Clacky::Server::ProjectManager.new
190
192
  @registry = SessionRegistry.new(
191
193
  session_manager: @session_manager,
192
194
  session_restorer: method(:build_session_from_data),
@@ -215,6 +217,9 @@ module Clacky
215
217
  # Lazy: process-wide MCP registry. Created on first /api/mcp/:name access
216
218
  # so test setups that override Dir.home in before-hooks still work.
217
219
  @mcp_registry_mutex = Mutex.new
220
+ # Install jobs: job_id => { stage:, progress:, error:, done: }
221
+ @install_jobs = {}
222
+ @install_jobs_mutex = Mutex.new
218
223
  # Access key authentication:
219
224
  # - localhost (127.0.0.1 / ::1) is always trusted; auth is skipped entirely.
220
225
  # - Any other bind address requires CLACKY_ACCESS_KEY env var.
@@ -368,10 +373,13 @@ module Clacky
368
373
 
369
374
  server.mount_proc("/") do |req, res|
370
375
  if req.path == "/" || req.path == "/index.html"
371
- product_name = Clacky::BrandConfig.load.product_name || "OpenClacky"
376
+ brand = Clacky::BrandConfig.load
377
+ product_name = brand.product_name || "OpenClacky"
378
+ logo_url = brand.logo_url || "/logo_nav_dark.png"
372
379
  pure = req.query["pure"] == "true"
373
380
  html = File.read(index_html_path)
374
381
  .gsub("{{BRAND_NAME}}", product_name)
382
+ .gsub("{{BRAND_LOGO_URL}}", logo_url)
375
383
  .gsub("{{EXT_SCRIPTS}}", pure ? "" : self.send(:webui_ext_script_tags))
376
384
  res.status = 200
377
385
  res["Content-Type"] = "text/html; charset=utf-8"
@@ -491,7 +499,9 @@ module Clacky
491
499
  # Building/extracting a tar.gz of ~/.clacky can take a while.
492
500
  120
493
501
  elsif path == "/api/store/extension/install"
494
- 300
502
+ 10 # now async — just spawns a thread and returns job_id immediately
503
+ elsif path == "/api/store/extension/install/status"
504
+ 10
495
505
  else
496
506
  30
497
507
  end
@@ -520,6 +530,8 @@ module Clacky
520
530
  case [method, path]
521
531
  when ["GET", "/api/sessions"] then api_list_sessions(req, res)
522
532
  when ["POST", "/api/sessions"] then api_create_session(req, res)
533
+ when ["GET", "/api/projects"] then api_list_projects(res)
534
+ when ["POST", "/api/projects"] then api_create_project(req, res)
523
535
  when ["GET", "/api/cron-tasks"] then api_list_cron_tasks(res)
524
536
  when ["POST", "/api/cron-tasks"] then api_create_cron_task(req, res)
525
537
  when ["GET", "/api/skills"] then api_list_skills(res)
@@ -556,9 +568,11 @@ module Clacky
556
568
  when ["GET", "/api/store/skills"] then api_store_skills(res)
557
569
  when ["GET", "/api/store/extensions"] then api_store_extensions(req, res)
558
570
  when ["GET", "/api/store/extensions/brand"] then api_store_extensions_brand(res)
571
+ when ["GET", "/api/store/extensions/system"] then api_store_extensions_system(res)
559
572
  when ["GET", "/api/store/extensions/installed"] then api_store_extensions_installed(res)
560
573
  when ["GET", "/api/store/extension"] then api_store_extension_detail(req, res)
561
574
  when ["POST", "/api/store/extension/install"] then api_store_extension_install(req, res)
575
+ when ["GET", "/api/store/extension/install/status"] then api_store_extension_install_status(req, res)
562
576
  when ["POST", "/api/store/extension/disable"] then api_store_extension_disable(req, res)
563
577
  when ["POST", "/api/store/extension/enable"] then api_store_extension_enable(req, res)
564
578
  when ["DELETE", "/api/store/extension"] then api_store_extension_uninstall(req, res)
@@ -598,7 +612,10 @@ module Clacky
598
612
  when ["GET", "/api/billing/daily"] then api_billing_daily(req, res)
599
613
  when ["GET", "/api/billing/records"] then api_billing_records(req, res)
600
614
  when ["GET", "/api/billing/sessions"] then api_billing_sessions(req, res)
601
- when ["DELETE", "/api/billing/clear"] then api_billing_clear(req, res) when ["PATCH", "/api/sessions/:id/model"] then api_switch_session_model(req, res)
615
+ when ["DELETE", "/api/billing/clear"] then api_billing_clear(req, res)
616
+ when ["POST", "/api/ui/open_aside"] then api_ui_open_aside(req, res)
617
+ when ["POST", "/api/ui/show_ext_refresh"] then api_ui_show_ext_refresh(req, res)
618
+ when ["PATCH", "/api/sessions/:id/model"] then api_switch_session_model(req, res)
602
619
  when ["PATCH", "/api/sessions/:id/working_dir"] then api_change_session_working_dir(req, res)
603
620
  else
604
621
  if method == "POST" && path.match?(%r{^/api/channels/[^/]+/send$})
@@ -688,6 +705,9 @@ module Clacky
688
705
  elsif method == "PATCH" && path.match?(%r{^/api/sessions/[^/]+$})
689
706
  session_id = path.sub("/api/sessions/", "")
690
707
  api_rename_session(session_id, req, res)
708
+ elsif method == "PATCH" && path.match?(%r{^/api/sessions/[^/]+/project$})
709
+ session_id = path.sub("/api/sessions/", "").sub("/project", "")
710
+ api_update_session_project(session_id, req, res)
691
711
  elsif method == "PATCH" && path.match?(%r{^/api/sessions/[^/]+/model$})
692
712
  session_id = path.sub("/api/sessions/", "").sub("/model", "")
693
713
  api_switch_session_model(session_id, req, res)
@@ -763,6 +783,12 @@ module Clacky
763
783
  elsif method == "DELETE" && path.match?(%r{^/api/memories/[^/]+$})
764
784
  filename = URI.decode_www_form_component(path.sub("/api/memories/", ""))
765
785
  api_memories_delete(filename, res)
786
+ elsif method == "PATCH" && path.match?(%r{^/api/projects/[^/]+$})
787
+ project_id = path.sub("/api/projects/", "")
788
+ api_update_project(project_id, req, res)
789
+ elsif method == "DELETE" && path.match?(%r{^/api/projects/[^/]+$})
790
+ project_id = path.sub("/api/projects/", "")
791
+ api_delete_project(project_id, res)
766
792
  else
767
793
  not_found(res)
768
794
  end
@@ -773,7 +799,7 @@ module Clacky
773
799
 
774
800
  def api_list_sessions(req, res)
775
801
  query = URI.decode_www_form(req.query_string.to_s).to_h
776
- limit = [query["limit"].to_i.then { |n| n > 0 ? n : 20 }, 50].min
802
+ limit = [query["limit"].to_i.then { |n| n > 0 ? n : 15 }, 50].min
777
803
  before = query["before"].to_s.strip.then { |v| v.empty? ? nil : v }
778
804
  q = query["q"].to_s.strip.then { |v| v.empty? ? nil : v }
779
805
  q_scope = query["q_scope"].to_s.strip.then { |v| %w[name content].include?(v) ? v : "name" }
@@ -837,11 +863,37 @@ module Clacky
837
863
  return json_response(res, 400, { error: "Model not found in configuration" })
838
864
  end
839
865
 
866
+ # Optional project association — validate the project exists if provided
867
+ project_id_override = body["project_id"].to_s.strip
868
+ project_id_override = nil if project_id_override.empty?
869
+ if project_id_override && @project_manager.find(project_id_override).nil?
870
+ return json_response(res, 400, { error: "Project not found" })
871
+ end
872
+
873
+ # If no explicit working_dir was given but the project has one, inherit it.
874
+ if raw_dir.empty? && project_id_override
875
+ project = @project_manager.find(project_id_override)
876
+ if project && project[:working_dir].to_s.strip != ""
877
+ working_dir = File.expand_path(project[:working_dir])
878
+ end
879
+ end
880
+
840
881
  # Create working directory if it doesn't exist
841
882
  # Allow multiple sessions in the same directory
842
883
  FileUtils.mkdir_p(working_dir)
843
884
 
844
885
  session_id = build_session(name: name, working_dir: working_dir, profile: profile, source: source, model_id: model_id_override)
886
+
887
+ # Persist project_id into the session file right away if provided
888
+ if project_id_override
889
+ agent = nil
890
+ @registry.with_session(session_id) { |s| agent = s[:agent] }
891
+ if agent
892
+ agent.project_id = project_id_override
893
+ @session_manager.save(agent.to_session_data)
894
+ end
895
+ end
896
+
845
897
  broadcast_session_update(session_id)
846
898
  json_response(res, 201, { session: @registry.session_summary(session_id) })
847
899
  end
@@ -1081,16 +1133,19 @@ module Clacky
1081
1133
  end
1082
1134
 
1083
1135
  # Build the full <script> payload injected at {{EXT_SCRIPTS}}:
1084
- # 1. panel→agents registry (which agent profiles reference each panel)
1085
- # 2. agent-scoped UI — user ~/.clacky/agents/<name>/webui/**/*.js (data-agent)
1086
- # 3. ext.yml containers — panel view.js served from /ext_ui/
1136
+ # 1. ext.yml containers — panel view.js served from /ext_ui/
1137
+ # (also triggers ExtensionLoader.load_all, refreshing @last_result)
1138
+ # 2. panel→agents registry (which agent profiles reference each panel)
1139
+ # (reads last_result — must come AFTER container_ext_script_tags so
1140
+ # it sees the freshly scanned result, not a stale one)
1141
+ # 3. agent-scoped UI — user ~/.clacky/agents/<name>/webui/**/*.js (data-agent)
1087
1142
  # Never raises.
1088
1143
  private def webui_ext_script_tags
1089
1144
  [
1145
+ container_ext_script_tags,
1090
1146
  panel_agents_script,
1091
1147
  ext_contributions_script,
1092
1148
  agent_webui_script_tags,
1093
- container_ext_script_tags,
1094
1149
  ].reject(&:empty?).join("\n")
1095
1150
  end
1096
1151
 
@@ -1166,6 +1221,14 @@ module Clacky
1166
1221
  result = Hash.new { |h, k| h[k] = [] }
1167
1222
  add = ->(pid, agent) { result[pid] << agent unless result[pid].include?(agent) }
1168
1223
 
1224
+ # Use the cached last_result (populated by the load_all call in
1225
+ # container_ext_script_tags which is always called in the same
1226
+ # webui_ext_script_tags render pass). Calling load_all() here with
1227
+ # no arguments would overwrite @last_result with a default-layer scan,
1228
+ # clobbering any test-injected result and causing panel attach: to
1229
+ # silently produce empty lists.
1230
+ ext_result = Clacky::ExtensionLoader.last_result
1231
+
1169
1232
  agent_data = agent_profile_data
1170
1233
  agent_data.each do |name, data|
1171
1234
  ext_id = data["_ext_id"]
@@ -1176,8 +1239,11 @@ module Clacky
1176
1239
  end
1177
1240
  end
1178
1241
 
1179
- all_agent_ids = agent_data.keys
1180
- Array(Clacky::ExtensionLoader.last_result&.panels).each do |unit|
1242
+ # Always include "general" — it is the implicit default profile for sessions
1243
+ # that have no custom agent assigned. Without this, attach: [\"*\"] panels
1244
+ # would never appear in general-profile sessions when no named agent dirs exist.
1245
+ all_agent_ids = (agent_data.keys + ["general"]).uniq
1246
+ Array(ext_result&.panels).each do |unit|
1181
1247
  full_id = "#{unit.ext_id}/#{unit.id}"
1182
1248
  attach = Array(unit.spec["attach"])
1183
1249
  next if attach.empty?
@@ -2182,6 +2248,13 @@ module Clacky
2182
2248
  # * All exceptions are swallowed; a refresh failure must not crash the
2183
2249
  # server or leak through the web stack.
2184
2250
  def trigger_async_distribution_refresh!
2251
+ refresh_source = worker_clacky_license_server
2252
+ source_current = -> { effective_clacky_license_server == refresh_source }
2253
+ unless source_current.call
2254
+ Clacky::Logger.debug("[Brand] distribution refresh skipped — worker source is stale")
2255
+ return
2256
+ end
2257
+
2185
2258
  BRAND_DIST_REFRESH_MUTEX.synchronize do
2186
2259
  if @@brand_dist_refresh_inflight
2187
2260
  Clacky::Logger.debug("[Brand] distribution refresh already in flight, skipping")
@@ -2194,7 +2267,7 @@ module Clacky
2194
2267
  Clacky::Logger.info("[Brand] async distribution refresh starting...")
2195
2268
  begin
2196
2269
  brand = Clacky::BrandConfig.load
2197
- result = brand.refresh_distribution!
2270
+ result = brand.refresh_distribution!(&source_current)
2198
2271
  if result[:success]
2199
2272
  Clacky::Logger.info("[Brand] async distribution refresh OK")
2200
2273
  else
@@ -2202,7 +2275,7 @@ module Clacky
2202
2275
  end
2203
2276
  # Free-mode skill sync: branded + unactivated installs need their
2204
2277
  # creator's free skills auto-installed for the "no serial number" UX.
2205
- brand.sync_free_skills_async!
2278
+ brand.sync_free_skills_async! if source_current.call
2206
2279
  rescue StandardError => e
2207
2280
  Clacky::Logger.warn("[Brand] async distribution refresh raised: #{e.class}: #{e.message}")
2208
2281
  ensure
@@ -2227,7 +2300,16 @@ module Clacky
2227
2300
  brand = Clacky::BrandConfig.load
2228
2301
 
2229
2302
  unless brand.branded?
2230
- json_response(res, 200, { branded: false })
2303
+ refresh_pending = false
2304
+ if brand.distribution_refresh_due?
2305
+ trigger_async_distribution_refresh!
2306
+ refresh_pending = true
2307
+ end
2308
+
2309
+ json_response(res, 200, {
2310
+ branded: false,
2311
+ distribution_refresh_pending: refresh_pending
2312
+ })
2231
2313
  return
2232
2314
  end
2233
2315
 
@@ -2346,13 +2428,18 @@ module Clacky
2346
2428
  # Deactivates (unbinds) the current brand license and clears all brand state.
2347
2429
  # Brand skills are removed from disk. Returns 200 on success.
2348
2430
  private def api_brand_deactivate(res)
2349
- brand = Clacky::BrandConfig.load
2350
- result = brand.deactivate!
2351
- # Reload skill_loader without brand config so brand skills are no longer visible.
2352
- @skill_loader = Clacky::SkillLoader.new(working_dir: nil, brand_config: Clacky::BrandConfig.new({}))
2431
+ deactivate_brand!
2353
2432
  json_response(res, 200, { ok: true })
2354
2433
  end
2355
2434
 
2435
+ private def deactivate_brand!
2436
+ Clacky::BrandConfig.load.deactivate!
2437
+ @skill_loader = Clacky::SkillLoader.new(
2438
+ working_dir: nil,
2439
+ brand_config: Clacky::BrandConfig.new({})
2440
+ )
2441
+ end
2442
+
2356
2443
  # GET /api/brand/skills
2357
2444
  # Fetches the brand skills list from the cloud, enriched with local installed version.
2358
2445
  # Returns 200 with skill list, or 403 when license is not activated.
@@ -2450,6 +2537,47 @@ module Clacky
2450
2537
  end
2451
2538
  end
2452
2539
 
2540
+ # GET /api/store/extensions/system
2541
+ #
2542
+ # Returns only the builtin (system) extensions shipped with the gem.
2543
+ def api_store_extensions_system(res)
2544
+ result = Clacky::ExtensionLoader.load_all
2545
+ disabled = Clacky::ExtensionLoader.disabled_ids
2546
+
2547
+ hidden = %w[coding general ext-studio].to_set
2548
+
2549
+ extensions = Array(result&.containers).filter_map do |ext_id, container|
2550
+ next unless container[:layer] == :builtin
2551
+ next if hidden.include?(ext_id)
2552
+
2553
+ local_name = container[:name].to_s.then { |n| n.empty? ? ext_id : n }
2554
+ local_desc = container.dig(:raw, "description").to_s
2555
+ local_desc_zh = container.dig(:raw, "description_zh").to_s
2556
+ local_author = container[:author].to_s
2557
+ local_units = units_from_container(container)
2558
+ {
2559
+ "id" => ext_id,
2560
+ "name" => local_name,
2561
+ "display_name" => container.dig(:raw, "display_name") || local_name,
2562
+ "display_name_zh" => container.dig(:raw, "display_name_zh"),
2563
+ "slug" => ext_id,
2564
+ "version" => container[:version],
2565
+ "description" => local_desc,
2566
+ "description_zh" => local_desc_zh,
2567
+ "author" => local_author,
2568
+ "units" => local_units,
2569
+ "layer" => "builtin",
2570
+ "installed" => true,
2571
+ "removable" => false,
2572
+ "disabled" => disabled.include?(ext_id),
2573
+ }
2574
+ end
2575
+
2576
+ json_response(res, 200, { ok: true, extensions: extensions })
2577
+ rescue StandardError => e
2578
+ json_response(res, 500, { ok: false, error: e.message })
2579
+ end
2580
+
2453
2581
  # GET /api/store/extensions/installed
2454
2582
  #
2455
2583
  # Returns all locally installed extensions (all layers: builtin, installed,
@@ -2579,6 +2707,7 @@ module Clacky
2579
2707
  slug = ext["name"] || ext[:name] || ext["slug"] || ext[:slug]
2580
2708
  container = extension_container(slug)
2581
2709
  ext = ext.merge(
2710
+ "slug" => slug,
2582
2711
  "installed" => !container.nil?,
2583
2712
  "installed_version" => container&.dig(:version),
2584
2713
  "removable" => container && container[:layer] == :installed,
@@ -2655,7 +2784,9 @@ module Clacky
2655
2784
  json_response(res, 500, { ok: false, error: e.message })
2656
2785
  end
2657
2786
 
2658
- # DELETE /api/store/extension body: { id: <slug> }
2787
+ # POST /api/store/extension/install body: { download_url:, name: }
2788
+ # Starts an async install job and returns immediately with a job_id.
2789
+ # Poll GET /api/store/extension/install/status?job_id=xxx for progress.
2659
2790
  def api_store_extension_install(req, res)
2660
2791
  body = parse_json_body(req)
2661
2792
  download_url = body["download_url"].to_s.strip
@@ -2666,14 +2797,71 @@ module Clacky
2666
2797
  return
2667
2798
  end
2668
2799
 
2669
- Clacky::ExtensionPackager.install(download_url, force: true)
2670
- Clacky::ExtensionLoader.invalidate_cache!
2671
- Clacky::Telemetry.extension_install!(name) unless name.empty?
2672
- json_response(res, 200, { ok: true, name: name })
2673
- rescue Clacky::ExtensionPackager::Error => e
2674
- json_response(res, 422, { ok: false, error: e.message })
2675
- rescue StandardError => e
2676
- json_response(res, 500, { ok: false, error: e.message })
2800
+ job_id = SecureRandom.hex(8)
2801
+ @install_jobs_mutex.synchronize do
2802
+ sweep_stale_install_jobs!
2803
+ @install_jobs[job_id] = { stage: "downloading", progress: 0, error: nil, done: false, updated_at: Time.now }
2804
+ end
2805
+
2806
+ Thread.new do
2807
+ begin
2808
+ on_progress = lambda do |info|
2809
+ @install_jobs_mutex.synchronize do
2810
+ @install_jobs[job_id] = info.merge(error: nil, done: false, updated_at: Time.now)
2811
+ end
2812
+ end
2813
+ Clacky::ExtensionPackager.install(download_url, force: true, on_progress: on_progress)
2814
+ Clacky::ExtensionLoader.invalidate_cache!
2815
+ Clacky::Telemetry.extension_install!(name) unless name.empty?
2816
+ @install_jobs_mutex.synchronize do
2817
+ @install_jobs[job_id] = { stage: "done", progress: 100, error: nil, done: true, updated_at: Time.now }
2818
+ end
2819
+ rescue StandardError => e
2820
+ @install_jobs_mutex.synchronize do
2821
+ @install_jobs[job_id] = { stage: "error", progress: 0, error: e.message, done: true, updated_at: Time.now }
2822
+ end
2823
+ end
2824
+ end
2825
+
2826
+ json_response(res, 200, { ok: true, job_id: job_id })
2827
+ end
2828
+
2829
+ # GET /api/store/extension/install/status?job_id=xxx
2830
+ def api_store_extension_install_status(req, res)
2831
+ job_id = req.query["job_id"].to_s.strip
2832
+ if job_id.empty?
2833
+ json_response(res, 400, { ok: false, error: "Missing job_id." })
2834
+ return
2835
+ end
2836
+ job = @install_jobs_mutex.synchronize do
2837
+ sweep_stale_install_jobs!
2838
+ @install_jobs[job_id]
2839
+ end
2840
+ if job.nil?
2841
+ json_response(res, 404, { ok: false, error: "Job not found." })
2842
+ return
2843
+ end
2844
+ # Clean up finished jobs after they are read as done
2845
+ if job[:done]
2846
+ @install_jobs_mutex.synchronize { @install_jobs.delete(job_id) }
2847
+ end
2848
+ json_response(res, 200, { ok: true }.merge(job))
2849
+ end
2850
+
2851
+ # Install jobs can go stale in two ways:
2852
+ # - the client abandons the poll (tab closed, network drop) so a
2853
+ # finished job never gets picked up and deleted by the status endpoint
2854
+ # - the worker thread dies without ever updating the job hash (should
2855
+ # not happen given the rescue in api_store_extension_install, but a
2856
+ # hung Thread with no timeout upstream would otherwise pin memory
2857
+ # forever)
2858
+ # Sweep anything past its TTL. Must be called from inside
2859
+ # @install_jobs_mutex.synchronize.
2860
+ INSTALL_JOB_TTL = 30 * 60 # seconds
2861
+
2862
+ private def sweep_stale_install_jobs!
2863
+ cutoff = Time.now - INSTALL_JOB_TTL
2864
+ @install_jobs.delete_if { |_id, job| (job[:updated_at] || Time.at(0)) < cutoff }
2677
2865
  end
2678
2866
 
2679
2867
  def api_store_extension_uninstall(req, res)
@@ -2947,6 +3135,41 @@ module Clacky
2947
3135
  json_response(res, 500, { error: e.message })
2948
3136
  end
2949
3137
 
3138
+ # POST /api/ui/open_aside
3139
+ # Broadcasts an open_aside event to the specified session's WebSocket clients,
3140
+ # causing the browser to open the right-side panel.
3141
+ # Body: { session_id: "..." }
3142
+ def api_ui_open_aside(req, res)
3143
+ body = parse_json_body(req) || {}
3144
+ session_id = body["session_id"].to_s.strip
3145
+ if session_id.empty?
3146
+ json_response(res, 400, { error: "session_id is required" })
3147
+ return
3148
+ end
3149
+ broadcast(session_id, { type: "open_aside" })
3150
+ json_response(res, 200, { ok: true })
3151
+ rescue => e
3152
+ json_response(res, 500, { error: e.message })
3153
+ end
3154
+
3155
+ # POST /api/ui/show_ext_refresh
3156
+ # Broadcasts a show_ext_refresh event to the specified session's WebSocket clients,
3157
+ # causing the browser to display a one-click "reload extensions" button.
3158
+ # Called by the AI after editing extension files so the user knows to reload.
3159
+ # Body: { session_id: "..." }
3160
+ def api_ui_show_ext_refresh(req, res)
3161
+ body = parse_json_body(req) || {}
3162
+ session_id = body["session_id"].to_s.strip
3163
+ if session_id.empty?
3164
+ json_response(res, 400, { error: "session_id is required" })
3165
+ return
3166
+ end
3167
+ broadcast(session_id, { type: "show_ext_refresh" })
3168
+ json_response(res, 200, { ok: true })
3169
+ rescue => e
3170
+ json_response(res, 500, { error: e.message })
3171
+ end
3172
+
2950
3173
  # GET /api/version
2951
3174
  # Returns current version and latest version from RubyGems (cached for 1 hour).
2952
3175
  def api_get_version(res)
@@ -3253,7 +3476,10 @@ module Clacky
3253
3476
  # Responds 200 first, then waits briefly for WEBrick to flush the response before exec.
3254
3477
  def api_restart(req, res)
3255
3478
  json_response(res, 200, { ok: true, message: "Restarting…" })
3479
+ schedule_restart
3480
+ end
3256
3481
 
3482
+ private def schedule_restart
3257
3483
  Thread.new do
3258
3484
  sleep 0.5 # Let WEBrick flush the HTTP response
3259
3485
 
@@ -4239,12 +4465,30 @@ module Clacky
4239
4465
  # Each entry carries { id, title, title_zh, description, description_zh,
4240
4466
  # source, order, layer, author }. Extensions can supply title_zh /
4241
4467
  # description_zh / author in their ext.yml so the New Session cards read
4242
- # naturally and credit their author.
4468
+ # naturally and credit their author. Third-party extension agents are
4469
+ # ordered by most-recently-used (derived from session history) so the
4470
+ # ones the user actually uses float to the front of that group.
4243
4471
  def api_list_agents(res)
4244
- agents = Clacky::AgentProfile.all
4472
+ recency = agent_last_used_recency
4473
+ agents = Clacky::AgentProfile.all(recency: recency)
4245
4474
  json_response(res, 200, { agents: agents })
4246
4475
  end
4247
4476
 
4477
+ # Map agent_profile id => most recent session's updated_at (epoch seconds),
4478
+ # scanning every session on disk. Used to rank extension agent cards by
4479
+ # recent usage instead of a static declared `order`.
4480
+ private def agent_last_used_recency
4481
+ recency = {}
4482
+ @session_manager.all_sessions.each do |s|
4483
+ id = (s[:agent_profile] || "general").to_s
4484
+ ts = s[:updated_at] || s[:created_at]
4485
+ next unless ts
4486
+ epoch = Time.parse(ts.to_s).to_i rescue next
4487
+ recency[id] = epoch if epoch > (recency[id] || 0)
4488
+ end
4489
+ recency
4490
+ end
4491
+
4248
4492
  # GET /api/sessions/:id/skills — list user-invocable skills for a session,
4249
4493
  # filtered by the session's agent profile. Used by the frontend slash-command
4250
4494
  # autocomplete so only skills valid for the current profile are suggested.
@@ -4921,7 +5165,8 @@ module Clacky
4921
5165
  file_size: s[:file_size] || 0,
4922
5166
  model: s[:model],
4923
5167
  working_dir: s[:working_dir],
4924
- source: s[:source]
5168
+ source: s[:source],
5169
+ project_id: s[:project_id]
4925
5170
  }
4926
5171
  end
4927
5172
 
@@ -4952,6 +5197,15 @@ module Clacky
4952
5197
  return
4953
5198
  end
4954
5199
 
5200
+ # If the session belonged to a project that no longer exists, clear
5201
+ # the stale project_id so it falls back to the regular session list.
5202
+ restored_data = @session_manager.load(session_id)
5203
+ if restored_data && restored_data[:project_id]
5204
+ unless @project_manager.find(restored_data[:project_id])
5205
+ @session_manager.save(restored_data.merge(project_id: nil))
5206
+ end
5207
+ end
5208
+
4955
5209
  # Load the restored session into the registry so it behaves like any
4956
5210
  # other live session (status, agent, snapshot all available).
4957
5211
  @registry.ensure(session_id)
@@ -5542,9 +5796,11 @@ module Clacky
5542
5796
  json_response(res, 200, {
5543
5797
  ok: true,
5544
5798
  enable_compression: @agent_config.enable_compression,
5799
+ enable_idle_compression: @agent_config.enable_idle_compression,
5545
5800
  enable_prompt_caching: @agent_config.enable_prompt_caching,
5546
5801
  memory_update_enabled: @agent_config.memory_update_enabled,
5547
- proxy_url: @agent_config.proxy_url.to_s
5802
+ proxy_url: @agent_config.proxy_url.to_s,
5803
+ clacky_license_server: effective_clacky_license_server
5548
5804
  })
5549
5805
  end
5550
5806
 
@@ -5553,9 +5809,22 @@ module Clacky
5553
5809
  body = parse_json_body(req)
5554
5810
  return json_response(res, 400, { error: "Invalid JSON" }) unless body
5555
5811
 
5812
+ source_requested = body.key?("clacky_license_server")
5813
+ normalized_source = nil
5814
+ if source_requested
5815
+ begin
5816
+ normalized_source = normalize_http_origin(body["clacky_license_server"])
5817
+ rescue ArgumentError => e
5818
+ return json_response(res, 422, { ok: false, error: e.message })
5819
+ end
5820
+ end
5821
+
5556
5822
  if body.key?("enable_compression")
5557
5823
  @agent_config.enable_compression = !!body["enable_compression"]
5558
5824
  end
5825
+ if body.key?("enable_idle_compression")
5826
+ @agent_config.enable_idle_compression = !!body["enable_idle_compression"]
5827
+ end
5559
5828
  if body.key?("enable_prompt_caching")
5560
5829
  @agent_config.enable_prompt_caching = !!body["enable_prompt_caching"]
5561
5830
  end
@@ -5579,12 +5848,69 @@ module Clacky
5579
5848
  end
5580
5849
  end
5581
5850
 
5851
+ source_changed = source_requested &&
5852
+ normalized_source != effective_clacky_license_server
5853
+ deactivate_brand! if source_changed
5854
+ @agent_config.clacky_license_server = normalized_source if source_requested
5855
+
5582
5856
  @agent_config.save
5583
- json_response(res, 200, { ok: true })
5857
+ payload = { ok: true }
5858
+ if source_requested
5859
+ payload.merge!(
5860
+ clacky_license_server: normalized_source,
5861
+ source_changed: source_changed,
5862
+ restarting: source_changed
5863
+ )
5864
+ end
5865
+ json_response(res, 200, payload)
5866
+ schedule_restart if source_changed
5584
5867
  rescue => e
5585
5868
  json_response(res, 422, { error: e.message })
5586
5869
  end
5587
5870
 
5871
+ private def effective_clacky_license_server
5872
+ source = @agent_config.clacky_license_server.to_s.strip
5873
+ source = ENV["CLACKY_LICENSE_SERVER"].to_s.strip if source.empty?
5874
+ source = Clacky::PlatformHttpClient::PRIMARY_HOST if source.empty?
5875
+ normalize_http_origin(source)
5876
+ rescue ArgumentError
5877
+ source
5878
+ end
5879
+
5880
+ private def worker_clacky_license_server
5881
+ source = ENV["CLACKY_LICENSE_SERVER"].to_s.strip
5882
+ source = Clacky::PlatformHttpClient::PRIMARY_HOST if source.empty?
5883
+ normalize_http_origin(source)
5884
+ rescue ArgumentError
5885
+ source
5886
+ end
5887
+
5888
+ private def normalize_http_origin(value)
5889
+ raw = value.to_s.strip
5890
+ uri = URI.parse(raw)
5891
+ scheme = uri.scheme.to_s.downcase
5892
+ host = uri.host.to_s.downcase
5893
+ path = uri.path.to_s
5894
+
5895
+ valid = uri.is_a?(URI::HTTP) &&
5896
+ %w[http https].include?(scheme) &&
5897
+ !host.empty? &&
5898
+ uri.userinfo.nil? &&
5899
+ (path.empty? || path == "/") &&
5900
+ uri.query.nil? &&
5901
+ uri.fragment.nil?
5902
+ unless valid
5903
+ raise ArgumentError,
5904
+ "clacky_license_server must be an HTTP/HTTPS origin without credentials, path, query, or fragment"
5905
+ end
5906
+
5907
+ default_port = scheme == "https" ? 443 : 80
5908
+ port_suffix = uri.port == default_port ? "" : ":#{uri.port}"
5909
+ "#{scheme}://#{host}#{port_suffix}"
5910
+ rescue URI::InvalidURIError
5911
+ raise ArgumentError, "clacky_license_server is not a valid URL"
5912
+ end
5913
+
5588
5914
  # DEPRECATED: this endpoint previously accepted the entire models array
5589
5915
  # and replaced @models in place. That design was fragile — any missing
5590
5916
  # or stale field on ANY row could wipe other rows' api_keys. It has
@@ -5888,6 +6214,113 @@ module Clacky
5888
6214
  json_response(res, 200, { events: collected, has_more: result[:has_more] })
5889
6215
  end
5890
6216
 
6217
+ # ── Project API ───────────────────────────────────────────────────────────
6218
+
6219
+ # GET /api/projects — list all projects
6220
+ def api_list_projects(res)
6221
+ projects = @project_manager.all
6222
+ json_response(res, 200, { projects: projects })
6223
+ end
6224
+
6225
+ # POST /api/projects — create a new project
6226
+ # Body: { name:, description:?, color:?, icon:?, working_dir:? }
6227
+ def api_create_project(req, res)
6228
+ body = parse_json_body(req)
6229
+ name = body["name"].to_s.strip
6230
+ return json_response(res, 400, { error: "name is required" }) if name.empty?
6231
+
6232
+ description = body["description"]
6233
+ color = body["color"]
6234
+ icon = body["icon"]
6235
+ working_dir = body["working_dir"].to_s.strip
6236
+ working_dir = working_dir.empty? ? nil : File.expand_path(working_dir)
6237
+
6238
+ project = @project_manager.create(name: name, description: description, color: color, icon: icon, working_dir: working_dir)
6239
+ json_response(res, 201, { project: project })
6240
+ rescue => e
6241
+ json_response(res, 500, { error: e.message })
6242
+ end
6243
+
6244
+ # PATCH /api/projects/:id — update a project
6245
+ # Body: { name:?, description:?, color:?, working_dir:? } — only provided fields are changed
6246
+ def api_update_project(project_id, req, res)
6247
+ body = parse_json_body(req)
6248
+ return json_response(res, 404, { error: "Project not found" }) if @project_manager.find(project_id).nil?
6249
+
6250
+ kwargs = {}
6251
+ kwargs[:name] = body["name"] if body.key?("name")
6252
+ kwargs[:description] = body["description"] if body.key?("description")
6253
+ kwargs[:color] = body["color"] if body.key?("color")
6254
+ kwargs[:icon] = body["icon"] if body.key?("icon")
6255
+ if body.key?("working_dir")
6256
+ raw_wd = body["working_dir"].to_s.strip
6257
+ kwargs[:working_dir] = raw_wd.empty? ? nil : File.expand_path(raw_wd)
6258
+ end
6259
+
6260
+ project = @project_manager.update(project_id, **kwargs)
6261
+ return json_response(res, 404, { error: "Project not found" }) unless project
6262
+
6263
+ json_response(res, 200, { project: project })
6264
+ rescue ArgumentError => e
6265
+ json_response(res, 400, { error: e.message })
6266
+ rescue => e
6267
+ json_response(res, 500, { error: e.message })
6268
+ end
6269
+
6270
+ # DELETE /api/projects/:id — delete a project and all its sessions (soft-delete to trash)
6271
+ def api_delete_project(project_id, res)
6272
+ return json_response(res, 404, { error: "Project not found" }) unless @project_manager.find(project_id)
6273
+
6274
+ @project_manager.delete(project_id)
6275
+
6276
+ # Soft-delete all sessions that belonged to this project.
6277
+ @session_manager.all_sessions.each do |session_data|
6278
+ next unless session_data[:project_id].to_s == project_id
6279
+
6280
+ sid = session_data[:session_id]
6281
+ in_registry = @registry.exist?(sid)
6282
+ on_disk = !@session_manager.load(sid).nil?
6283
+
6284
+ @registry.delete(sid) if in_registry
6285
+ @session_manager.soft_delete(sid) if on_disk
6286
+
6287
+ broadcast(sid, { type: "session_deleted", session_id: sid })
6288
+ unsubscribe_all(sid)
6289
+ end
6290
+
6291
+ json_response(res, 200, { ok: true })
6292
+ rescue => e
6293
+ json_response(res, 500, { error: e.message })
6294
+ end
6295
+
6296
+ # PATCH /api/sessions/:id/project — move a session into or out of a project
6297
+ # Body: { project_id: "<id>" } to assign, { project_id: null } to remove
6298
+ def api_update_session_project(session_id, req, res)
6299
+ body = parse_json_body(req)
6300
+ new_pid = body.key?("project_id") ? body["project_id"] : :__unset
6301
+
6302
+ return json_response(res, 400, { error: "project_id key is required" }) if new_pid == :__unset
6303
+
6304
+ # Validate the target project exists (skip when clearing)
6305
+ if new_pid && @project_manager.find(new_pid.to_s).nil?
6306
+ return json_response(res, 400, { error: "Project not found" })
6307
+ end
6308
+
6309
+ return json_response(res, 404, { error: "Session not found" }) unless @registry.ensure(session_id)
6310
+
6311
+ agent = nil
6312
+ @registry.with_session(session_id) { |s| agent = s[:agent] }
6313
+ return json_response(res, 404, { error: "Session not found" }) unless agent
6314
+
6315
+ agent.project_id = new_pid ? new_pid.to_s : nil
6316
+ @session_manager.save(agent.to_session_data)
6317
+ broadcast_session_update(session_id)
6318
+
6319
+ json_response(res, 200, { ok: true, project_id: agent.project_id })
6320
+ rescue => e
6321
+ json_response(res, 500, { error: e.message })
6322
+ end
6323
+
5891
6324
  def api_rename_session(session_id, req, res)
5892
6325
  body = parse_json_body(req)
5893
6326
  new_name = body["name"]&.to_s&.strip
@@ -5964,7 +6397,7 @@ module Clacky
5964
6397
  end
5965
6398
 
5966
6399
  # PATCH /api/sessions/:id/reasoning_effort
5967
- # Body: { "reasoning_effort": "off" | "low" | "medium" | "high" | "xhigh" }
6400
+ # Body: { "reasoning_effort": "off" | "low" | "medium" | "high" | "xhigh" | "max" }
5968
6401
  def api_switch_session_reasoning_effort(session_id, req, res)
5969
6402
  body = parse_json_body(req)
5970
6403
  raw = body["reasoning_effort"]
@@ -6365,12 +6798,28 @@ module Clacky
6365
6798
  interrupt_session(session_id)
6366
6799
 
6367
6800
  when "list_sessions"
6368
- stats = @registry.cron_stats
6369
- page = @registry.list(limit: 21, exclude_type: "cron")
6370
- has_more = page.size > 20
6371
- all_sessions = page.first(20)
6801
+ stats = @registry.cron_stats
6802
+ page = @registry.list(limit: 16, exclude_type: "cron", exclude_project: true)
6803
+ has_more = page.size > 15
6804
+ all_sessions = page.first(15)
6805
+ projects = @project_manager.all
6806
+ # Include ALL sessions that belong to any project, regardless of the
6807
+ # 15-item pagination limit. We merge them into the same `sessions`
6808
+ # array; the client deduplicates by id in `setAll`.
6809
+ if projects.any?
6810
+ project_ids = projects.map { |p| p[:id] }
6811
+ project_sessions = project_ids.flat_map do |pid|
6812
+ @registry.list(project_id: pid, exclude_type: "cron")
6813
+ end
6814
+ # Deduplicate: project sessions that are already in the first page
6815
+ # will be replaced by the enriched version from `all_sessions`.
6816
+ paged_ids = all_sessions.map { |s| s[:id] }.to_set
6817
+ extra_project_sessions = project_sessions.reject { |s| paged_ids.include?(s[:id]) }
6818
+ all_sessions = all_sessions + extra_project_sessions
6819
+ end
6372
6820
  conn.send_json(type: "session_list", sessions: all_sessions, has_more: has_more,
6373
- cron_count: stats[:count], latest_cron_updated_at: stats[:latest_updated_at])
6821
+ cron_count: stats[:count], latest_cron_updated_at: stats[:latest_updated_at],
6822
+ projects: projects)
6374
6823
 
6375
6824
  when "run_task"
6376
6825
  # Client sends this after subscribing to guarantee it's ready to receive
@@ -6450,7 +6899,13 @@ module Clacky
6450
6899
  msg_created_at = Time.now.to_f
6451
6900
  web_ui = nil
6452
6901
  @registry.with_session(session_id) { |s| web_ui = s[:ui] }
6453
- web_ui&.show_user_message(content, created_at: msg_created_at, source: :web)
6902
+ # Pass the uploaded files through to the realtime broadcast so images and
6903
+ # attachments render in the bubble immediately. agent.run later appends the
6904
+ # authoritative display_files to history — but only after vision/OCR
6905
+ # processing finishes, which can take seconds. Without the preview here, a
6906
+ # page refresh inside that window shows the message without its image (the
6907
+ # "need to refresh several times before the image appears" bug).
6908
+ web_ui&.show_user_message(content, created_at: msg_created_at, source: :web, files: Array(files))
6454
6909
 
6455
6910
  # File references are now handled inside agent.run — injected as a system_injected
6456
6911
  # message after the user message, so replay_history skips them automatically.
@@ -6745,7 +7200,7 @@ module Clacky
6745
7200
  # @param working_dir [String] working directory for the agent
6746
7201
  # @param permission_mode [Symbol] :confirm_all (default, human present) or
6747
7202
  # :auto_approve (unattended — suppresses request_user_feedback waits)
6748
- def build_session(name:, working_dir: nil, permission_mode: :confirm_all, profile: "general", source: :manual, model_id: nil)
7203
+ def build_session(name:, working_dir: nil, permission_mode: :confirm_all, profile: "general", source: :manual, model_id: nil, hidden: false)
6749
7204
  working_dir ||= default_working_dir
6750
7205
  FileUtils.mkdir_p(working_dir) unless Dir.exist?(working_dir)
6751
7206
  session_id = Clacky::SessionManager.generate_id
@@ -6784,6 +7239,7 @@ module Clacky
6784
7239
  agent = Clacky::Agent.new(client, config, working_dir: working_dir, ui: ui, profile: profile,
6785
7240
  session_id: session_id, source: source)
6786
7241
  agent.rename(name) unless name.nil? || name.empty?
7242
+ agent.hidden = hidden
6787
7243
  idle_timer = build_idle_timer(session_id, agent)
6788
7244
 
6789
7245
  @registry.with_session(session_id) do |s|