openclacky 1.5.2 → 1.5.4

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 (57) hide show
  1. checksums.yaml +4 -4
  2. data/.dockerignore +13 -0
  3. data/CHANGELOG.md +50 -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 +13 -15
  10. data/lib/clacky/agent/session_serializer.rb +6 -0
  11. data/lib/clacky/agent.rb +13 -1
  12. data/lib/clacky/agent_config.rb +42 -11
  13. data/lib/clacky/brand_config.rb +32 -12
  14. data/lib/clacky/client.rb +7 -0
  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 +2 -6
  17. data/lib/clacky/default_extensions/ext-studio/panels/studio/view.js +11 -4
  18. data/lib/clacky/default_extensions/ext-studio/skills/ext-develop/SKILL.md +5 -1
  19. data/lib/clacky/default_parsers/xlsx_parser.py +66 -0
  20. data/lib/clacky/extension/api_extension.rb +3 -2
  21. data/lib/clacky/message_format/anthropic.rb +27 -1
  22. data/lib/clacky/message_format/open_ai.rb +92 -4
  23. data/lib/clacky/providers.rb +67 -14
  24. data/lib/clacky/server/http_server.rb +361 -36
  25. data/lib/clacky/server/project_manager.rb +150 -0
  26. data/lib/clacky/server/server_master.rb +7 -0
  27. data/lib/clacky/server/session_registry.rb +14 -1
  28. data/lib/clacky/session_manager.rb +5 -4
  29. data/lib/clacky/tools/file_reader.rb +2 -1
  30. data/lib/clacky/utils/model_pricing.rb +77 -18
  31. data/lib/clacky/utils/parser_manager.rb +115 -7
  32. data/lib/clacky/version.rb +1 -1
  33. data/lib/clacky/web/app.css +1053 -143
  34. data/lib/clacky/web/app.js +19 -5
  35. data/lib/clacky/web/components/code-editor.js +50 -12
  36. data/lib/clacky/web/components/notify.js +139 -22
  37. data/lib/clacky/web/core/aside.js +21 -0
  38. data/lib/clacky/web/features/billing/view.js +2 -1
  39. data/lib/clacky/web/features/brand/store.js +3 -1
  40. data/lib/clacky/web/features/brand/view.js +29 -5
  41. data/lib/clacky/web/features/extensions/store.js +15 -6
  42. data/lib/clacky/web/features/extensions/view.js +8 -4
  43. data/lib/clacky/web/features/new-session/view.js +2 -2
  44. data/lib/clacky/web/features/trash/store.js +23 -9
  45. data/lib/clacky/web/features/trash/view.js +51 -0
  46. data/lib/clacky/web/features/workspace/store.js +9 -0
  47. data/lib/clacky/web/features/workspace/view.js +8 -1
  48. data/lib/clacky/web/i18n.js +99 -4
  49. data/lib/clacky/web/index.html +70 -17
  50. data/lib/clacky/web/projects.js +1143 -0
  51. data/lib/clacky/web/sessions.js +318 -537
  52. data/lib/clacky/web/settings.js +146 -7
  53. data/lib/clacky/web/ws-dispatcher.js +59 -1
  54. data/scripts/build/src/install_system_deps.sh.cc +11 -3
  55. data/scripts/install_system_deps.sh +11 -3
  56. metadata +5 -2
  57. 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),
@@ -520,6 +522,8 @@ module Clacky
520
522
  case [method, path]
521
523
  when ["GET", "/api/sessions"] then api_list_sessions(req, res)
522
524
  when ["POST", "/api/sessions"] then api_create_session(req, res)
525
+ when ["GET", "/api/projects"] then api_list_projects(res)
526
+ when ["POST", "/api/projects"] then api_create_project(req, res)
523
527
  when ["GET", "/api/cron-tasks"] then api_list_cron_tasks(res)
524
528
  when ["POST", "/api/cron-tasks"] then api_create_cron_task(req, res)
525
529
  when ["GET", "/api/skills"] then api_list_skills(res)
@@ -598,7 +602,10 @@ module Clacky
598
602
  when ["GET", "/api/billing/daily"] then api_billing_daily(req, res)
599
603
  when ["GET", "/api/billing/records"] then api_billing_records(req, res)
600
604
  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)
605
+ when ["DELETE", "/api/billing/clear"] then api_billing_clear(req, res)
606
+ when ["POST", "/api/ui/open_aside"] then api_ui_open_aside(req, res)
607
+ when ["POST", "/api/ui/show_ext_refresh"] then api_ui_show_ext_refresh(req, res)
608
+ when ["PATCH", "/api/sessions/:id/model"] then api_switch_session_model(req, res)
602
609
  when ["PATCH", "/api/sessions/:id/working_dir"] then api_change_session_working_dir(req, res)
603
610
  else
604
611
  if method == "POST" && path.match?(%r{^/api/channels/[^/]+/send$})
@@ -688,6 +695,9 @@ module Clacky
688
695
  elsif method == "PATCH" && path.match?(%r{^/api/sessions/[^/]+$})
689
696
  session_id = path.sub("/api/sessions/", "")
690
697
  api_rename_session(session_id, req, res)
698
+ elsif method == "PATCH" && path.match?(%r{^/api/sessions/[^/]+/project$})
699
+ session_id = path.sub("/api/sessions/", "").sub("/project", "")
700
+ api_update_session_project(session_id, req, res)
691
701
  elsif method == "PATCH" && path.match?(%r{^/api/sessions/[^/]+/model$})
692
702
  session_id = path.sub("/api/sessions/", "").sub("/model", "")
693
703
  api_switch_session_model(session_id, req, res)
@@ -763,6 +773,12 @@ module Clacky
763
773
  elsif method == "DELETE" && path.match?(%r{^/api/memories/[^/]+$})
764
774
  filename = URI.decode_www_form_component(path.sub("/api/memories/", ""))
765
775
  api_memories_delete(filename, res)
776
+ elsif method == "PATCH" && path.match?(%r{^/api/projects/[^/]+$})
777
+ project_id = path.sub("/api/projects/", "")
778
+ api_update_project(project_id, req, res)
779
+ elsif method == "DELETE" && path.match?(%r{^/api/projects/[^/]+$})
780
+ project_id = path.sub("/api/projects/", "")
781
+ api_delete_project(project_id, res)
766
782
  else
767
783
  not_found(res)
768
784
  end
@@ -773,7 +789,7 @@ module Clacky
773
789
 
774
790
  def api_list_sessions(req, res)
775
791
  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
792
+ limit = [query["limit"].to_i.then { |n| n > 0 ? n : 15 }, 50].min
777
793
  before = query["before"].to_s.strip.then { |v| v.empty? ? nil : v }
778
794
  q = query["q"].to_s.strip.then { |v| v.empty? ? nil : v }
779
795
  q_scope = query["q_scope"].to_s.strip.then { |v| %w[name content].include?(v) ? v : "name" }
@@ -837,11 +853,37 @@ module Clacky
837
853
  return json_response(res, 400, { error: "Model not found in configuration" })
838
854
  end
839
855
 
856
+ # Optional project association — validate the project exists if provided
857
+ project_id_override = body["project_id"].to_s.strip
858
+ project_id_override = nil if project_id_override.empty?
859
+ if project_id_override && @project_manager.find(project_id_override).nil?
860
+ return json_response(res, 400, { error: "Project not found" })
861
+ end
862
+
863
+ # If no explicit working_dir was given but the project has one, inherit it.
864
+ if raw_dir.empty? && project_id_override
865
+ project = @project_manager.find(project_id_override)
866
+ if project && project[:working_dir].to_s.strip != ""
867
+ working_dir = File.expand_path(project[:working_dir])
868
+ end
869
+ end
870
+
840
871
  # Create working directory if it doesn't exist
841
872
  # Allow multiple sessions in the same directory
842
873
  FileUtils.mkdir_p(working_dir)
843
874
 
844
875
  session_id = build_session(name: name, working_dir: working_dir, profile: profile, source: source, model_id: model_id_override)
876
+
877
+ # Persist project_id into the session file right away if provided
878
+ if project_id_override
879
+ agent = nil
880
+ @registry.with_session(session_id) { |s| agent = s[:agent] }
881
+ if agent
882
+ agent.project_id = project_id_override
883
+ @session_manager.save(agent.to_session_data)
884
+ end
885
+ end
886
+
845
887
  broadcast_session_update(session_id)
846
888
  json_response(res, 201, { session: @registry.session_summary(session_id) })
847
889
  end
@@ -1081,16 +1123,19 @@ module Clacky
1081
1123
  end
1082
1124
 
1083
1125
  # 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/
1126
+ # 1. ext.yml containers — panel view.js served from /ext_ui/
1127
+ # (also triggers ExtensionLoader.load_all, refreshing @last_result)
1128
+ # 2. panel→agents registry (which agent profiles reference each panel)
1129
+ # (reads last_result — must come AFTER container_ext_script_tags so
1130
+ # it sees the freshly scanned result, not a stale one)
1131
+ # 3. agent-scoped UI — user ~/.clacky/agents/<name>/webui/**/*.js (data-agent)
1087
1132
  # Never raises.
1088
1133
  private def webui_ext_script_tags
1089
1134
  [
1135
+ container_ext_script_tags,
1090
1136
  panel_agents_script,
1091
1137
  ext_contributions_script,
1092
1138
  agent_webui_script_tags,
1093
- container_ext_script_tags,
1094
1139
  ].reject(&:empty?).join("\n")
1095
1140
  end
1096
1141
 
@@ -1166,6 +1211,14 @@ module Clacky
1166
1211
  result = Hash.new { |h, k| h[k] = [] }
1167
1212
  add = ->(pid, agent) { result[pid] << agent unless result[pid].include?(agent) }
1168
1213
 
1214
+ # Use the cached last_result (populated by the load_all call in
1215
+ # container_ext_script_tags which is always called in the same
1216
+ # webui_ext_script_tags render pass). Calling load_all() here with
1217
+ # no arguments would overwrite @last_result with a default-layer scan,
1218
+ # clobbering any test-injected result and causing panel attach: to
1219
+ # silently produce empty lists.
1220
+ ext_result = Clacky::ExtensionLoader.last_result
1221
+
1169
1222
  agent_data = agent_profile_data
1170
1223
  agent_data.each do |name, data|
1171
1224
  ext_id = data["_ext_id"]
@@ -1176,8 +1229,11 @@ module Clacky
1176
1229
  end
1177
1230
  end
1178
1231
 
1179
- all_agent_ids = agent_data.keys
1180
- Array(Clacky::ExtensionLoader.last_result&.panels).each do |unit|
1232
+ # Always include "general" — it is the implicit default profile for sessions
1233
+ # that have no custom agent assigned. Without this, attach: [\"*\"] panels
1234
+ # would never appear in general-profile sessions when no named agent dirs exist.
1235
+ all_agent_ids = (agent_data.keys + ["general"]).uniq
1236
+ Array(ext_result&.panels).each do |unit|
1181
1237
  full_id = "#{unit.ext_id}/#{unit.id}"
1182
1238
  attach = Array(unit.spec["attach"])
1183
1239
  next if attach.empty?
@@ -2182,6 +2238,13 @@ module Clacky
2182
2238
  # * All exceptions are swallowed; a refresh failure must not crash the
2183
2239
  # server or leak through the web stack.
2184
2240
  def trigger_async_distribution_refresh!
2241
+ refresh_source = worker_clacky_license_server
2242
+ source_current = -> { effective_clacky_license_server == refresh_source }
2243
+ unless source_current.call
2244
+ Clacky::Logger.debug("[Brand] distribution refresh skipped — worker source is stale")
2245
+ return
2246
+ end
2247
+
2185
2248
  BRAND_DIST_REFRESH_MUTEX.synchronize do
2186
2249
  if @@brand_dist_refresh_inflight
2187
2250
  Clacky::Logger.debug("[Brand] distribution refresh already in flight, skipping")
@@ -2194,7 +2257,7 @@ module Clacky
2194
2257
  Clacky::Logger.info("[Brand] async distribution refresh starting...")
2195
2258
  begin
2196
2259
  brand = Clacky::BrandConfig.load
2197
- result = brand.refresh_distribution!
2260
+ result = brand.refresh_distribution!(&source_current)
2198
2261
  if result[:success]
2199
2262
  Clacky::Logger.info("[Brand] async distribution refresh OK")
2200
2263
  else
@@ -2202,7 +2265,7 @@ module Clacky
2202
2265
  end
2203
2266
  # Free-mode skill sync: branded + unactivated installs need their
2204
2267
  # creator's free skills auto-installed for the "no serial number" UX.
2205
- brand.sync_free_skills_async!
2268
+ brand.sync_free_skills_async! if source_current.call
2206
2269
  rescue StandardError => e
2207
2270
  Clacky::Logger.warn("[Brand] async distribution refresh raised: #{e.class}: #{e.message}")
2208
2271
  ensure
@@ -2227,7 +2290,16 @@ module Clacky
2227
2290
  brand = Clacky::BrandConfig.load
2228
2291
 
2229
2292
  unless brand.branded?
2230
- json_response(res, 200, { branded: false })
2293
+ refresh_pending = false
2294
+ if brand.distribution_refresh_due?
2295
+ trigger_async_distribution_refresh!
2296
+ refresh_pending = true
2297
+ end
2298
+
2299
+ json_response(res, 200, {
2300
+ branded: false,
2301
+ distribution_refresh_pending: refresh_pending
2302
+ })
2231
2303
  return
2232
2304
  end
2233
2305
 
@@ -2346,13 +2418,18 @@ module Clacky
2346
2418
  # Deactivates (unbinds) the current brand license and clears all brand state.
2347
2419
  # Brand skills are removed from disk. Returns 200 on success.
2348
2420
  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({}))
2421
+ deactivate_brand!
2353
2422
  json_response(res, 200, { ok: true })
2354
2423
  end
2355
2424
 
2425
+ private def deactivate_brand!
2426
+ Clacky::BrandConfig.load.deactivate!
2427
+ @skill_loader = Clacky::SkillLoader.new(
2428
+ working_dir: nil,
2429
+ brand_config: Clacky::BrandConfig.new({})
2430
+ )
2431
+ end
2432
+
2356
2433
  # GET /api/brand/skills
2357
2434
  # Fetches the brand skills list from the cloud, enriched with local installed version.
2358
2435
  # Returns 200 with skill list, or 403 when license is not activated.
@@ -2492,11 +2569,11 @@ module Clacky
2492
2569
  "origin" => market ? (market["origin"] || container[:origin]) : container[:origin],
2493
2570
  "hub_active" => market&.dig("hub_active"),
2494
2571
  "download_count" => market&.dig("download_count").to_i,
2495
- # Only mark as unlisted when the extension was published to the
2496
- # marketplace (origin != "self") but is no longer listed there.
2497
- # Self-authored extensions (origin: self) have never been published
2498
- # and should never show the "unlisted" badge.
2499
- "unlisted" => market.nil? && container[:origin].to_s != "self",
2572
+ # Mark as unlisted when:
2573
+ # - market is nil (extension no longer exists on the platform), OR
2574
+ # - platform batch API explicitly returned unlisted:true (brand-private
2575
+ # extension removed from all distributions but not yet soft-deleted).
2576
+ "unlisted" => market.nil? || market["unlisted"] == true,
2500
2577
  "layer" => container[:layer].to_s,
2501
2578
  "installed" => true,
2502
2579
  "removable" => true,
@@ -2582,7 +2659,7 @@ module Clacky
2582
2659
  "installed" => !container.nil?,
2583
2660
  "installed_version" => container&.dig(:version),
2584
2661
  "removable" => container && container[:layer] == :installed,
2585
- "disabled" => container ? container[:disabled] == true : false
2662
+ "disabled" => container ? container[:disabled] == true : false,
2586
2663
  )
2587
2664
  json_response(res, 200, { ok: true, extension: ext })
2588
2665
  else
@@ -2609,9 +2686,7 @@ module Clacky
2609
2686
  "homepage" => market ? (market["homepage"] || "") : container[:homepage].to_s,
2610
2687
  "origin" => market ? (market["origin"] || container[:origin]) : container[:origin],
2611
2688
  "hub_active" => market&.dig("hub_active"),
2612
- # Only mark as unlisted when the extension was published to the
2613
- # marketplace (origin != "self") but is no longer listed there.
2614
- "unlisted" => market.nil? && container[:origin].to_s != "self",
2689
+ "unlisted" => market.nil? || market["unlisted"] == true,
2615
2690
  "installed" => true,
2616
2691
  "removable" => true,
2617
2692
  "disabled" => container[:disabled] == true,
@@ -2949,6 +3024,41 @@ module Clacky
2949
3024
  json_response(res, 500, { error: e.message })
2950
3025
  end
2951
3026
 
3027
+ # POST /api/ui/open_aside
3028
+ # Broadcasts an open_aside event to the specified session's WebSocket clients,
3029
+ # causing the browser to open the right-side panel.
3030
+ # Body: { session_id: "..." }
3031
+ def api_ui_open_aside(req, res)
3032
+ body = parse_json_body(req) || {}
3033
+ session_id = body["session_id"].to_s.strip
3034
+ if session_id.empty?
3035
+ json_response(res, 400, { error: "session_id is required" })
3036
+ return
3037
+ end
3038
+ broadcast(session_id, { type: "open_aside" })
3039
+ json_response(res, 200, { ok: true })
3040
+ rescue => e
3041
+ json_response(res, 500, { error: e.message })
3042
+ end
3043
+
3044
+ # POST /api/ui/show_ext_refresh
3045
+ # Broadcasts a show_ext_refresh event to the specified session's WebSocket clients,
3046
+ # causing the browser to display a one-click "reload extensions" button.
3047
+ # Called by the AI after editing extension files so the user knows to reload.
3048
+ # Body: { session_id: "..." }
3049
+ def api_ui_show_ext_refresh(req, res)
3050
+ body = parse_json_body(req) || {}
3051
+ session_id = body["session_id"].to_s.strip
3052
+ if session_id.empty?
3053
+ json_response(res, 400, { error: "session_id is required" })
3054
+ return
3055
+ end
3056
+ broadcast(session_id, { type: "show_ext_refresh" })
3057
+ json_response(res, 200, { ok: true })
3058
+ rescue => e
3059
+ json_response(res, 500, { error: e.message })
3060
+ end
3061
+
2952
3062
  # GET /api/version
2953
3063
  # Returns current version and latest version from RubyGems (cached for 1 hour).
2954
3064
  def api_get_version(res)
@@ -3255,7 +3365,10 @@ module Clacky
3255
3365
  # Responds 200 first, then waits briefly for WEBrick to flush the response before exec.
3256
3366
  def api_restart(req, res)
3257
3367
  json_response(res, 200, { ok: true, message: "Restarting…" })
3368
+ schedule_restart
3369
+ end
3258
3370
 
3371
+ private def schedule_restart
3259
3372
  Thread.new do
3260
3373
  sleep 0.5 # Let WEBrick flush the HTTP response
3261
3374
 
@@ -3785,9 +3898,10 @@ module Clacky
3785
3898
 
3786
3899
  # POST /api/file-action
3787
3900
  # Unified file action endpoint — open locally or download.
3788
- # Body: { path: String, action: "open" | "download" }
3901
+ # Body: { path: String, action: "open" | "download" | "save" }
3789
3902
  # open: opens the file with the OS default handler (local deployments).
3790
3903
  # download: returns the file as a download (remote deployments).
3904
+ # save: writes content back to the file. Body must include { content: String }.
3791
3905
  def api_file_action(req, res)
3792
3906
  body = parse_json_body(req)
3793
3907
  path = body["path"]
@@ -3801,7 +3915,10 @@ module Clacky
3801
3915
  linux_path = Utils::EnvironmentDetector.win_to_linux_path(path)
3802
3916
  linux_path = File.expand_path(linux_path)
3803
3917
 
3804
- return json_response(res, 404, { error: "file not found" }) unless File.exist?(linux_path)
3918
+ # For save action, the file may not exist yet — skip the existence check.
3919
+ unless action == "save"
3920
+ return json_response(res, 404, { error: "file not found" }) unless File.exist?(linux_path)
3921
+ end
3805
3922
 
3806
3923
  case action
3807
3924
  when "open"
@@ -3816,8 +3933,14 @@ module Clacky
3816
3933
  when "display-path"
3817
3934
  display = Utils::EnvironmentDetector.linux_to_win_path(linux_path)
3818
3935
  json_response(res, 200, { ok: true, path: display })
3936
+ when "save"
3937
+ content = body["content"]
3938
+ return json_response(res, 400, { error: "content is required" }) if content.nil?
3939
+ FileUtils.mkdir_p(File.dirname(linux_path))
3940
+ File.write(linux_path, content, encoding: "UTF-8")
3941
+ json_response(res, 200, { ok: true })
3819
3942
  else
3820
- json_response(res, 400, { error: "invalid action. Must be 'open', 'reveal', 'download' or 'display-path'" })
3943
+ json_response(res, 400, { error: "invalid action. Must be 'open', 'reveal', 'download', 'display-path' or 'save'" })
3821
3944
  end
3822
3945
  rescue => e
3823
3946
  json_response(res, 500, { ok: false, error: e.message })
@@ -4913,7 +5036,8 @@ module Clacky
4913
5036
  file_size: s[:file_size] || 0,
4914
5037
  model: s[:model],
4915
5038
  working_dir: s[:working_dir],
4916
- source: s[:source]
5039
+ source: s[:source],
5040
+ project_id: s[:project_id]
4917
5041
  }
4918
5042
  end
4919
5043
 
@@ -4944,6 +5068,15 @@ module Clacky
4944
5068
  return
4945
5069
  end
4946
5070
 
5071
+ # If the session belonged to a project that no longer exists, clear
5072
+ # the stale project_id so it falls back to the regular session list.
5073
+ restored_data = @session_manager.load(session_id)
5074
+ if restored_data && restored_data[:project_id]
5075
+ unless @project_manager.find(restored_data[:project_id])
5076
+ @session_manager.save(restored_data.merge(project_id: nil))
5077
+ end
5078
+ end
5079
+
4947
5080
  # Load the restored session into the registry so it behaves like any
4948
5081
  # other live session (status, agent, snapshot all available).
4949
5082
  @registry.ensure(session_id)
@@ -5536,7 +5669,8 @@ module Clacky
5536
5669
  enable_compression: @agent_config.enable_compression,
5537
5670
  enable_prompt_caching: @agent_config.enable_prompt_caching,
5538
5671
  memory_update_enabled: @agent_config.memory_update_enabled,
5539
- proxy_url: @agent_config.proxy_url.to_s
5672
+ proxy_url: @agent_config.proxy_url.to_s,
5673
+ clacky_license_server: effective_clacky_license_server
5540
5674
  })
5541
5675
  end
5542
5676
 
@@ -5545,6 +5679,16 @@ module Clacky
5545
5679
  body = parse_json_body(req)
5546
5680
  return json_response(res, 400, { error: "Invalid JSON" }) unless body
5547
5681
 
5682
+ source_requested = body.key?("clacky_license_server")
5683
+ normalized_source = nil
5684
+ if source_requested
5685
+ begin
5686
+ normalized_source = normalize_http_origin(body["clacky_license_server"])
5687
+ rescue ArgumentError => e
5688
+ return json_response(res, 422, { ok: false, error: e.message })
5689
+ end
5690
+ end
5691
+
5548
5692
  if body.key?("enable_compression")
5549
5693
  @agent_config.enable_compression = !!body["enable_compression"]
5550
5694
  end
@@ -5571,12 +5715,69 @@ module Clacky
5571
5715
  end
5572
5716
  end
5573
5717
 
5718
+ source_changed = source_requested &&
5719
+ normalized_source != effective_clacky_license_server
5720
+ deactivate_brand! if source_changed
5721
+ @agent_config.clacky_license_server = normalized_source if source_requested
5722
+
5574
5723
  @agent_config.save
5575
- json_response(res, 200, { ok: true })
5724
+ payload = { ok: true }
5725
+ if source_requested
5726
+ payload.merge!(
5727
+ clacky_license_server: normalized_source,
5728
+ source_changed: source_changed,
5729
+ restarting: source_changed
5730
+ )
5731
+ end
5732
+ json_response(res, 200, payload)
5733
+ schedule_restart if source_changed
5576
5734
  rescue => e
5577
5735
  json_response(res, 422, { error: e.message })
5578
5736
  end
5579
5737
 
5738
+ private def effective_clacky_license_server
5739
+ source = @agent_config.clacky_license_server.to_s.strip
5740
+ source = ENV["CLACKY_LICENSE_SERVER"].to_s.strip if source.empty?
5741
+ source = Clacky::PlatformHttpClient::PRIMARY_HOST if source.empty?
5742
+ normalize_http_origin(source)
5743
+ rescue ArgumentError
5744
+ source
5745
+ end
5746
+
5747
+ private def worker_clacky_license_server
5748
+ source = ENV["CLACKY_LICENSE_SERVER"].to_s.strip
5749
+ source = Clacky::PlatformHttpClient::PRIMARY_HOST if source.empty?
5750
+ normalize_http_origin(source)
5751
+ rescue ArgumentError
5752
+ source
5753
+ end
5754
+
5755
+ private def normalize_http_origin(value)
5756
+ raw = value.to_s.strip
5757
+ uri = URI.parse(raw)
5758
+ scheme = uri.scheme.to_s.downcase
5759
+ host = uri.host.to_s.downcase
5760
+ path = uri.path.to_s
5761
+
5762
+ valid = uri.is_a?(URI::HTTP) &&
5763
+ %w[http https].include?(scheme) &&
5764
+ !host.empty? &&
5765
+ uri.userinfo.nil? &&
5766
+ (path.empty? || path == "/") &&
5767
+ uri.query.nil? &&
5768
+ uri.fragment.nil?
5769
+ unless valid
5770
+ raise ArgumentError,
5771
+ "clacky_license_server must be an HTTP/HTTPS origin without credentials, path, query, or fragment"
5772
+ end
5773
+
5774
+ default_port = scheme == "https" ? 443 : 80
5775
+ port_suffix = uri.port == default_port ? "" : ":#{uri.port}"
5776
+ "#{scheme}://#{host}#{port_suffix}"
5777
+ rescue URI::InvalidURIError
5778
+ raise ArgumentError, "clacky_license_server is not a valid URL"
5779
+ end
5780
+
5580
5781
  # DEPRECATED: this endpoint previously accepted the entire models array
5581
5782
  # and replaced @models in place. That design was fragile — any missing
5582
5783
  # or stale field on ANY row could wipe other rows' api_keys. It has
@@ -5880,6 +6081,113 @@ module Clacky
5880
6081
  json_response(res, 200, { events: collected, has_more: result[:has_more] })
5881
6082
  end
5882
6083
 
6084
+ # ── Project API ───────────────────────────────────────────────────────────
6085
+
6086
+ # GET /api/projects — list all projects
6087
+ def api_list_projects(res)
6088
+ projects = @project_manager.all
6089
+ json_response(res, 200, { projects: projects })
6090
+ end
6091
+
6092
+ # POST /api/projects — create a new project
6093
+ # Body: { name:, description:?, color:?, icon:?, working_dir:? }
6094
+ def api_create_project(req, res)
6095
+ body = parse_json_body(req)
6096
+ name = body["name"].to_s.strip
6097
+ return json_response(res, 400, { error: "name is required" }) if name.empty?
6098
+
6099
+ description = body["description"]
6100
+ color = body["color"]
6101
+ icon = body["icon"]
6102
+ working_dir = body["working_dir"].to_s.strip
6103
+ working_dir = working_dir.empty? ? nil : File.expand_path(working_dir)
6104
+
6105
+ project = @project_manager.create(name: name, description: description, color: color, icon: icon, working_dir: working_dir)
6106
+ json_response(res, 201, { project: project })
6107
+ rescue => e
6108
+ json_response(res, 500, { error: e.message })
6109
+ end
6110
+
6111
+ # PATCH /api/projects/:id — update a project
6112
+ # Body: { name:?, description:?, color:?, working_dir:? } — only provided fields are changed
6113
+ def api_update_project(project_id, req, res)
6114
+ body = parse_json_body(req)
6115
+ return json_response(res, 404, { error: "Project not found" }) if @project_manager.find(project_id).nil?
6116
+
6117
+ kwargs = {}
6118
+ kwargs[:name] = body["name"] if body.key?("name")
6119
+ kwargs[:description] = body["description"] if body.key?("description")
6120
+ kwargs[:color] = body["color"] if body.key?("color")
6121
+ kwargs[:icon] = body["icon"] if body.key?("icon")
6122
+ if body.key?("working_dir")
6123
+ raw_wd = body["working_dir"].to_s.strip
6124
+ kwargs[:working_dir] = raw_wd.empty? ? nil : File.expand_path(raw_wd)
6125
+ end
6126
+
6127
+ project = @project_manager.update(project_id, **kwargs)
6128
+ return json_response(res, 404, { error: "Project not found" }) unless project
6129
+
6130
+ json_response(res, 200, { project: project })
6131
+ rescue ArgumentError => e
6132
+ json_response(res, 400, { error: e.message })
6133
+ rescue => e
6134
+ json_response(res, 500, { error: e.message })
6135
+ end
6136
+
6137
+ # DELETE /api/projects/:id — delete a project and all its sessions (soft-delete to trash)
6138
+ def api_delete_project(project_id, res)
6139
+ return json_response(res, 404, { error: "Project not found" }) unless @project_manager.find(project_id)
6140
+
6141
+ @project_manager.delete(project_id)
6142
+
6143
+ # Soft-delete all sessions that belonged to this project.
6144
+ @session_manager.all_sessions.each do |session_data|
6145
+ next unless session_data[:project_id].to_s == project_id
6146
+
6147
+ sid = session_data[:session_id]
6148
+ in_registry = @registry.exist?(sid)
6149
+ on_disk = !@session_manager.load(sid).nil?
6150
+
6151
+ @registry.delete(sid) if in_registry
6152
+ @session_manager.soft_delete(sid) if on_disk
6153
+
6154
+ broadcast(sid, { type: "session_deleted", session_id: sid })
6155
+ unsubscribe_all(sid)
6156
+ end
6157
+
6158
+ json_response(res, 200, { ok: true })
6159
+ rescue => e
6160
+ json_response(res, 500, { error: e.message })
6161
+ end
6162
+
6163
+ # PATCH /api/sessions/:id/project — move a session into or out of a project
6164
+ # Body: { project_id: "<id>" } to assign, { project_id: null } to remove
6165
+ def api_update_session_project(session_id, req, res)
6166
+ body = parse_json_body(req)
6167
+ new_pid = body.key?("project_id") ? body["project_id"] : :__unset
6168
+
6169
+ return json_response(res, 400, { error: "project_id key is required" }) if new_pid == :__unset
6170
+
6171
+ # Validate the target project exists (skip when clearing)
6172
+ if new_pid && @project_manager.find(new_pid.to_s).nil?
6173
+ return json_response(res, 400, { error: "Project not found" })
6174
+ end
6175
+
6176
+ return json_response(res, 404, { error: "Session not found" }) unless @registry.ensure(session_id)
6177
+
6178
+ agent = nil
6179
+ @registry.with_session(session_id) { |s| agent = s[:agent] }
6180
+ return json_response(res, 404, { error: "Session not found" }) unless agent
6181
+
6182
+ agent.project_id = new_pid ? new_pid.to_s : nil
6183
+ @session_manager.save(agent.to_session_data)
6184
+ broadcast_session_update(session_id)
6185
+
6186
+ json_response(res, 200, { ok: true, project_id: agent.project_id })
6187
+ rescue => e
6188
+ json_response(res, 500, { error: e.message })
6189
+ end
6190
+
5883
6191
  def api_rename_session(session_id, req, res)
5884
6192
  body = parse_json_body(req)
5885
6193
  new_name = body["name"]&.to_s&.strip
@@ -6357,12 +6665,28 @@ module Clacky
6357
6665
  interrupt_session(session_id)
6358
6666
 
6359
6667
  when "list_sessions"
6360
- stats = @registry.cron_stats
6361
- page = @registry.list(limit: 21, exclude_type: "cron")
6362
- has_more = page.size > 20
6363
- all_sessions = page.first(20)
6668
+ stats = @registry.cron_stats
6669
+ page = @registry.list(limit: 16, exclude_type: "cron", exclude_project: true)
6670
+ has_more = page.size > 15
6671
+ all_sessions = page.first(15)
6672
+ projects = @project_manager.all
6673
+ # Include ALL sessions that belong to any project, regardless of the
6674
+ # 15-item pagination limit. We merge them into the same `sessions`
6675
+ # array; the client deduplicates by id in `setAll`.
6676
+ if projects.any?
6677
+ project_ids = projects.map { |p| p[:id] }
6678
+ project_sessions = project_ids.flat_map do |pid|
6679
+ @registry.list(project_id: pid, exclude_type: "cron")
6680
+ end
6681
+ # Deduplicate: project sessions that are already in the first page
6682
+ # will be replaced by the enriched version from `all_sessions`.
6683
+ paged_ids = all_sessions.map { |s| s[:id] }.to_set
6684
+ extra_project_sessions = project_sessions.reject { |s| paged_ids.include?(s[:id]) }
6685
+ all_sessions = all_sessions + extra_project_sessions
6686
+ end
6364
6687
  conn.send_json(type: "session_list", sessions: all_sessions, has_more: has_more,
6365
- cron_count: stats[:count], latest_cron_updated_at: stats[:latest_updated_at])
6688
+ cron_count: stats[:count], latest_cron_updated_at: stats[:latest_updated_at],
6689
+ projects: projects)
6366
6690
 
6367
6691
  when "run_task"
6368
6692
  # Client sends this after subscribing to guarantee it's ready to receive
@@ -6737,7 +7061,7 @@ module Clacky
6737
7061
  # @param working_dir [String] working directory for the agent
6738
7062
  # @param permission_mode [Symbol] :confirm_all (default, human present) or
6739
7063
  # :auto_approve (unattended — suppresses request_user_feedback waits)
6740
- def build_session(name:, working_dir: nil, permission_mode: :confirm_all, profile: "general", source: :manual, model_id: nil)
7064
+ def build_session(name:, working_dir: nil, permission_mode: :confirm_all, profile: "general", source: :manual, model_id: nil, hidden: false)
6741
7065
  working_dir ||= default_working_dir
6742
7066
  FileUtils.mkdir_p(working_dir) unless Dir.exist?(working_dir)
6743
7067
  session_id = Clacky::SessionManager.generate_id
@@ -6776,6 +7100,7 @@ module Clacky
6776
7100
  agent = Clacky::Agent.new(client, config, working_dir: working_dir, ui: ui, profile: profile,
6777
7101
  session_id: session_id, source: source)
6778
7102
  agent.rename(name) unless name.nil? || name.empty?
7103
+ agent.hidden = hidden
6779
7104
  idle_timer = build_idle_timer(session_id, agent)
6780
7105
 
6781
7106
  @registry.with_session(session_id) do |s|