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.
- checksums.yaml +4 -4
- data/.dockerignore +13 -0
- data/CHANGELOG.md +50 -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 +42 -11
- data/lib/clacky/brand_config.rb +32 -12
- data/lib/clacky/client.rb +7 -0
- 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 +2 -6
- data/lib/clacky/default_extensions/ext-studio/panels/studio/view.js +11 -4
- 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/anthropic.rb +27 -1
- data/lib/clacky/message_format/open_ai.rb +92 -4
- data/lib/clacky/providers.rb +67 -14
- data/lib/clacky/server/http_server.rb +361 -36
- 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 +77 -18
- data/lib/clacky/utils/parser_manager.rb +115 -7
- data/lib/clacky/version.rb +1 -1
- data/lib/clacky/web/app.css +1053 -143
- data/lib/clacky/web/app.js +19 -5
- data/lib/clacky/web/components/code-editor.js +50 -12
- data/lib/clacky/web/components/notify.js +139 -22
- data/lib/clacky/web/core/aside.js +21 -0
- data/lib/clacky/web/features/billing/view.js +2 -1
- 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 +15 -6
- data/lib/clacky/web/features/extensions/view.js +8 -4
- 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/features/workspace/store.js +9 -0
- data/lib/clacky/web/features/workspace/view.js +8 -1
- data/lib/clacky/web/i18n.js +99 -4
- data/lib/clacky/web/index.html +70 -17
- data/lib/clacky/web/projects.js +1143 -0
- data/lib/clacky/web/sessions.js +318 -537
- data/lib/clacky/web/settings.js +146 -7
- 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.
|
|
@@ -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
|
-
#
|
|
2496
|
-
#
|
|
2497
|
-
#
|
|
2498
|
-
#
|
|
2499
|
-
"unlisted" => market.nil?
|
|
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
|
-
|
|
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
|
-
|
|
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'
|
|
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
|
-
|
|
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
|
|
6361
|
-
page
|
|
6362
|
-
has_more = page.size >
|
|
6363
|
-
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
|
|
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|
|