openclacky 1.5.3 → 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.
- checksums.yaml +4 -4
- data/.dockerignore +13 -0
- data/CHANGELOG.md +26 -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 +13 -15
- data/lib/clacky/agent/session_serializer.rb +6 -0
- data/lib/clacky/agent.rb +13 -1
- data/lib/clacky/agent_config.rb +6 -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/panels/studio/view.js +1 -1
- data/lib/clacky/default_extensions/ext-studio/skills/ext-develop/SKILL.md +5 -1
- data/lib/clacky/default_parsers/xlsx_parser.py +66 -0
- data/lib/clacky/extension/api_extension.rb +3 -2
- data/lib/clacky/message_format/open_ai.rb +26 -3
- data/lib/clacky/providers.rb +16 -8
- data/lib/clacky/server/http_server.rb +341 -24
- 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/session_manager.rb +5 -4
- data/lib/clacky/tools/file_reader.rb +2 -1
- 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 +903 -135
- data/lib/clacky/web/app.js +11 -2
- 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/new-session/view.js +2 -2
- 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 +95 -4
- data/lib/clacky/web/index.html +69 -15
- data/lib/clacky/web/projects.js +1143 -0
- data/lib/clacky/web/sessions.js +318 -537
- data/lib/clacky/web/settings.js +138 -5
- data/lib/clacky/web/ws-dispatcher.js +59 -1
- 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),
|
|
@@ -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)
|
|
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 :
|
|
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.
|
|
1085
|
-
#
|
|
1086
|
-
#
|
|
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
|
-
|
|
1180
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
@@ -2947,6 +3024,41 @@ module Clacky
|
|
|
2947
3024
|
json_response(res, 500, { error: e.message })
|
|
2948
3025
|
end
|
|
2949
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
|
+
|
|
2950
3062
|
# GET /api/version
|
|
2951
3063
|
# Returns current version and latest version from RubyGems (cached for 1 hour).
|
|
2952
3064
|
def api_get_version(res)
|
|
@@ -3253,7 +3365,10 @@ module Clacky
|
|
|
3253
3365
|
# Responds 200 first, then waits briefly for WEBrick to flush the response before exec.
|
|
3254
3366
|
def api_restart(req, res)
|
|
3255
3367
|
json_response(res, 200, { ok: true, message: "Restarting…" })
|
|
3368
|
+
schedule_restart
|
|
3369
|
+
end
|
|
3256
3370
|
|
|
3371
|
+
private def schedule_restart
|
|
3257
3372
|
Thread.new do
|
|
3258
3373
|
sleep 0.5 # Let WEBrick flush the HTTP response
|
|
3259
3374
|
|
|
@@ -4921,7 +5036,8 @@ module Clacky
|
|
|
4921
5036
|
file_size: s[:file_size] || 0,
|
|
4922
5037
|
model: s[:model],
|
|
4923
5038
|
working_dir: s[:working_dir],
|
|
4924
|
-
source: s[:source]
|
|
5039
|
+
source: s[:source],
|
|
5040
|
+
project_id: s[:project_id]
|
|
4925
5041
|
}
|
|
4926
5042
|
end
|
|
4927
5043
|
|
|
@@ -4952,6 +5068,15 @@ module Clacky
|
|
|
4952
5068
|
return
|
|
4953
5069
|
end
|
|
4954
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
|
+
|
|
4955
5080
|
# Load the restored session into the registry so it behaves like any
|
|
4956
5081
|
# other live session (status, agent, snapshot all available).
|
|
4957
5082
|
@registry.ensure(session_id)
|
|
@@ -5544,7 +5669,8 @@ module Clacky
|
|
|
5544
5669
|
enable_compression: @agent_config.enable_compression,
|
|
5545
5670
|
enable_prompt_caching: @agent_config.enable_prompt_caching,
|
|
5546
5671
|
memory_update_enabled: @agent_config.memory_update_enabled,
|
|
5547
|
-
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
|
|
5548
5674
|
})
|
|
5549
5675
|
end
|
|
5550
5676
|
|
|
@@ -5553,6 +5679,16 @@ module Clacky
|
|
|
5553
5679
|
body = parse_json_body(req)
|
|
5554
5680
|
return json_response(res, 400, { error: "Invalid JSON" }) unless body
|
|
5555
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
|
+
|
|
5556
5692
|
if body.key?("enable_compression")
|
|
5557
5693
|
@agent_config.enable_compression = !!body["enable_compression"]
|
|
5558
5694
|
end
|
|
@@ -5579,12 +5715,69 @@ module Clacky
|
|
|
5579
5715
|
end
|
|
5580
5716
|
end
|
|
5581
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
|
+
|
|
5582
5723
|
@agent_config.save
|
|
5583
|
-
|
|
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
|
|
5584
5734
|
rescue => e
|
|
5585
5735
|
json_response(res, 422, { error: e.message })
|
|
5586
5736
|
end
|
|
5587
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
|
+
|
|
5588
5781
|
# DEPRECATED: this endpoint previously accepted the entire models array
|
|
5589
5782
|
# and replaced @models in place. That design was fragile — any missing
|
|
5590
5783
|
# or stale field on ANY row could wipe other rows' api_keys. It has
|
|
@@ -5888,6 +6081,113 @@ module Clacky
|
|
|
5888
6081
|
json_response(res, 200, { events: collected, has_more: result[:has_more] })
|
|
5889
6082
|
end
|
|
5890
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
|
+
|
|
5891
6191
|
def api_rename_session(session_id, req, res)
|
|
5892
6192
|
body = parse_json_body(req)
|
|
5893
6193
|
new_name = body["name"]&.to_s&.strip
|
|
@@ -6365,12 +6665,28 @@ module Clacky
|
|
|
6365
6665
|
interrupt_session(session_id)
|
|
6366
6666
|
|
|
6367
6667
|
when "list_sessions"
|
|
6368
|
-
stats
|
|
6369
|
-
page
|
|
6370
|
-
has_more = page.size >
|
|
6371
|
-
all_sessions = page.first(
|
|
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
|
|
6372
6687
|
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]
|
|
6688
|
+
cron_count: stats[:count], latest_cron_updated_at: stats[:latest_updated_at],
|
|
6689
|
+
projects: projects)
|
|
6374
6690
|
|
|
6375
6691
|
when "run_task"
|
|
6376
6692
|
# Client sends this after subscribing to guarantee it's ready to receive
|
|
@@ -6745,7 +7061,7 @@ module Clacky
|
|
|
6745
7061
|
# @param working_dir [String] working directory for the agent
|
|
6746
7062
|
# @param permission_mode [Symbol] :confirm_all (default, human present) or
|
|
6747
7063
|
# :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)
|
|
7064
|
+
def build_session(name:, working_dir: nil, permission_mode: :confirm_all, profile: "general", source: :manual, model_id: nil, hidden: false)
|
|
6749
7065
|
working_dir ||= default_working_dir
|
|
6750
7066
|
FileUtils.mkdir_p(working_dir) unless Dir.exist?(working_dir)
|
|
6751
7067
|
session_id = Clacky::SessionManager.generate_id
|
|
@@ -6784,6 +7100,7 @@ module Clacky
|
|
|
6784
7100
|
agent = Clacky::Agent.new(client, config, working_dir: working_dir, ui: ui, profile: profile,
|
|
6785
7101
|
session_id: session_id, source: source)
|
|
6786
7102
|
agent.rename(name) unless name.nil? || name.empty?
|
|
7103
|
+
agent.hidden = hidden
|
|
6787
7104
|
idle_timer = build_idle_timer(session_id, agent)
|
|
6788
7105
|
|
|
6789
7106
|
@registry.with_session(session_id) do |s|
|