openclacky 1.3.5 → 1.3.7
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/CHANGELOG.md +42 -0
- data/lib/clacky/agent/message_compressor.rb +32 -8
- data/lib/clacky/agent/message_compressor_helper.rb +113 -12
- data/lib/clacky/agent.rb +8 -4
- data/lib/clacky/agent_profile.rb +121 -53
- data/lib/clacky/brand_config.rb +309 -6
- data/lib/clacky/cli.rb +4 -58
- data/lib/clacky/default_extensions/coding/ext.yml +18 -0
- data/lib/clacky/default_extensions/ext-studio/agents/ext-developer/system_prompt.md +95 -0
- data/lib/clacky/default_extensions/ext-studio/api/handler.rb +254 -0
- data/lib/clacky/default_extensions/ext-studio/ext.yml +32 -0
- data/lib/clacky/default_extensions/ext-studio/panels/studio/view.js +1269 -0
- data/lib/clacky/default_extensions/ext-studio/skills/ext-debug/SKILL.md +71 -0
- data/lib/clacky/default_extensions/ext-studio/skills/ext-publish/SKILL.md +74 -0
- data/lib/clacky/default_extensions/ext-studio/skills/ext-scaffold/SKILL.md +65 -0
- data/lib/clacky/default_extensions/general/ext.yml +18 -0
- data/lib/clacky/default_extensions/git/ext.yml +12 -0
- data/lib/clacky/{default_agents/_panels/git/panel.js → default_extensions/git/panels/git/view.js} +1 -2
- data/lib/clacky/default_extensions/meeting/{handler.rb → api/handler.rb} +78 -26
- data/lib/clacky/default_extensions/meeting/ext.yml +15 -0
- data/lib/clacky/default_extensions/meeting/{meeting.js → panels/meeting/view.js} +266 -89
- data/lib/clacky/default_extensions/meeting/skills/meeting-summarizer/SKILL.md +2 -1
- data/lib/clacky/default_extensions/time_machine/ext.yml +12 -0
- data/lib/clacky/{default_agents/_panels/time_machine/panel.js → default_extensions/time_machine/panels/time_machine/view.js} +58 -47
- data/lib/clacky/default_skills/channel-manager/discord_setup.rb +1 -1
- data/lib/clacky/default_skills/extend-openclacky/SKILL.md +83 -18
- data/lib/clacky/{api_extension.rb → extension/api_extension.rb} +27 -11
- data/lib/clacky/extension/api_loader.rb +136 -0
- data/lib/clacky/extension/cli_commands.rb +226 -0
- data/lib/clacky/{server/api_extension_dispatcher.rb → extension/dispatcher.rb} +22 -9
- data/lib/clacky/extension/hook_loader.rb +77 -0
- data/lib/clacky/extension/loader.rb +483 -0
- data/lib/clacky/extension/packager.rb +226 -0
- data/lib/clacky/{patch_loader.rb → extension/patch_loader.rb} +45 -0
- data/lib/clacky/extension/scaffold/template_renderer.rb +64 -0
- data/lib/clacky/extension/scaffold/templates/full/README.md.erb +48 -0
- data/lib/clacky/extension/scaffold/templates/full/agents/designer.md +5 -0
- data/lib/clacky/extension/scaffold/templates/full/api/handler.rb.erb +16 -0
- data/lib/clacky/extension/scaffold/templates/full/channels/noop.rb.erb +40 -0
- data/lib/clacky/extension/scaffold/templates/full/ext.yml.erb +41 -0
- data/lib/clacky/extension/scaffold/templates/full/hooks/audit.rb +9 -0
- data/lib/clacky/extension/scaffold/templates/full/panels/dashboard/view.js.erb +194 -0
- data/lib/clacky/extension/scaffold/templates/full/patches/audit.rb +15 -0
- data/lib/clacky/extension/scaffold/templates/full/skills/__slug__-skill/SKILL.md.erb +8 -0
- data/lib/clacky/extension/scaffold/templates/hello/api/handler.rb.erb +9 -0
- data/lib/clacky/extension/scaffold/templates/hello/ext.yml.erb +12 -0
- data/lib/clacky/extension/scaffold/templates/hello/panels/hello/view.js.erb +31 -0
- data/lib/clacky/extension/scaffold.rb +55 -0
- data/lib/clacky/extension/verifier.rb +196 -0
- data/lib/clacky/identity.rb +70 -0
- data/lib/clacky/locales/en.rb +19 -1
- data/lib/clacky/locales/zh.rb +19 -1
- data/lib/clacky/platform_http_client.rb +18 -15
- data/lib/clacky/server/channel/extension_adapter_loader.rb +32 -0
- data/lib/clacky/server/channel.rb +6 -0
- data/lib/clacky/server/http_server.rb +435 -263
- data/lib/clacky/server/session_registry.rb +59 -3
- data/lib/clacky/session_manager.rb +35 -4
- data/lib/clacky/skill_loader.rb +28 -1
- data/lib/clacky/ui2/layout_manager.rb +0 -5
- data/lib/clacky/ui2/progress_handle.rb +0 -3
- data/lib/clacky/ui2/ui_controller.rb +9 -15
- data/lib/clacky/version.rb +1 -1
- data/lib/clacky/web/app.css +782 -554
- data/lib/clacky/web/app.js +63 -15
- data/lib/clacky/web/auth.js +2 -0
- data/lib/clacky/web/components/notify.js +2 -0
- data/lib/clacky/web/components/sidebar.js +2 -8
- data/lib/clacky/web/core/ext.js +316 -79
- data/lib/clacky/web/features/backup/store.js +24 -0
- data/lib/clacky/web/features/backup/view.js +49 -23
- data/lib/clacky/web/features/brand/view.js +13 -5
- data/lib/clacky/web/features/extensions/store.js +184 -0
- data/lib/clacky/web/features/extensions/view.js +389 -0
- data/lib/clacky/web/features/new-session/store.js +186 -0
- data/lib/clacky/web/features/new-session/view.js +319 -0
- data/lib/clacky/web/features/share/view.js +63 -14
- data/lib/clacky/web/features/skills/store.js +3 -1
- data/lib/clacky/web/features/skills/view.js +1 -1
- data/lib/clacky/web/features/tasks/view.js +88 -27
- data/lib/clacky/web/features/trash/store.js +24 -6
- data/lib/clacky/web/features/trash/view.js +46 -9
- data/lib/clacky/web/features/workspace/store.js +2 -0
- data/lib/clacky/web/features/workspace/view.js +0 -1
- data/lib/clacky/web/i18n.js +140 -108
- data/lib/clacky/web/index.html +190 -214
- data/lib/clacky/web/sessions.js +99 -304
- data/lib/clacky/web/settings.js +1 -1
- data/lib/clacky/web/skills.js +2 -0
- data/lib/clacky/web/theme.js +2 -0
- data/lib/clacky/web/ws-dispatcher.js +2 -1
- data/lib/clacky/web/ws.js +2 -0
- data/lib/clacky.rb +21 -10
- metadata +55 -24
- data/benchmark/runner.rb +0 -441
- data/lib/clacky/api_extension_loader.rb +0 -168
- data/lib/clacky/default_agents/SOUL.md +0 -3
- data/lib/clacky/default_agents/USER.md +0 -1
- data/lib/clacky/default_agents/coding/profile.yml +0 -5
- data/lib/clacky/default_agents/coding/webui/.gitkeep +0 -0
- data/lib/clacky/default_agents/general/profile.yml +0 -2
- data/lib/clacky/default_extensions/meeting/meta.yml +0 -3
- data/lib/clacky/web/features/creator/store.js +0 -81
- data/lib/clacky/web/features/creator/view.js +0 -380
- /data/lib/clacky/{default_agents → default_extensions/coding/agents}/coding/system_prompt.md +0 -0
- /data/lib/clacky/{default_agents → default_extensions/general/agents}/general/system_prompt.md +0 -0
- /data/lib/clacky/{default_agents/base_prompt.md → prompts/base.md} +0 -0
|
@@ -15,6 +15,7 @@ require "securerandom"
|
|
|
15
15
|
require "timeout"
|
|
16
16
|
require "yaml"
|
|
17
17
|
require "date"
|
|
18
|
+
require "open3"
|
|
18
19
|
require_relative "session_registry"
|
|
19
20
|
require_relative "git_panel"
|
|
20
21
|
require_relative "web_ui_controller"
|
|
@@ -366,14 +367,10 @@ module Clacky
|
|
|
366
367
|
res["Cache-Control"] = "no-store"
|
|
367
368
|
res["Pragma"] = "no-cache"
|
|
368
369
|
res.body = html
|
|
369
|
-
elsif req.path.start_with?("/webui_ext/")
|
|
370
|
-
self.send(:serve_webui_ext, req, res)
|
|
371
|
-
elsif req.path.start_with?("/builtin_ext/")
|
|
372
|
-
self.send(:serve_builtin_ext, req, res)
|
|
373
370
|
elsif req.path.start_with?("/agent_ui/")
|
|
374
371
|
self.send(:serve_agent_ui, req, res)
|
|
375
|
-
elsif req.path.start_with?("/
|
|
376
|
-
self.send(:
|
|
372
|
+
elsif req.path.start_with?("/ext_ui/")
|
|
373
|
+
self.send(:serve_ext_ui, req, res)
|
|
377
374
|
else
|
|
378
375
|
file_handler.service(req, res)
|
|
379
376
|
res["Cache-Control"] = "no-store"
|
|
@@ -384,12 +381,18 @@ module Clacky
|
|
|
384
381
|
# Auto-create a default session on startup
|
|
385
382
|
create_default_session
|
|
386
383
|
|
|
387
|
-
# Load
|
|
388
|
-
#
|
|
389
|
-
#
|
|
390
|
-
#
|
|
384
|
+
# Load api backends contributed by ext.yml containers into the shared
|
|
385
|
+
# ApiExtension registry. Each unit mounts at /api/ext/<ext_id>/. Done
|
|
386
|
+
# here (not at gem load) so handlers can resolve session_manager and
|
|
387
|
+
# other host helpers as soon as they are wired up.
|
|
391
388
|
Clacky::ApiExtensionLoader.load_all
|
|
392
389
|
|
|
390
|
+
# Static verification of every ext.yml container: unknown keys, bad
|
|
391
|
+
# scopes, dangling agent→panel references, layer-shadow warnings.
|
|
392
|
+
# Errors from the loader itself are already surfaced elsewhere; this
|
|
393
|
+
# pass covers the whole-program checks an author needs to see at boot.
|
|
394
|
+
report_extension_issues
|
|
395
|
+
|
|
393
396
|
# Start the background scheduler
|
|
394
397
|
@scheduler.start
|
|
395
398
|
puts " Scheduler: #{@scheduler.schedules.size} task(s) loaded"
|
|
@@ -468,8 +471,8 @@ module Clacky
|
|
|
468
471
|
30
|
|
469
472
|
elsif path == "/api/media/video/understand"
|
|
470
473
|
60
|
|
471
|
-
elsif path.start_with?("/api/backup/download") || path == "/api/backup/run"
|
|
472
|
-
# Building a tar.gz of ~/.clacky
|
|
474
|
+
elsif path.start_with?("/api/backup/download") || path == "/api/backup/run" || path == "/api/backup/restore"
|
|
475
|
+
# Building/extracting a tar.gz of ~/.clacky can take a while.
|
|
473
476
|
120
|
|
474
477
|
else
|
|
475
478
|
10
|
|
@@ -489,9 +492,9 @@ module Clacky
|
|
|
489
492
|
path = req.path
|
|
490
493
|
method = req.request_method
|
|
491
494
|
|
|
492
|
-
#
|
|
493
|
-
#
|
|
494
|
-
#
|
|
495
|
+
# HTTP API extensions declared in an ext.yml container mount at
|
|
496
|
+
# /api/ext/<ext_id>/... Routed through a separate dispatcher so the
|
|
497
|
+
# host's giant case table stays focused on built-in endpoints.
|
|
495
498
|
if path.start_with?(Clacky::Server::ApiExtensionDispatcher::MOUNT_PREFIX)
|
|
496
499
|
return if Clacky::Server::ApiExtensionDispatcher.handle(req, res, http_server: self)
|
|
497
500
|
end
|
|
@@ -502,6 +505,7 @@ module Clacky
|
|
|
502
505
|
when ["GET", "/api/cron-tasks"] then api_list_cron_tasks(res)
|
|
503
506
|
when ["POST", "/api/cron-tasks"] then api_create_cron_task(req, res)
|
|
504
507
|
when ["GET", "/api/skills"] then api_list_skills(res)
|
|
508
|
+
when ["GET", "/api/agents"] then api_list_agents(res)
|
|
505
509
|
when ["GET", "/api/config"] then api_get_config(req, res)
|
|
506
510
|
when ["GET", "/api/config/settings"] then api_get_settings(res)
|
|
507
511
|
when ["GET", "/api/exchange-rate"] then api_exchange_rate(req, res)
|
|
@@ -525,11 +529,18 @@ module Clacky
|
|
|
525
529
|
when ["GET", "/api/backup/status"] then api_backup_status(res)
|
|
526
530
|
when ["POST", "/api/backup/run"] then api_backup_run(res)
|
|
527
531
|
when ["GET", "/api/backup/download"] then api_backup_download(res)
|
|
532
|
+
when ["POST", "/api/backup/restore"] then api_backup_restore(req, res)
|
|
533
|
+
when ["POST", "/api/backup/open-folder"] then api_backup_open_folder(res)
|
|
528
534
|
when ["PATCH", "/api/backup/config"] then api_backup_config(req, res)
|
|
529
535
|
when ["POST", "/api/telemetry"] then api_telemetry(req, res)
|
|
530
536
|
when ["POST", "/api/onboard/complete"] then api_onboard_complete(req, res)
|
|
531
537
|
when ["POST", "/api/onboard/skip-soul"] then api_onboard_skip_soul(req, res)
|
|
532
538
|
when ["GET", "/api/store/skills"] then api_store_skills(res)
|
|
539
|
+
when ["GET", "/api/store/extensions"] then api_store_extensions(req, res)
|
|
540
|
+
when ["GET", "/api/store/extension"] then api_store_extension_detail(req, res)
|
|
541
|
+
when ["POST", "/api/store/extension/disable"] then api_store_extension_disable(req, res)
|
|
542
|
+
when ["POST", "/api/store/extension/enable"] then api_store_extension_enable(req, res)
|
|
543
|
+
when ["DELETE", "/api/store/extension"] then api_store_extension_uninstall(req, res)
|
|
533
544
|
when ["GET", "/api/brand/status"] then api_brand_status(res)
|
|
534
545
|
when ["POST", "/api/brand/activate"] then api_brand_activate(req, res)
|
|
535
546
|
when ["DELETE", "/api/brand/license"] then api_brand_deactivate(res)
|
|
@@ -736,30 +747,28 @@ module Clacky
|
|
|
736
747
|
# ── REST API ──────────────────────────────────────────────────────────────
|
|
737
748
|
|
|
738
749
|
def api_list_sessions(req, res)
|
|
739
|
-
query
|
|
740
|
-
limit
|
|
741
|
-
before
|
|
742
|
-
q
|
|
743
|
-
q_scope
|
|
744
|
-
date
|
|
745
|
-
type
|
|
750
|
+
query = URI.decode_www_form(req.query_string.to_s).to_h
|
|
751
|
+
limit = [query["limit"].to_i.then { |n| n > 0 ? n : 20 }, 50].min
|
|
752
|
+
before = query["before"].to_s.strip.then { |v| v.empty? ? nil : v }
|
|
753
|
+
q = query["q"].to_s.strip.then { |v| v.empty? ? nil : v }
|
|
754
|
+
q_scope = query["q_scope"].to_s.strip.then { |v| %w[name content].include?(v) ? v : "name" }
|
|
755
|
+
date = query["date"].to_s.strip.then { |v| v.empty? ? nil : v }
|
|
756
|
+
type = query["type"].to_s.strip.then { |v| v.empty? ? nil : v }
|
|
757
|
+
exclude_type = query["exclude_type"].to_s.strip.then { |v| v.empty? ? nil : v }
|
|
746
758
|
# Backward-compat: ?source=<x> and ?profile=coding → type
|
|
747
759
|
type ||= query["profile"].to_s.strip.then { |v| v.empty? ? nil : v }
|
|
748
760
|
type ||= query["source"].to_s.strip.then { |v| v.empty? ? nil : v }
|
|
749
761
|
|
|
750
|
-
|
|
751
|
-
# `registry.list` always returns ALL matching pinned rows first (on the
|
|
752
|
-
# first page; `before` == nil), followed by non-pinned rows up to `limit+1`.
|
|
753
|
-
# So has_more is determined by whether the non-pinned section overflowed.
|
|
754
|
-
sessions = @registry.list(limit: limit + 1, before: before, q: q, q_scope: q_scope, date: date, type: type)
|
|
762
|
+
sessions = @registry.list(limit: limit + 1, before: before, q: q, q_scope: q_scope, date: date, type: type, exclude_type: exclude_type)
|
|
755
763
|
|
|
756
|
-
# Split pinned vs non-pinned to apply has_more only to the non-pinned tail.
|
|
757
764
|
pinned_part, non_pinned_part = sessions.partition { |s| s[:pinned] }
|
|
758
765
|
has_more = non_pinned_part.size > limit
|
|
759
766
|
non_pinned_part = non_pinned_part.first(limit)
|
|
760
767
|
sessions = pinned_part + non_pinned_part
|
|
761
768
|
|
|
762
|
-
|
|
769
|
+
stats = @registry.cron_stats
|
|
770
|
+
json_response(res, 200, { sessions: sessions, has_more: has_more,
|
|
771
|
+
cron_count: stats[:count], latest_cron_updated_at: stats[:latest_updated_at] })
|
|
763
772
|
end
|
|
764
773
|
|
|
765
774
|
# GET /api/sessions/:id — fetch a single session by id (memory + disk merged).
|
|
@@ -996,7 +1005,13 @@ module Clacky
|
|
|
996
1005
|
api_key: data["api_key"],
|
|
997
1006
|
base_url: data["base_url"],
|
|
998
1007
|
model: data["default_model"]
|
|
999
|
-
)
|
|
1008
|
+
) if data["api_key"]
|
|
1009
|
+
if data["device_token"]
|
|
1010
|
+
Clacky::Identity.load.bind!(
|
|
1011
|
+
device_token: data["device_token"],
|
|
1012
|
+
user_id: data["user_id"]
|
|
1013
|
+
)
|
|
1014
|
+
end
|
|
1000
1015
|
json_response(res, 200, {
|
|
1001
1016
|
ok: true,
|
|
1002
1017
|
status: "approved",
|
|
@@ -1038,28 +1053,41 @@ module Clacky
|
|
|
1038
1053
|
@agent_config.save
|
|
1039
1054
|
end
|
|
1040
1055
|
|
|
1041
|
-
# Absolute path to the user's WebUI extension directory.
|
|
1042
|
-
WEBUI_EXT_ROOT = File.expand_path("~/.clacky/webui_ext")
|
|
1043
|
-
|
|
1044
|
-
# Built-in extensions bundled with the gem (default_extensions/*/**.js).
|
|
1045
|
-
BUILTIN_EXT_ROOT = File.expand_path("../default_extensions", __dir__)
|
|
1046
|
-
|
|
1047
1056
|
# Build the full <script> payload injected at {{EXT_SCRIPTS}}:
|
|
1048
|
-
# 1.
|
|
1049
|
-
# 2. agent-scoped UI — agents/<name>/webui/**/*.js
|
|
1050
|
-
# 3.
|
|
1051
|
-
#
|
|
1052
|
-
# agent profiles reference each official panel. Never raises.
|
|
1057
|
+
# 1. panel→agents registry (which agent profiles reference each panel)
|
|
1058
|
+
# 2. agent-scoped UI — user ~/.clacky/agents/<name>/webui/**/*.js (data-agent)
|
|
1059
|
+
# 3. ext.yml containers — panel view.js served from /ext_ui/
|
|
1060
|
+
# Never raises.
|
|
1053
1061
|
private def webui_ext_script_tags
|
|
1054
1062
|
[
|
|
1055
1063
|
panel_agents_script,
|
|
1056
|
-
builtin_ext_script_tags,
|
|
1057
|
-
global_ext_script_tags,
|
|
1058
1064
|
agent_webui_script_tags,
|
|
1059
|
-
|
|
1065
|
+
container_ext_script_tags,
|
|
1060
1066
|
].reject(&:empty?).join("\n")
|
|
1061
1067
|
end
|
|
1062
1068
|
|
|
1069
|
+
# Container extensions (ext.yml): inject each declared panel's view.js.
|
|
1070
|
+
# Visibility is entirely driven by `agent.panels: [id]` references in
|
|
1071
|
+
# profile.yml / ext.yml agents (see `panel_agents_map`). A panel that no
|
|
1072
|
+
# agent references stays hidden — panels never mount themselves.
|
|
1073
|
+
# Served from /ext_ui/<ext_id>/<rel> with no-store so edits go live on
|
|
1074
|
+
# page refresh. Never raises.
|
|
1075
|
+
private def container_ext_script_tags
|
|
1076
|
+
result = Clacky::ExtensionLoader.load_all
|
|
1077
|
+
result.panels.map do |unit|
|
|
1078
|
+
rel = unit.spec["view"]
|
|
1079
|
+
ext_id = "ext/#{unit.ext_id}/#{unit.id}"
|
|
1080
|
+
panel_id = "#{unit.ext_id}/#{unit.id}"
|
|
1081
|
+
src = "/ext_ui/#{unit.ext_id}/#{rel}"
|
|
1082
|
+
"<script>Clacky.ext._extBegin(#{ext_id.to_json}, #{nil.to_json}, #{panel_id.to_json})</script>" \
|
|
1083
|
+
"<script src=#{src.to_json} data-ext-id=#{ext_id.to_json}></script>" \
|
|
1084
|
+
"<script>Clacky.ext._extEnd()</script>"
|
|
1085
|
+
end.join("\n")
|
|
1086
|
+
rescue StandardError => e
|
|
1087
|
+
Clacky::Logger.warn("[ExtensionLoader] panel injection failed: #{e.message}")
|
|
1088
|
+
""
|
|
1089
|
+
end
|
|
1090
|
+
|
|
1063
1091
|
# Inline script registering the panel→agents map (which agent profiles
|
|
1064
1092
|
# reference each official panel, from their profile.yml `panels:`).
|
|
1065
1093
|
private def panel_agents_script
|
|
@@ -1069,62 +1097,86 @@ module Clacky
|
|
|
1069
1097
|
"<script>Clacky.ext.registerPanelAgents(#{map.to_json})</script>"
|
|
1070
1098
|
end
|
|
1071
1099
|
|
|
1072
|
-
#
|
|
1073
|
-
# `panels
|
|
1100
|
+
# panel id => list of agent names allowed to see it. Two sources merge:
|
|
1101
|
+
# 1. `agent.panels: [id]` — agent-side explicit reference (opt-in mount)
|
|
1102
|
+
# 2. `panel.attach: [...]` — panel-author's default suggestion
|
|
1103
|
+
# - `attach: [foo]` → visible to agent `foo`
|
|
1104
|
+
# - `attach: ["*"]` → visible to every known agent
|
|
1105
|
+
# - omitted → hidden unless (1) references it
|
|
1106
|
+
# A panel with no source stays hidden.
|
|
1074
1107
|
private def panel_agents_map
|
|
1075
1108
|
result = Hash.new { |h, k| h[k] = [] }
|
|
1109
|
+
add = ->(pid, agent) { result[pid] << agent unless result[pid].include?(agent) }
|
|
1110
|
+
|
|
1111
|
+
agent_data = agent_profile_data
|
|
1112
|
+
agent_data.each do |name, data|
|
|
1113
|
+
ext_id = data["_ext_id"]
|
|
1114
|
+
Array(data["panels"]).each do |panel|
|
|
1115
|
+
pid = panel.to_s
|
|
1116
|
+
full = pid.include?("/") ? pid : (ext_id ? "#{ext_id}/#{pid}" : pid)
|
|
1117
|
+
add.call(full, name)
|
|
1118
|
+
end
|
|
1119
|
+
end
|
|
1120
|
+
|
|
1121
|
+
all_agent_ids = agent_data.keys
|
|
1122
|
+
Array(Clacky::ExtensionLoader.last_result&.panels).each do |unit|
|
|
1123
|
+
full_id = "#{unit.ext_id}/#{unit.id}"
|
|
1124
|
+
attach = Array(unit.spec["attach"])
|
|
1125
|
+
next if attach.empty?
|
|
1126
|
+
if attach.include?("*")
|
|
1127
|
+
all_agent_ids.each { |a| add.call(full_id, a) }
|
|
1128
|
+
else
|
|
1129
|
+
attach.each { |a| add.call(full_id, a) if all_agent_ids.include?(a) }
|
|
1130
|
+
end
|
|
1131
|
+
end
|
|
1132
|
+
result
|
|
1133
|
+
end
|
|
1134
|
+
|
|
1135
|
+
# { agent_name => parsed_profile_data } merged across built-in, user, and
|
|
1136
|
+
# ext-contributed agents. Override order: ext < user (ext provides defaults
|
|
1137
|
+
# for ids not in user dirs; user dirs win on collision).
|
|
1138
|
+
private def agent_profile_data
|
|
1139
|
+
data = {}
|
|
1140
|
+
Array(Clacky::ExtensionLoader.last_result&.agents).each do |unit|
|
|
1141
|
+
data[unit.id] = {
|
|
1142
|
+
"name" => unit.id,
|
|
1143
|
+
"description" => unit.spec["description"],
|
|
1144
|
+
"panels" => unit.spec["panels"],
|
|
1145
|
+
"skills" => unit.spec["skills"],
|
|
1146
|
+
"_ext_id" => unit.ext_id,
|
|
1147
|
+
}
|
|
1148
|
+
end
|
|
1076
1149
|
agent_profile_dirs.each do |name, dir|
|
|
1077
1150
|
yml = File.join(dir, "profile.yml")
|
|
1078
1151
|
next unless File.file?(yml)
|
|
1079
1152
|
|
|
1080
|
-
|
|
1153
|
+
parsed = begin
|
|
1081
1154
|
YAML.safe_load(File.read(yml)) || {}
|
|
1082
1155
|
rescue StandardError
|
|
1083
1156
|
{}
|
|
1084
1157
|
end
|
|
1085
|
-
|
|
1158
|
+
data[name] = parsed
|
|
1086
1159
|
end
|
|
1087
|
-
|
|
1160
|
+
data
|
|
1088
1161
|
end
|
|
1089
1162
|
|
|
1090
|
-
# { agent_name => resolved_dir }
|
|
1091
|
-
#
|
|
1163
|
+
# { agent_name => resolved_dir } for user-side agent overrides only.
|
|
1164
|
+
# Extension-provided agents are served through the extension pipeline.
|
|
1092
1165
|
private def agent_profile_dirs
|
|
1093
1166
|
dirs = {}
|
|
1094
|
-
|
|
1095
|
-
|
|
1167
|
+
root = Clacky::AgentProfile::USER_AGENTS_DIR
|
|
1168
|
+
return dirs unless Dir.exist?(root)
|
|
1096
1169
|
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1170
|
+
Dir.children(root).each do |name|
|
|
1171
|
+
next if name.start_with?("_")
|
|
1172
|
+
full = File.join(root, name)
|
|
1173
|
+
next unless File.directory?(full) && File.file?(File.join(full, "profile.yml"))
|
|
1101
1174
|
|
|
1102
|
-
|
|
1103
|
-
end
|
|
1175
|
+
dirs[name] = full
|
|
1104
1176
|
end
|
|
1105
1177
|
dirs
|
|
1106
1178
|
end
|
|
1107
1179
|
|
|
1108
|
-
# Built-in extensions from default_extensions/*/*.js — loaded before user
|
|
1109
|
-
# extensions so user ~/.clacky/webui_ext/ can override by ext id.
|
|
1110
|
-
private def builtin_ext_script_tags
|
|
1111
|
-
return "" unless Dir.exist?(BUILTIN_EXT_ROOT)
|
|
1112
|
-
|
|
1113
|
-
Dir.glob(File.join(BUILTIN_EXT_ROOT, "*", "**", "*.js")).sort.map do |abs|
|
|
1114
|
-
rel = abs.delete_prefix(BUILTIN_EXT_ROOT + "/")
|
|
1115
|
-
ext_id = "builtin/" + rel.delete_suffix(".js")
|
|
1116
|
-
src = "/builtin_ext/#{rel}"
|
|
1117
|
-
"<script>Clacky.ext._extBegin(#{ext_id.to_json}, #{nil.to_json}, #{nil.to_json})</script>" \
|
|
1118
|
-
"<script src=#{src.to_json} data-ext-id=#{ext_id.to_json}></script>" \
|
|
1119
|
-
"<script>Clacky.ext._extEnd()</script>"
|
|
1120
|
-
end.join("\n")
|
|
1121
|
-
end
|
|
1122
|
-
|
|
1123
|
-
# Global extensions — visible for all agents (unchanged legacy behavior).
|
|
1124
|
-
private def global_ext_script_tags
|
|
1125
|
-
ext_script_block(WEBUI_EXT_ROOT, "/webui_ext")
|
|
1126
|
-
end
|
|
1127
|
-
|
|
1128
1180
|
# Agent-scoped UI: agents/<name>/webui/**/*.js. Each script is tagged with
|
|
1129
1181
|
# its owning agent so the client only mounts it for that profile.
|
|
1130
1182
|
private def agent_webui_script_tags
|
|
@@ -1136,21 +1188,6 @@ module Clacky
|
|
|
1136
1188
|
end.reject(&:empty?).join("\n")
|
|
1137
1189
|
end
|
|
1138
1190
|
|
|
1139
|
-
# Official panels: default_agents/_panels/<id>/**/*.js. Scope comes from
|
|
1140
|
-
# the panel→agents map (registerPanelAgents), so a panel only mounts for
|
|
1141
|
-
# agents whose profile.yml references it.
|
|
1142
|
-
private def official_panel_script_tags
|
|
1143
|
-
root = File.join(Clacky::AgentProfile::DEFAULT_AGENTS_DIR, "_panels")
|
|
1144
|
-
return "" unless Dir.exist?(root)
|
|
1145
|
-
|
|
1146
|
-
Dir.children(root).sort.filter_map do |panel|
|
|
1147
|
-
dir = File.join(root, panel)
|
|
1148
|
-
next unless File.directory?(dir)
|
|
1149
|
-
|
|
1150
|
-
ext_script_block(dir, "/panel_ui/#{panel}", id_prefix: "panel/#{panel}/", panel: panel)
|
|
1151
|
-
end.reject(&:empty?).join("\n")
|
|
1152
|
-
end
|
|
1153
|
-
|
|
1154
1191
|
# Emit begin/script/end triples for every *.js under `root`, served from
|
|
1155
1192
|
# `url_base`. `agents`/`panel` carry agent-scoping to _extBegin so the
|
|
1156
1193
|
# client can decide visibility. Returns "" when the dir is absent.
|
|
@@ -1172,39 +1209,49 @@ module Clacky
|
|
|
1172
1209
|
end.join("\n")
|
|
1173
1210
|
end
|
|
1174
1211
|
|
|
1175
|
-
#
|
|
1176
|
-
#
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1212
|
+
# Whole-program static checks over ext.yml — unknown keys, bad scopes,
|
|
1213
|
+
# dangling agent→panel refs, layer-shadow overrides. Uses the already-
|
|
1214
|
+
# loaded ExtensionLoader result so this is essentially free at boot.
|
|
1215
|
+
private def report_extension_issues
|
|
1216
|
+
result = Clacky::ExtensionLoader.load_all
|
|
1217
|
+
issues = Clacky::ExtensionVerifier.verify(result)
|
|
1218
|
+
return if issues.empty?
|
|
1219
|
+
|
|
1220
|
+
errors = issues.count { |i| i.level == :error }
|
|
1221
|
+
warns = issues.size - errors
|
|
1222
|
+
Clacky::Logger.info("[ExtensionVerifier] #{errors} error(s), #{warns} warning(s)")
|
|
1223
|
+
|
|
1224
|
+
issues.each do |issue|
|
|
1225
|
+
location = issue.file ? " [#{issue.file}]" : ""
|
|
1226
|
+
unit = issue.unit ? " #{issue.unit}" : ""
|
|
1227
|
+
message = "[ExtensionVerifier] #{issue.ext}#{unit} (#{issue.code}) — #{issue.message}#{location}"
|
|
1228
|
+
if issue.level == :error
|
|
1229
|
+
Clacky::Logger.error(message)
|
|
1230
|
+
else
|
|
1231
|
+
Clacky::Logger.warn(message)
|
|
1232
|
+
end
|
|
1233
|
+
end
|
|
1234
|
+
rescue StandardError => e
|
|
1235
|
+
Clacky::Logger.warn("[ExtensionVerifier] verify failed: #{e.message}")
|
|
1236
|
+
end
|
|
1237
|
+
|
|
1238
|
+
# Serve a file from a resolved extension container: /ext_ui/<ext_id>/<rel>.
|
|
1239
|
+
# The container dir is looked up via the loader (it may live in any layer),
|
|
1240
|
+
# then strict path containment prevents escaping it. JS/CSS only, no-store.
|
|
1241
|
+
private def serve_ext_ui(req, res)
|
|
1242
|
+
rel = req.path.delete_prefix("/ext_ui/")
|
|
1243
|
+
ext_id, _, sub = rel.partition("/")
|
|
1180
1244
|
|
|
1181
|
-
|
|
1245
|
+
unit = Clacky::ExtensionLoader.load_all.units.find { |u| u.ext_id == ext_id }
|
|
1246
|
+
unless unit
|
|
1182
1247
|
res.status = 404
|
|
1183
1248
|
res.body = "not found"
|
|
1184
1249
|
return
|
|
1185
1250
|
end
|
|
1186
1251
|
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
unless ctype
|
|
1191
|
-
res.status = 415
|
|
1192
|
-
res.body = "unsupported media type"
|
|
1193
|
-
return
|
|
1194
|
-
end
|
|
1195
|
-
|
|
1196
|
-
res.status = 200
|
|
1197
|
-
res["Content-Type"] = ctype
|
|
1198
|
-
res["Cache-Control"] = "no-store"
|
|
1199
|
-
res["Pragma"] = "no-cache"
|
|
1200
|
-
res.body = File.read(abs)
|
|
1201
|
-
end
|
|
1202
|
-
|
|
1203
|
-
private def serve_builtin_ext(req, res)
|
|
1204
|
-
rel = req.path.delete_prefix("/builtin_ext/")
|
|
1205
|
-
abs = File.expand_path(File.join(BUILTIN_EXT_ROOT, rel))
|
|
1206
|
-
|
|
1207
|
-
unless abs.start_with?(BUILTIN_EXT_ROOT + File::SEPARATOR) && File.file?(abs)
|
|
1252
|
+
root = File.expand_path(unit.dir)
|
|
1253
|
+
abs = File.expand_path(File.join(root, sub))
|
|
1254
|
+
unless abs.start_with?(root + File::SEPARATOR) && File.file?(abs)
|
|
1208
1255
|
res.status = 404
|
|
1209
1256
|
res.body = "not found"
|
|
1210
1257
|
return
|
|
@@ -1236,16 +1283,6 @@ module Clacky
|
|
|
1236
1283
|
serve_static_under(File.join(dir, "webui"), rel, res)
|
|
1237
1284
|
end
|
|
1238
1285
|
|
|
1239
|
-
# Serve official panel assets: /panel_ui/<panel>/<rel>.
|
|
1240
|
-
private def serve_panel_ui(req, res)
|
|
1241
|
-
rest = req.path.delete_prefix("/panel_ui/")
|
|
1242
|
-
panel, _, rel = rest.partition("/")
|
|
1243
|
-
return (res.status = 404; res.body = "not found") if panel.empty? || rel.empty?
|
|
1244
|
-
|
|
1245
|
-
root = File.join(Clacky::AgentProfile::DEFAULT_AGENTS_DIR, "_panels", panel)
|
|
1246
|
-
serve_static_under(root, rel, res)
|
|
1247
|
-
end
|
|
1248
|
-
|
|
1249
1286
|
# Read-only static serve of `rel` under `root`, JS/CSS/HTML only, with
|
|
1250
1287
|
# strict path containment so a crafted rel cannot escape `root`.
|
|
1251
1288
|
private def serve_static_under(root, rel, res)
|
|
@@ -2188,6 +2225,124 @@ module Clacky
|
|
|
2188
2225
|
end
|
|
2189
2226
|
end
|
|
2190
2227
|
|
|
2228
|
+
# GET /api/store/extensions?q=&sort=
|
|
2229
|
+
#
|
|
2230
|
+
# Public extension marketplace catalog. Anonymous (no license needed).
|
|
2231
|
+
# Proxies BrandConfig#search_extensions! which hits the platform's public
|
|
2232
|
+
# /api/v1/extensions endpoint. Extension archives are NOT downloadable here
|
|
2233
|
+
# — they ship inside license-gated brand packages (path B distribution).
|
|
2234
|
+
def api_store_extensions(req, res)
|
|
2235
|
+
brand = Clacky::BrandConfig.load
|
|
2236
|
+
result = brand.search_extensions!(query: req.query["q"], sort: req.query["sort"])
|
|
2237
|
+
|
|
2238
|
+
if result[:success]
|
|
2239
|
+
installed = installed_extension_slugs
|
|
2240
|
+
extensions = Array(result[:extensions]).map do |ext|
|
|
2241
|
+
slug = ext["name"] || ext[:name] || ext["slug"] || ext[:slug]
|
|
2242
|
+
ext.merge("installed" => installed.include?(slug))
|
|
2243
|
+
end
|
|
2244
|
+
json_response(res, 200, { ok: true, extensions: extensions })
|
|
2245
|
+
else
|
|
2246
|
+
json_response(res, 200, {
|
|
2247
|
+
ok: true,
|
|
2248
|
+
extensions: [],
|
|
2249
|
+
warning: result[:error] || "Could not reach the extension store."
|
|
2250
|
+
})
|
|
2251
|
+
end
|
|
2252
|
+
end
|
|
2253
|
+
|
|
2254
|
+
# Slugs of every extension container currently loaded (any layer), used to
|
|
2255
|
+
# flag "installed" on the public marketplace catalog.
|
|
2256
|
+
def installed_extension_slugs
|
|
2257
|
+
result = Clacky::ExtensionLoader.load_all
|
|
2258
|
+
Array(result&.containers).map { |id, _c| id }.to_set
|
|
2259
|
+
rescue StandardError
|
|
2260
|
+
Set.new
|
|
2261
|
+
end
|
|
2262
|
+
|
|
2263
|
+
# GET /api/store/extension?id=<slug-or-id>
|
|
2264
|
+
#
|
|
2265
|
+
# Public detail for a single marketplace extension (contributes + version
|
|
2266
|
+
# history). Anonymous, proxies BrandConfig#extension_detail!.
|
|
2267
|
+
def api_store_extension_detail(req, res)
|
|
2268
|
+
id = req.query["id"].to_s
|
|
2269
|
+
if id.strip.empty?
|
|
2270
|
+
json_response(res, 400, { ok: false, error: "Missing id." })
|
|
2271
|
+
return
|
|
2272
|
+
end
|
|
2273
|
+
|
|
2274
|
+
brand = Clacky::BrandConfig.load
|
|
2275
|
+
result = brand.extension_detail!(id)
|
|
2276
|
+
|
|
2277
|
+
if result[:success] && result[:extension]
|
|
2278
|
+
ext = result[:extension]
|
|
2279
|
+
slug = ext["name"] || ext[:name] || ext["slug"] || ext[:slug]
|
|
2280
|
+
container = extension_container(slug)
|
|
2281
|
+
ext = ext.merge(
|
|
2282
|
+
"installed" => !container.nil?,
|
|
2283
|
+
"removable" => container && container[:layer] == :installed,
|
|
2284
|
+
"disabled" => container ? container[:disabled] == true : false
|
|
2285
|
+
)
|
|
2286
|
+
json_response(res, 200, { ok: true, extension: ext })
|
|
2287
|
+
else
|
|
2288
|
+
json_response(res, 404, { ok: false, error: result[:error] || "Not found" })
|
|
2289
|
+
end
|
|
2290
|
+
end
|
|
2291
|
+
|
|
2292
|
+
# Locate an installed container by slug/id (nil when not installed).
|
|
2293
|
+
def extension_container(slug)
|
|
2294
|
+
return nil if slug.to_s.strip.empty?
|
|
2295
|
+
|
|
2296
|
+
result = Clacky::ExtensionLoader.load_all
|
|
2297
|
+
Array(result&.containers).to_h[slug.to_s]
|
|
2298
|
+
rescue StandardError
|
|
2299
|
+
nil
|
|
2300
|
+
end
|
|
2301
|
+
|
|
2302
|
+
# POST /api/store/extension/disable body: { id: <slug> }
|
|
2303
|
+
def api_store_extension_disable(req, res)
|
|
2304
|
+
toggle_extension(req, res, :disable)
|
|
2305
|
+
end
|
|
2306
|
+
|
|
2307
|
+
# POST /api/store/extension/enable body: { id: <slug> }
|
|
2308
|
+
def api_store_extension_enable(req, res)
|
|
2309
|
+
toggle_extension(req, res, :enable)
|
|
2310
|
+
end
|
|
2311
|
+
|
|
2312
|
+
def toggle_extension(req, res, action)
|
|
2313
|
+
id = parse_json_body(req)["id"].to_s
|
|
2314
|
+
container = extension_container(id)
|
|
2315
|
+
if container.nil?
|
|
2316
|
+
json_response(res, 404, { ok: false, error: "Not installed." })
|
|
2317
|
+
return
|
|
2318
|
+
end
|
|
2319
|
+
|
|
2320
|
+
action == :disable ? Clacky::ExtensionLoader.disable!(id) : Clacky::ExtensionLoader.enable!(id)
|
|
2321
|
+
json_response(res, 200, { ok: true, id: id, disabled: action == :disable })
|
|
2322
|
+
rescue StandardError => e
|
|
2323
|
+
json_response(res, 500, { ok: false, error: e.message })
|
|
2324
|
+
end
|
|
2325
|
+
|
|
2326
|
+
# DELETE /api/store/extension body: { id: <slug> }
|
|
2327
|
+
def api_store_extension_uninstall(req, res)
|
|
2328
|
+
id = parse_json_body(req)["id"].to_s
|
|
2329
|
+
container = extension_container(id)
|
|
2330
|
+
if container.nil?
|
|
2331
|
+
json_response(res, 404, { ok: false, error: "Not installed." })
|
|
2332
|
+
return
|
|
2333
|
+
end
|
|
2334
|
+
unless container[:layer] == :installed
|
|
2335
|
+
json_response(res, 422, { ok: false, error: "Only marketplace-installed extensions can be removed." })
|
|
2336
|
+
return
|
|
2337
|
+
end
|
|
2338
|
+
|
|
2339
|
+
if Clacky::ExtensionLoader.uninstall!(id)
|
|
2340
|
+
json_response(res, 200, { ok: true, id: id })
|
|
2341
|
+
else
|
|
2342
|
+
json_response(res, 404, { ok: false, error: "Not installed." })
|
|
2343
|
+
end
|
|
2344
|
+
end
|
|
2345
|
+
|
|
2191
2346
|
# POST /api/store/skills/:slug/install
|
|
2192
2347
|
def api_brand_skills(res)
|
|
2193
2348
|
brand = Clacky::BrandConfig.load
|
|
@@ -3707,6 +3862,16 @@ module Clacky
|
|
|
3707
3862
|
json_response(res, 200, { skills: skills })
|
|
3708
3863
|
end
|
|
3709
3864
|
|
|
3865
|
+
# GET /api/agents — list all available agent profiles (default + user override + ext).
|
|
3866
|
+
# Each entry carries { id, title, title_zh, description, description_zh,
|
|
3867
|
+
# source, order, layer, author }. Extensions can supply title_zh /
|
|
3868
|
+
# description_zh / author in their ext.yml so the New Session cards read
|
|
3869
|
+
# naturally and credit their author.
|
|
3870
|
+
def api_list_agents(res)
|
|
3871
|
+
agents = Clacky::AgentProfile.all
|
|
3872
|
+
json_response(res, 200, { agents: agents })
|
|
3873
|
+
end
|
|
3874
|
+
|
|
3710
3875
|
# GET /api/sessions/:id/skills — list user-invocable skills for a session,
|
|
3711
3876
|
# filtered by the session's agent profile. Used by the frontend slash-command
|
|
3712
3877
|
# autocomplete so only skills valid for the current profile are suggested.
|
|
@@ -4111,14 +4276,12 @@ module Clacky
|
|
|
4111
4276
|
# Returns two separate groups:
|
|
4112
4277
|
# cloud_skills — published to the platform (with download_count)
|
|
4113
4278
|
# local_skills — local user skills not yet published, or published but with local changes
|
|
4114
|
-
#
|
|
4279
|
+
# Local skills are always returned. Cloud listing needs a user license;
|
|
4280
|
+
# when absent we return licensed:false so the UI can explain that becoming
|
|
4281
|
+
# a creator is required to upload.
|
|
4115
4282
|
private def api_creator_skills(res)
|
|
4116
|
-
brand
|
|
4117
|
-
|
|
4118
|
-
unless brand.user_licensed?
|
|
4119
|
-
json_response(res, 403, { ok: false, error: "User license required" })
|
|
4120
|
-
return
|
|
4121
|
-
end
|
|
4283
|
+
brand = Clacky::BrandConfig.load
|
|
4284
|
+
licensed = brand.user_licensed?
|
|
4122
4285
|
|
|
4123
4286
|
@skill_loader.load_all
|
|
4124
4287
|
upload_meta = Clacky::BrandConfig.load_upload_meta
|
|
@@ -4147,8 +4310,9 @@ module Clacky
|
|
|
4147
4310
|
}
|
|
4148
4311
|
end
|
|
4149
4312
|
|
|
4150
|
-
# Fetch platform skills (may fail — we
|
|
4151
|
-
|
|
4313
|
+
# Fetch platform skills only when licensed (may still fail — we always
|
|
4314
|
+
# return local skills regardless).
|
|
4315
|
+
platform_result = licensed ? brand.fetch_my_skills! : { success: false, skills: [] }
|
|
4152
4316
|
platform_skills = platform_result[:success] ? platform_result[:skills] : []
|
|
4153
4317
|
|
|
4154
4318
|
# cloud_skills: everything that has been published to the platform
|
|
@@ -4184,6 +4348,7 @@ module Clacky
|
|
|
4184
4348
|
|
|
4185
4349
|
json_response(res, 200, {
|
|
4186
4350
|
ok: true,
|
|
4351
|
+
licensed: licensed,
|
|
4187
4352
|
cloud_skills: cloud_skills,
|
|
4188
4353
|
local_skills: local_skills,
|
|
4189
4354
|
platform_fetch_error: platform_result[:success] ? nil : platform_result[:error]
|
|
@@ -4353,7 +4518,8 @@ module Clacky
|
|
|
4353
4518
|
total_tasks: s.dig(:stats, :total_tasks) || 0,
|
|
4354
4519
|
file_size: s[:file_size] || 0,
|
|
4355
4520
|
model: s[:model],
|
|
4356
|
-
working_dir: s[:working_dir]
|
|
4521
|
+
working_dir: s[:working_dir],
|
|
4522
|
+
source: s[:source]
|
|
4357
4523
|
}
|
|
4358
4524
|
end
|
|
4359
4525
|
|
|
@@ -4478,7 +4644,6 @@ module Clacky
|
|
|
4478
4644
|
# endpoints let the Web UI read and edit those files.
|
|
4479
4645
|
|
|
4480
4646
|
PROFILE_USER_AGENTS_DIR = File.expand_path("~/.clacky/agents").freeze
|
|
4481
|
-
PROFILE_DEFAULT_AGENTS_DIR = File.expand_path("../../default_agents", __dir__).freeze
|
|
4482
4647
|
PROFILE_MAX_BYTES = 50_000 # Hard limit; prevents runaway content.
|
|
4483
4648
|
|
|
4484
4649
|
# GET /api/profile
|
|
@@ -4530,31 +4695,30 @@ module Clacky
|
|
|
4530
4695
|
json_response(res, 500, { ok: false, error: e.message })
|
|
4531
4696
|
end
|
|
4532
4697
|
|
|
4533
|
-
# Read a profile file — user override if present, else built-in default.
|
|
4698
|
+
# Read a profile file — user override if present, else built-in default constant.
|
|
4534
4699
|
# Returns { path:, content:, is_default:, writable: }.
|
|
4535
4700
|
private def _profile_read_file(filename)
|
|
4536
|
-
user_path
|
|
4537
|
-
default_path = File.join(PROFILE_DEFAULT_AGENTS_DIR, filename)
|
|
4701
|
+
user_path = File.join(PROFILE_USER_AGENTS_DIR, filename)
|
|
4538
4702
|
|
|
4539
4703
|
if File.exist?(user_path) && !File.zero?(user_path)
|
|
4540
|
-
{
|
|
4704
|
+
return {
|
|
4541
4705
|
path: user_path,
|
|
4542
4706
|
content: File.read(user_path),
|
|
4543
4707
|
is_default: false
|
|
4544
4708
|
}
|
|
4545
|
-
elsif File.exist?(default_path)
|
|
4546
|
-
{
|
|
4547
|
-
path: default_path,
|
|
4548
|
-
content: File.read(default_path),
|
|
4549
|
-
is_default: true
|
|
4550
|
-
}
|
|
4551
|
-
else
|
|
4552
|
-
{
|
|
4553
|
-
path: user_path, # Where it WILL be written
|
|
4554
|
-
content: "",
|
|
4555
|
-
is_default: true
|
|
4556
|
-
}
|
|
4557
4709
|
end
|
|
4710
|
+
|
|
4711
|
+
default_content = case filename
|
|
4712
|
+
when "SOUL.md" then Clacky::AgentProfile::DEFAULT_SOUL
|
|
4713
|
+
when "USER.md" then Clacky::AgentProfile::DEFAULT_USER
|
|
4714
|
+
else ""
|
|
4715
|
+
end
|
|
4716
|
+
|
|
4717
|
+
{
|
|
4718
|
+
path: user_path,
|
|
4719
|
+
content: default_content,
|
|
4720
|
+
is_default: true
|
|
4721
|
+
}
|
|
4558
4722
|
rescue StandardError => e
|
|
4559
4723
|
{ path: "", content: "", is_default: true, error: e.message }
|
|
4560
4724
|
end
|
|
@@ -4876,6 +5040,68 @@ module Clacky
|
|
|
4876
5040
|
})
|
|
4877
5041
|
end
|
|
4878
5042
|
|
|
5043
|
+
# POST /api/backup/restore — accept a tar.gz upload, extract over ~/.clacky, hot-restart
|
|
5044
|
+
def api_backup_restore(req, res)
|
|
5045
|
+
body = req.body.to_s
|
|
5046
|
+
return json_response(res, 400, { error: "Empty body" }) if body.empty?
|
|
5047
|
+
|
|
5048
|
+
clacky_dir = File.expand_path("~/.clacky")
|
|
5049
|
+
stamp = Time.now.strftime("%Y%m%d-%H%M%S")
|
|
5050
|
+
tmp_archive = File.join(Dir.tmpdir, "clacky-restore-#{stamp}.tar.gz")
|
|
5051
|
+
tmp_backup = File.join(Dir.tmpdir, "clacky-pre-restore-#{stamp}")
|
|
5052
|
+
|
|
5053
|
+
File.binwrite(tmp_archive, body)
|
|
5054
|
+
|
|
5055
|
+
FileUtils.cp_r(clacky_dir, tmp_backup)
|
|
5056
|
+
|
|
5057
|
+
result = system("tar", "-xzf", tmp_archive, "-C", clacky_dir)
|
|
5058
|
+
unless result
|
|
5059
|
+
FileUtils.rm_rf(clacky_dir)
|
|
5060
|
+
FileUtils.cp_r(tmp_backup, clacky_dir)
|
|
5061
|
+
return json_response(res, 500, { error: "Failed to extract archive" })
|
|
5062
|
+
end
|
|
5063
|
+
|
|
5064
|
+
json_response(res, 200, { ok: true })
|
|
5065
|
+
|
|
5066
|
+
Thread.new do
|
|
5067
|
+
sleep 0.5
|
|
5068
|
+
if @master_pid
|
|
5069
|
+
begin
|
|
5070
|
+
Process.kill("USR1", @master_pid)
|
|
5071
|
+
rescue Errno::ESRCH
|
|
5072
|
+
standalone_exec_restart
|
|
5073
|
+
end
|
|
5074
|
+
else
|
|
5075
|
+
standalone_exec_restart
|
|
5076
|
+
end
|
|
5077
|
+
end
|
|
5078
|
+
rescue => e
|
|
5079
|
+
json_response(res, 500, { ok: false, error: e.message })
|
|
5080
|
+
ensure
|
|
5081
|
+
FileUtils.rm_f(tmp_archive) if tmp_archive
|
|
5082
|
+
FileUtils.rm_rf(tmp_backup) if tmp_backup && Dir.exist?(tmp_backup)
|
|
5083
|
+
end
|
|
5084
|
+
|
|
5085
|
+
# POST /api/backup/open-folder — open the backup destination in Finder/Explorer
|
|
5086
|
+
def api_backup_open_folder(res)
|
|
5087
|
+
dest = BackupManager.status["dest_dir"]
|
|
5088
|
+
FileUtils.mkdir_p(dest)
|
|
5089
|
+
host_os = RbConfig::CONFIG["host_os"]
|
|
5090
|
+
if host_os =~ /darwin/
|
|
5091
|
+
system("open", dest)
|
|
5092
|
+
elsif host_os =~ /linux/
|
|
5093
|
+
if File.exist?("/proc/version") && File.read("/proc/version").downcase.include?("microsoft")
|
|
5094
|
+
windows_path, = Open3.capture2("wslpath", "-w", dest)
|
|
5095
|
+
system("explorer.exe", windows_path.strip) unless windows_path.strip.empty?
|
|
5096
|
+
else
|
|
5097
|
+
system("xdg-open", dest)
|
|
5098
|
+
end
|
|
5099
|
+
end
|
|
5100
|
+
json_response(res, 200, { ok: true, dest_dir: dest })
|
|
5101
|
+
rescue => e
|
|
5102
|
+
json_response(res, 500, { ok: false, error: e.message })
|
|
5103
|
+
end
|
|
5104
|
+
|
|
4879
5105
|
# Resolve the AgentConfig for a given session, falling back to nil when
|
|
4880
5106
|
# the session isn't live so callers can use the global config instead.
|
|
4881
5107
|
def config_for_session(session_id)
|
|
@@ -5323,7 +5549,7 @@ module Clacky
|
|
|
5323
5549
|
end
|
|
5324
5550
|
|
|
5325
5551
|
# PATCH /api/sessions/:id/reasoning_effort
|
|
5326
|
-
# Body: { "reasoning_effort": "off" | "low" | "medium" | "high" }
|
|
5552
|
+
# Body: { "reasoning_effort": "off" | "low" | "medium" | "high" | "xhigh" }
|
|
5327
5553
|
def api_switch_session_reasoning_effort(session_id, req, res)
|
|
5328
5554
|
body = parse_json_body(req)
|
|
5329
5555
|
raw = body["reasoning_effort"]
|
|
@@ -5722,12 +5948,12 @@ module Clacky
|
|
|
5722
5948
|
interrupt_session(session_id)
|
|
5723
5949
|
|
|
5724
5950
|
when "list_sessions"
|
|
5725
|
-
|
|
5726
|
-
|
|
5727
|
-
page = @registry.list(limit: 21)
|
|
5951
|
+
stats = @registry.cron_stats
|
|
5952
|
+
page = @registry.list(limit: 21, exclude_type: "cron")
|
|
5728
5953
|
has_more = page.size > 20
|
|
5729
5954
|
all_sessions = page.first(20)
|
|
5730
|
-
conn.send_json(type: "session_list", sessions: all_sessions, has_more: has_more,
|
|
5955
|
+
conn.send_json(type: "session_list", sessions: all_sessions, has_more: has_more,
|
|
5956
|
+
cron_count: stats[:count], latest_cron_updated_at: stats[:latest_updated_at])
|
|
5731
5957
|
|
|
5732
5958
|
when "run_task"
|
|
5733
5959
|
# Client sends this after subscribing to guarantee it's ready to receive
|
|
@@ -5822,112 +6048,30 @@ module Clacky
|
|
|
5822
6048
|
|
|
5823
6049
|
# Interrupt a running agent session.
|
|
5824
6050
|
#
|
|
5825
|
-
# Thread#raise
|
|
5826
|
-
#
|
|
5827
|
-
#
|
|
5828
|
-
# that stay "running" forever even after multiple interrupt attempts.
|
|
5829
|
-
#
|
|
5830
|
-
# Strategy: three-tier escalation in a background watchdog Thread so
|
|
5831
|
-
# the HTTP handler returns immediately.
|
|
6051
|
+
# Thread#raise is best-effort: it unblocks most pure-Ruby waits and
|
|
6052
|
+
# Faraday reads, but can't reach a thread stuck in a C-extension syscall
|
|
6053
|
+
# until that syscall returns. We raise once and return immediately.
|
|
5832
6054
|
#
|
|
5833
|
-
#
|
|
5834
|
-
#
|
|
5835
|
-
#
|
|
5836
|
-
#
|
|
5837
|
-
#
|
|
5838
|
-
#
|
|
5839
|
-
# Tier 3 (t=8): Thread#kill — last resort. Leaks any held
|
|
5840
|
-
# resources but frees the session so the user can
|
|
5841
|
-
# move on.
|
|
5842
|
-
#
|
|
5843
|
-
# Each transition is logged so that when users report "stuck
|
|
5844
|
-
# sessions" we can see in the log whether tier 2/3 ever had to
|
|
5845
|
-
# fire — that's our signal to dig deeper on the underlying block.
|
|
6055
|
+
# Correctness of the *takeover* does not depend on the old thread dying
|
|
6056
|
+
# promptly: each new task claims a fresh epoch (see run_agent_task), and
|
|
6057
|
+
# any status write or UI broadcast from a superseded thread is fenced off
|
|
6058
|
+
# by that epoch. A stale thread that lingers in a syscall is harmless — it
|
|
6059
|
+
# self-terminates at the next check_stale! checkpoint, or when the syscall
|
|
6060
|
+
# returns; either way it can no longer touch the live session.
|
|
5846
6061
|
def interrupt_session(session_id)
|
|
5847
|
-
thread = nil
|
|
5848
6062
|
@registry.with_session(session_id) do |s|
|
|
5849
6063
|
s[:idle_timer]&.cancel
|
|
5850
6064
|
thread = s[:thread]
|
|
5851
|
-
|
|
5852
6065
|
next unless thread&.alive?
|
|
5853
6066
|
|
|
5854
|
-
Clacky::Logger.info("[interrupt] session=#{session_id}
|
|
6067
|
+
Clacky::Logger.info("[interrupt] session=#{session_id} raise")
|
|
5855
6068
|
begin
|
|
5856
6069
|
thread.raise(Clacky::AgentInterrupted, "Interrupted by user")
|
|
5857
6070
|
rescue ThreadError => e
|
|
5858
|
-
Clacky::Logger.warn("[interrupt]
|
|
5859
|
-
end
|
|
5860
|
-
end
|
|
5861
|
-
|
|
5862
|
-
return unless thread&.alive?
|
|
5863
|
-
|
|
5864
|
-
start_interrupt_watchdog(session_id, thread)
|
|
5865
|
-
end
|
|
5866
|
-
|
|
5867
|
-
# Background watchdog: escalates from WebSocket force-close (tier 2)
|
|
5868
|
-
# to Thread#kill (tier 3) if the agent thread refuses to die.
|
|
5869
|
-
private def start_interrupt_watchdog(session_id, thread)
|
|
5870
|
-
Thread.new do
|
|
5871
|
-
Thread.current.name = "interrupt-watchdog[#{session_id}]" rescue nil
|
|
5872
|
-
|
|
5873
|
-
# Give the first Thread#raise a few seconds to unwind.
|
|
5874
|
-
sleep 3
|
|
5875
|
-
next unless thread.alive?
|
|
5876
|
-
|
|
5877
|
-
Clacky::Logger.warn(
|
|
5878
|
-
"[interrupt] session=#{session_id} tier=2 raise failed after 3s, " \
|
|
5879
|
-
"force-closing session resources"
|
|
5880
|
-
)
|
|
5881
|
-
force_close_session_sockets(session_id)
|
|
5882
|
-
# Re-raise — sometimes the first raise was swallowed deep in a
|
|
5883
|
-
# C-extension syscall; after we force-close the socket the
|
|
5884
|
-
# syscall returns and the next raise sticks.
|
|
5885
|
-
begin
|
|
5886
|
-
thread.raise(Clacky::AgentInterrupted, "Interrupted by user (escalated)")
|
|
5887
|
-
rescue ThreadError
|
|
5888
|
-
# already dead between checks — fine
|
|
5889
|
-
end
|
|
5890
|
-
|
|
5891
|
-
sleep 5
|
|
5892
|
-
next unless thread.alive?
|
|
5893
|
-
|
|
5894
|
-
Clacky::Logger.error(
|
|
5895
|
-
"[interrupt] session=#{session_id} tier=3 still alive after 8s, Thread#kill"
|
|
5896
|
-
)
|
|
5897
|
-
begin
|
|
5898
|
-
thread.kill
|
|
5899
|
-
rescue StandardError => e
|
|
5900
|
-
Clacky::Logger.error("[interrupt] Thread#kill raised: #{e.class}: #{e.message}")
|
|
5901
|
-
end
|
|
5902
|
-
|
|
5903
|
-
# Record the forced-kill so the UI can show a warning and operators
|
|
5904
|
-
# can correlate with any backtrace dumps. The session is left in
|
|
5905
|
-
# :idle state by run_agent_task's rescue clause; if the kill
|
|
5906
|
-
# happened before the rescue could run, patch the state directly.
|
|
5907
|
-
begin
|
|
5908
|
-
@registry.update(session_id, status: :idle, error: "Force-killed (interrupt watchdog)")
|
|
5909
|
-
broadcast_session_update(session_id)
|
|
5910
|
-
rescue StandardError
|
|
5911
|
-
# best effort
|
|
6071
|
+
Clacky::Logger.warn("[interrupt] raise failed: #{e.message}")
|
|
5912
6072
|
end
|
|
5913
6073
|
end
|
|
5914
|
-
end
|
|
5915
|
-
|
|
5916
|
-
# Close every WebSocket connection bound to the given session. Used by
|
|
5917
|
-
# the interrupt watchdog to unblock agent threads stuck in a WS write.
|
|
5918
|
-
private def force_close_session_sockets(session_id)
|
|
5919
|
-
conns = @ws_mutex.synchronize { (@ws_clients[session_id] || []).dup }
|
|
5920
|
-
conns.each do |conn|
|
|
5921
|
-
Clacky::Logger.warn(
|
|
5922
|
-
"[interrupt] session=#{session_id} force-closing WS conn"
|
|
5923
|
-
)
|
|
5924
|
-
conn.force_close!
|
|
5925
|
-
end
|
|
5926
|
-
rescue StandardError => e
|
|
5927
|
-
Clacky::Logger.error("[interrupt] force_close_session_sockets error: #{e.class}: #{e.message}")
|
|
5928
|
-
end
|
|
5929
|
-
|
|
5930
|
-
# Run a task in a session immediately in the background, without waiting
|
|
6074
|
+
end # Run a task in a session immediately in the background, without waiting
|
|
5931
6075
|
# for the client to subscribe. The user bubble is persisted via
|
|
5932
6076
|
# display_text (Agent#run → history → replay_history), so the frontend
|
|
5933
6077
|
# only needs to navigate over and load history — no realtime broadcast,
|
|
@@ -5937,9 +6081,15 @@ module Clacky
|
|
|
5937
6081
|
return unless @registry.exist?(session_id)
|
|
5938
6082
|
|
|
5939
6083
|
agent = nil
|
|
5940
|
-
|
|
6084
|
+
web_ui = nil
|
|
6085
|
+
@registry.with_session(session_id) do |s|
|
|
6086
|
+
agent = s[:agent]
|
|
6087
|
+
web_ui = s[:ui]
|
|
6088
|
+
end
|
|
5941
6089
|
return unless agent
|
|
5942
6090
|
|
|
6091
|
+
web_ui&.show_user_message(display_message, source: :web) if display_message
|
|
6092
|
+
|
|
5943
6093
|
run_agent_task(session_id, agent) { agent.run(prompt, display_text: display_message) }
|
|
5944
6094
|
end
|
|
5945
6095
|
|
|
@@ -6008,11 +6158,19 @@ module Clacky
|
|
|
6008
6158
|
# Cancel any pending idle compression before starting a new task
|
|
6009
6159
|
idle_timer&.cancel
|
|
6010
6160
|
|
|
6011
|
-
#
|
|
6161
|
+
# Claim a fresh epoch and mark running atomically-ish. The epoch
|
|
6162
|
+
# fences this task against an interrupted-but-not-yet-dead predecessor:
|
|
6163
|
+
# the old thread compares its epoch before writing status or
|
|
6164
|
+
# broadcasting and silently drops anything once superseded. Without
|
|
6165
|
+
# this a slow old thread could overwrite :running back to :idle (or
|
|
6166
|
+
# close the new task's sockets), leaving the UI stuck "running".
|
|
6167
|
+
#
|
|
6168
|
+
# Marked running BEFORE evict_excess_idle! — otherwise this session
|
|
6012
6169
|
# (still :idle here) can be evicted from the registry along with
|
|
6013
6170
|
# other idle agents, breaking subsequent status updates and any
|
|
6014
6171
|
# follow-up handle_user_message (which would early-return on
|
|
6015
6172
|
# @registry.exist? == false).
|
|
6173
|
+
epoch = @registry.claim_epoch(session_id)
|
|
6016
6174
|
@registry.update(session_id, status: :running)
|
|
6017
6175
|
|
|
6018
6176
|
# evict_excess_idle! serializes + writes 1 file per evicted session
|
|
@@ -6028,8 +6186,9 @@ module Clacky
|
|
|
6028
6186
|
locale = Thread.current[:lang]
|
|
6029
6187
|
thread = Thread.new do
|
|
6030
6188
|
Thread.current[:lang] = locale
|
|
6189
|
+
Thread.current[:task_epoch] = epoch
|
|
6031
6190
|
task.call
|
|
6032
|
-
@registry.
|
|
6191
|
+
next unless @registry.update_if_epoch(session_id, epoch, status: :idle, error: nil)
|
|
6033
6192
|
broadcast_session_update(session_id)
|
|
6034
6193
|
# Transient global signal for the optional task-complete sound. Sent to
|
|
6035
6194
|
# all clients (broadcast_all) so a browser viewing another session — or
|
|
@@ -6040,7 +6199,9 @@ module Clacky
|
|
|
6040
6199
|
# Start idle compression timer now that the agent is idle
|
|
6041
6200
|
idle_timer&.start
|
|
6042
6201
|
rescue Clacky::AgentInterrupted
|
|
6043
|
-
|
|
6202
|
+
# A superseding task already owns the session — do not touch status
|
|
6203
|
+
# or push UI events, they belong to the new epoch now.
|
|
6204
|
+
next unless @registry.update_if_epoch(session_id, epoch, status: :idle)
|
|
6044
6205
|
broadcast_session_update(session_id)
|
|
6045
6206
|
broadcast(session_id, { type: "interrupted", session_id: session_id })
|
|
6046
6207
|
@session_manager.save(agent.to_session_data(status: :interrupted))
|
|
@@ -6056,12 +6217,14 @@ module Clacky
|
|
|
6056
6217
|
end
|
|
6057
6218
|
user_message = e.respond_to?(:display_message) && e.display_message ? e.display_message : e.message
|
|
6058
6219
|
raw_message = e.respond_to?(:raw_message) ? e.raw_message : nil
|
|
6059
|
-
@registry.
|
|
6220
|
+
next unless @registry.update_if_epoch(session_id, epoch, status: :error, error: user_message, error_code: code, top_up_url: top_up_url, raw_message: raw_message)
|
|
6060
6221
|
broadcast_session_update(session_id)
|
|
6061
6222
|
web_ui&.show_error(user_message, code: code, top_up_url: top_up_url, raw_message: raw_message)
|
|
6062
6223
|
@session_manager.save(agent.to_session_data(status: :error, error_message: user_message, raw_message: raw_message))
|
|
6063
6224
|
end
|
|
6064
|
-
|
|
6225
|
+
# Register the thread only if we still own the epoch; a faster
|
|
6226
|
+
# superseding task may have already replaced it.
|
|
6227
|
+
@registry.with_session(session_id) { |s| s[:thread] = thread if s[:epoch].to_i == epoch.to_i }
|
|
6065
6228
|
end
|
|
6066
6229
|
|
|
6067
6230
|
# ── WebSocket subscription management ─────────────────────────────────────
|
|
@@ -6091,6 +6254,15 @@ module Clacky
|
|
|
6091
6254
|
# removed automatically. Connections already marked closed are skipped
|
|
6092
6255
|
# upfront so one sluggish client can't delay delivery to healthy ones.
|
|
6093
6256
|
def broadcast(session_id, event)
|
|
6257
|
+
# Drop events emitted by a superseded agent thread. A thread carrying a
|
|
6258
|
+
# :task_epoch only gets through while it still owns the session; an
|
|
6259
|
+
# interrupted-but-unwinding old thread (e.g. a late show_progress
|
|
6260
|
+
# "done" from an ensure block) is silently dropped so it can't disturb
|
|
6261
|
+
# the new task's UI. Threads without :task_epoch (HTTP handlers, the
|
|
6262
|
+
# task supervisor itself) are never affected.
|
|
6263
|
+
my_epoch = Thread.current[:task_epoch]
|
|
6264
|
+
return if my_epoch && @registry.current_epoch(session_id) != my_epoch
|
|
6265
|
+
|
|
6094
6266
|
clients = @ws_mutex.synchronize { (@ws_clients[session_id] || []).dup }
|
|
6095
6267
|
dead = []
|
|
6096
6268
|
clients.each do |conn|
|