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.
- checksums.yaml +4 -4
- data/.dockerignore +13 -0
- data/CHANGELOG.md +57 -0
- data/Dockerfile +21 -1
- data/README.md +26 -7
- data/README_CN.md +26 -7
- data/README_JA.md +26 -7
- data/lib/clacky/agent/message_compressor.rb +4 -29
- data/lib/clacky/agent/message_compressor_helper.rb +17 -16
- data/lib/clacky/agent/session_serializer.rb +7 -0
- data/lib/clacky/agent.rb +14 -2
- data/lib/clacky/agent_config.rb +10 -5
- data/lib/clacky/agent_profile.rb +25 -3
- data/lib/clacky/brand_config.rb +32 -12
- data/lib/clacky/default_extensions/ext-studio/agents/ext-developer/system_prompt.md +15 -0
- data/lib/clacky/default_extensions/ext-studio/api/handler.rb +1 -5
- data/lib/clacky/default_extensions/ext-studio/ext.yml +2 -2
- data/lib/clacky/default_extensions/ext-studio/panels/studio/view.js +43 -34
- data/lib/clacky/default_extensions/ext-studio/skills/ext-develop/SKILL.md +9 -3
- data/lib/clacky/default_extensions/git/ext.yml +3 -0
- data/lib/clacky/default_extensions/meeting/ext.yml +3 -0
- data/lib/clacky/default_extensions/time_machine/ext.yml +4 -0
- data/lib/clacky/default_parsers/xlsx_parser.py +66 -0
- data/lib/clacky/default_skills/new/SKILL.md +17 -12
- data/lib/clacky/extension/api_extension.rb +5 -2
- data/lib/clacky/extension/loader.rb +1 -0
- data/lib/clacky/extension/packager.rb +60 -15
- data/lib/clacky/extension/verifier.rb +1 -1
- data/lib/clacky/message_format/anthropic.rb +1 -1
- data/lib/clacky/message_format/bedrock.rb +1 -1
- data/lib/clacky/message_format/open_ai.rb +34 -3
- data/lib/clacky/providers.rb +77 -8
- data/lib/clacky/server/http_server.rb +495 -39
- data/lib/clacky/server/project_manager.rb +150 -0
- data/lib/clacky/server/server_master.rb +7 -0
- data/lib/clacky/server/session_registry.rb +14 -1
- data/lib/clacky/server/web_ui_controller.rb +12 -2
- data/lib/clacky/session_manager.rb +6 -4
- data/lib/clacky/tools/file_reader.rb +2 -1
- data/lib/clacky/tools/terminal.rb +30 -3
- data/lib/clacky/utils/model_pricing.rb +69 -18
- data/lib/clacky/utils/parser_manager.rb +115 -7
- data/lib/clacky/version.rb +1 -1
- data/lib/clacky/web/app.css +1216 -318
- data/lib/clacky/web/app.js +27 -4
- data/lib/clacky/web/components/notify.js +139 -22
- data/lib/clacky/web/core/aside.js +21 -0
- data/lib/clacky/web/features/brand/store.js +3 -1
- data/lib/clacky/web/features/brand/view.js +29 -5
- data/lib/clacky/web/features/extensions/store.js +214 -9
- data/lib/clacky/web/features/extensions/view.js +156 -11
- data/lib/clacky/web/features/new-session/store.js +25 -3
- data/lib/clacky/web/features/new-session/view.js +339 -142
- data/lib/clacky/web/features/skills/view.js +1 -1
- data/lib/clacky/web/features/trash/store.js +23 -9
- data/lib/clacky/web/features/trash/view.js +51 -0
- data/lib/clacky/web/i18n.js +124 -15
- data/lib/clacky/web/index.html +105 -79
- data/lib/clacky/web/projects.js +1143 -0
- data/lib/clacky/web/sessions.js +320 -538
- data/lib/clacky/web/settings.js +138 -5
- data/lib/clacky/web/utils.js +1 -0
- data/lib/clacky/web/ws-dispatcher.js +98 -4
- data/scripts/build/src/install_system_deps.sh.cc +11 -3
- data/scripts/install_system_deps.sh +11 -3
- metadata +5 -2
- 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
|
-
|
|
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
|
-
|
|
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)
|
|
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 :
|
|
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.
|
|
1085
|
-
#
|
|
1086
|
-
#
|
|
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
|
-
|
|
1180
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
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
|
-
|
|
2670
|
-
|
|
2671
|
-
|
|
2672
|
-
|
|
2673
|
-
|
|
2674
|
-
|
|
2675
|
-
|
|
2676
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
6369
|
-
page
|
|
6370
|
-
has_more = page.size >
|
|
6371
|
-
all_sessions = page.first(
|
|
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
|
-
|
|
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|
|