openclacky 1.3.6 → 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.
Files changed (103) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +30 -0
  3. data/lib/clacky/agent.rb +8 -1
  4. data/lib/clacky/agent_profile.rb +121 -53
  5. data/lib/clacky/brand_config.rb +309 -6
  6. data/lib/clacky/cli.rb +4 -57
  7. data/lib/clacky/default_extensions/coding/ext.yml +18 -0
  8. data/lib/clacky/default_extensions/ext-studio/agents/ext-developer/system_prompt.md +95 -0
  9. data/lib/clacky/default_extensions/ext-studio/api/handler.rb +254 -0
  10. data/lib/clacky/default_extensions/ext-studio/ext.yml +32 -0
  11. data/lib/clacky/default_extensions/ext-studio/panels/studio/view.js +1269 -0
  12. data/lib/clacky/default_extensions/ext-studio/skills/ext-debug/SKILL.md +71 -0
  13. data/lib/clacky/default_extensions/ext-studio/skills/ext-publish/SKILL.md +74 -0
  14. data/lib/clacky/default_extensions/ext-studio/skills/ext-scaffold/SKILL.md +65 -0
  15. data/lib/clacky/default_extensions/general/ext.yml +18 -0
  16. data/lib/clacky/default_extensions/git/ext.yml +12 -0
  17. data/lib/clacky/{default_agents/_panels/git/panel.js → default_extensions/git/panels/git/view.js} +1 -2
  18. data/lib/clacky/default_extensions/meeting/{handler.rb → api/handler.rb} +78 -26
  19. data/lib/clacky/default_extensions/meeting/ext.yml +15 -0
  20. data/lib/clacky/default_extensions/meeting/{meeting.js → panels/meeting/view.js} +266 -89
  21. data/lib/clacky/default_extensions/meeting/skills/meeting-summarizer/SKILL.md +2 -1
  22. data/lib/clacky/default_extensions/time_machine/ext.yml +12 -0
  23. data/lib/clacky/{default_agents/_panels/time_machine/panel.js → default_extensions/time_machine/panels/time_machine/view.js} +58 -47
  24. data/lib/clacky/default_skills/channel-manager/discord_setup.rb +1 -1
  25. data/lib/clacky/default_skills/extend-openclacky/SKILL.md +83 -18
  26. data/lib/clacky/{api_extension.rb → extension/api_extension.rb} +27 -11
  27. data/lib/clacky/extension/api_loader.rb +136 -0
  28. data/lib/clacky/extension/cli_commands.rb +226 -0
  29. data/lib/clacky/{server/api_extension_dispatcher.rb → extension/dispatcher.rb} +22 -9
  30. data/lib/clacky/extension/hook_loader.rb +77 -0
  31. data/lib/clacky/extension/loader.rb +483 -0
  32. data/lib/clacky/extension/packager.rb +226 -0
  33. data/lib/clacky/{patch_loader.rb → extension/patch_loader.rb} +45 -0
  34. data/lib/clacky/extension/scaffold/template_renderer.rb +64 -0
  35. data/lib/clacky/extension/scaffold/templates/full/README.md.erb +48 -0
  36. data/lib/clacky/extension/scaffold/templates/full/agents/designer.md +5 -0
  37. data/lib/clacky/extension/scaffold/templates/full/api/handler.rb.erb +16 -0
  38. data/lib/clacky/extension/scaffold/templates/full/channels/noop.rb.erb +40 -0
  39. data/lib/clacky/extension/scaffold/templates/full/ext.yml.erb +41 -0
  40. data/lib/clacky/extension/scaffold/templates/full/hooks/audit.rb +9 -0
  41. data/lib/clacky/extension/scaffold/templates/full/panels/dashboard/view.js.erb +194 -0
  42. data/lib/clacky/extension/scaffold/templates/full/patches/audit.rb +15 -0
  43. data/lib/clacky/extension/scaffold/templates/full/skills/__slug__-skill/SKILL.md.erb +8 -0
  44. data/lib/clacky/extension/scaffold/templates/hello/api/handler.rb.erb +9 -0
  45. data/lib/clacky/extension/scaffold/templates/hello/ext.yml.erb +12 -0
  46. data/lib/clacky/extension/scaffold/templates/hello/panels/hello/view.js.erb +31 -0
  47. data/lib/clacky/extension/scaffold.rb +55 -0
  48. data/lib/clacky/extension/verifier.rb +196 -0
  49. data/lib/clacky/identity.rb +70 -0
  50. data/lib/clacky/locales/en.rb +19 -1
  51. data/lib/clacky/locales/zh.rb +19 -1
  52. data/lib/clacky/platform_http_client.rb +18 -15
  53. data/lib/clacky/server/channel/extension_adapter_loader.rb +32 -0
  54. data/lib/clacky/server/channel.rb +6 -0
  55. data/lib/clacky/server/http_server.rb +313 -144
  56. data/lib/clacky/server/session_registry.rb +9 -2
  57. data/lib/clacky/skill_loader.rb +28 -1
  58. data/lib/clacky/ui2/ui_controller.rb +9 -6
  59. data/lib/clacky/version.rb +1 -1
  60. data/lib/clacky/web/app.css +748 -573
  61. data/lib/clacky/web/app.js +63 -15
  62. data/lib/clacky/web/auth.js +2 -0
  63. data/lib/clacky/web/components/notify.js +2 -0
  64. data/lib/clacky/web/components/sidebar.js +1 -5
  65. data/lib/clacky/web/core/ext.js +316 -79
  66. data/lib/clacky/web/features/backup/store.js +1 -0
  67. data/lib/clacky/web/features/backup/view.js +0 -1
  68. data/lib/clacky/web/features/brand/view.js +13 -5
  69. data/lib/clacky/web/features/extensions/store.js +184 -0
  70. data/lib/clacky/web/features/extensions/view.js +389 -0
  71. data/lib/clacky/web/features/new-session/store.js +186 -0
  72. data/lib/clacky/web/features/new-session/view.js +319 -0
  73. data/lib/clacky/web/features/share/view.js +63 -14
  74. data/lib/clacky/web/features/skills/store.js +3 -1
  75. data/lib/clacky/web/features/skills/view.js +1 -1
  76. data/lib/clacky/web/features/tasks/view.js +12 -0
  77. data/lib/clacky/web/features/trash/store.js +24 -6
  78. data/lib/clacky/web/features/trash/view.js +46 -9
  79. data/lib/clacky/web/features/workspace/store.js +2 -0
  80. data/lib/clacky/web/features/workspace/view.js +0 -1
  81. data/lib/clacky/web/i18n.js +118 -106
  82. data/lib/clacky/web/index.html +138 -188
  83. data/lib/clacky/web/sessions.js +64 -269
  84. data/lib/clacky/web/settings.js +1 -1
  85. data/lib/clacky/web/skills.js +2 -0
  86. data/lib/clacky/web/theme.js +2 -0
  87. data/lib/clacky/web/ws-dispatcher.js +1 -0
  88. data/lib/clacky/web/ws.js +2 -0
  89. data/lib/clacky.rb +21 -10
  90. metadata +55 -24
  91. data/benchmark/runner.rb +0 -441
  92. data/lib/clacky/api_extension_loader.rb +0 -168
  93. data/lib/clacky/default_agents/SOUL.md +0 -3
  94. data/lib/clacky/default_agents/USER.md +0 -1
  95. data/lib/clacky/default_agents/coding/profile.yml +0 -5
  96. data/lib/clacky/default_agents/coding/webui/.gitkeep +0 -0
  97. data/lib/clacky/default_agents/general/profile.yml +0 -2
  98. data/lib/clacky/default_extensions/meeting/meta.yml +0 -3
  99. data/lib/clacky/web/features/creator/store.js +0 -81
  100. data/lib/clacky/web/features/creator/view.js +0 -380
  101. /data/lib/clacky/{default_agents → default_extensions/coding/agents}/coding/system_prompt.md +0 -0
  102. /data/lib/clacky/{default_agents → default_extensions/general/agents}/general/system_prompt.md +0 -0
  103. /data/lib/clacky/{default_agents/base_prompt.md → prompts/base.md} +0 -0
@@ -367,14 +367,10 @@ module Clacky
367
367
  res["Cache-Control"] = "no-store"
368
368
  res["Pragma"] = "no-cache"
369
369
  res.body = html
370
- elsif req.path.start_with?("/webui_ext/")
371
- self.send(:serve_webui_ext, req, res)
372
- elsif req.path.start_with?("/builtin_ext/")
373
- self.send(:serve_builtin_ext, req, res)
374
370
  elsif req.path.start_with?("/agent_ui/")
375
371
  self.send(:serve_agent_ui, req, res)
376
- elsif req.path.start_with?("/panel_ui/")
377
- self.send(:serve_panel_ui, req, res)
372
+ elsif req.path.start_with?("/ext_ui/")
373
+ self.send(:serve_ext_ui, req, res)
378
374
  else
379
375
  file_handler.service(req, res)
380
376
  res["Cache-Control"] = "no-store"
@@ -385,12 +381,18 @@ module Clacky
385
381
  # Auto-create a default session on startup
386
382
  create_default_session
387
383
 
388
- # Load user-defined HTTP API extensions from ~/.clacky/api_ext/.
389
- # Done here (not at gem load) so handlers can resolve session_manager
390
- # and other host helpers as soon as they are wired up.
391
- # The loader logs its own summary via Clacky::Logger.
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.
392
388
  Clacky::ApiExtensionLoader.load_all
393
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
+
394
396
  # Start the background scheduler
395
397
  @scheduler.start
396
398
  puts " Scheduler: #{@scheduler.schedules.size} task(s) loaded"
@@ -490,9 +492,9 @@ module Clacky
490
492
  path = req.path
491
493
  method = req.request_method
492
494
 
493
- # User-defined HTTP API extensions live under ~/.clacky/api_ext/<name>/
494
- # and mount at /api/ext/<name>/... Routed through a separate dispatcher
495
- # so the host's giant case table stays focused on built-in endpoints.
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.
496
498
  if path.start_with?(Clacky::Server::ApiExtensionDispatcher::MOUNT_PREFIX)
497
499
  return if Clacky::Server::ApiExtensionDispatcher.handle(req, res, http_server: self)
498
500
  end
@@ -503,6 +505,7 @@ module Clacky
503
505
  when ["GET", "/api/cron-tasks"] then api_list_cron_tasks(res)
504
506
  when ["POST", "/api/cron-tasks"] then api_create_cron_task(req, res)
505
507
  when ["GET", "/api/skills"] then api_list_skills(res)
508
+ when ["GET", "/api/agents"] then api_list_agents(res)
506
509
  when ["GET", "/api/config"] then api_get_config(req, res)
507
510
  when ["GET", "/api/config/settings"] then api_get_settings(res)
508
511
  when ["GET", "/api/exchange-rate"] then api_exchange_rate(req, res)
@@ -533,6 +536,11 @@ module Clacky
533
536
  when ["POST", "/api/onboard/complete"] then api_onboard_complete(req, res)
534
537
  when ["POST", "/api/onboard/skip-soul"] then api_onboard_skip_soul(req, res)
535
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)
536
544
  when ["GET", "/api/brand/status"] then api_brand_status(res)
537
545
  when ["POST", "/api/brand/activate"] then api_brand_activate(req, res)
538
546
  when ["DELETE", "/api/brand/license"] then api_brand_deactivate(res)
@@ -997,7 +1005,13 @@ module Clacky
997
1005
  api_key: data["api_key"],
998
1006
  base_url: data["base_url"],
999
1007
  model: data["default_model"]
1000
- )
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
1001
1015
  json_response(res, 200, {
1002
1016
  ok: true,
1003
1017
  status: "approved",
@@ -1039,28 +1053,41 @@ module Clacky
1039
1053
  @agent_config.save
1040
1054
  end
1041
1055
 
1042
- # Absolute path to the user's WebUI extension directory.
1043
- WEBUI_EXT_ROOT = File.expand_path("~/.clacky/webui_ext")
1044
-
1045
- # Built-in extensions bundled with the gem (default_extensions/*/**.js).
1046
- BUILTIN_EXT_ROOT = File.expand_path("../default_extensions", __dir__)
1047
-
1048
1056
  # Build the full <script> payload injected at {{EXT_SCRIPTS}}:
1049
- # 1. global extensions — ~/.clacky/webui_ext/**/*.js (all agents)
1050
- # 2. agent-scoped UI — agents/<name>/webui/**/*.js (data-agent)
1051
- # 3. official panels default_agents/_panels/<id>/**/*.js (data-panel)
1052
- # Prefixed with a registerPanelAgents() call so the client knows which
1053
- # 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.
1054
1061
  private def webui_ext_script_tags
1055
1062
  [
1056
1063
  panel_agents_script,
1057
- builtin_ext_script_tags,
1058
- global_ext_script_tags,
1059
1064
  agent_webui_script_tags,
1060
- official_panel_script_tags,
1065
+ container_ext_script_tags,
1061
1066
  ].reject(&:empty?).join("\n")
1062
1067
  end
1063
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
+
1064
1091
  # Inline script registering the panel→agents map (which agent profiles
1065
1092
  # reference each official panel, from their profile.yml `panels:`).
1066
1093
  private def panel_agents_script
@@ -1070,62 +1097,86 @@ module Clacky
1070
1097
  "<script>Clacky.ext.registerPanelAgents(#{map.to_json})</script>"
1071
1098
  end
1072
1099
 
1073
- # { "git" => ["coding", ...], ... } scan every agent profile.yml for a
1074
- # `panels:` list and invert it into panel → referencing agents.
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.
1075
1107
  private def panel_agents_map
1076
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
1077
1149
  agent_profile_dirs.each do |name, dir|
1078
1150
  yml = File.join(dir, "profile.yml")
1079
1151
  next unless File.file?(yml)
1080
1152
 
1081
- data = begin
1153
+ parsed = begin
1082
1154
  YAML.safe_load(File.read(yml)) || {}
1083
1155
  rescue StandardError
1084
1156
  {}
1085
1157
  end
1086
- Array(data["panels"]).each { |panel| result[panel.to_s] << name unless result[panel.to_s].include?(name) }
1158
+ data[name] = parsed
1087
1159
  end
1088
- result
1160
+ data
1089
1161
  end
1090
1162
 
1091
- # { agent_name => resolved_dir } across built-in and user agent dirs,
1092
- # user dir winning on name collision (matches AgentProfile lookup order).
1163
+ # { agent_name => resolved_dir } for user-side agent overrides only.
1164
+ # Extension-provided agents are served through the extension pipeline.
1093
1165
  private def agent_profile_dirs
1094
1166
  dirs = {}
1095
- [Clacky::AgentProfile::DEFAULT_AGENTS_DIR, Clacky::AgentProfile::USER_AGENTS_DIR].each do |root|
1096
- next unless Dir.exist?(root)
1167
+ root = Clacky::AgentProfile::USER_AGENTS_DIR
1168
+ return dirs unless Dir.exist?(root)
1097
1169
 
1098
- Dir.children(root).each do |name|
1099
- next if name.start_with?("_") # _panels and other non-agent dirs
1100
- full = File.join(root, name)
1101
- next unless File.directory?(full) && File.file?(File.join(full, "profile.yml"))
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"))
1102
1174
 
1103
- dirs[name] = full
1104
- end
1175
+ dirs[name] = full
1105
1176
  end
1106
1177
  dirs
1107
1178
  end
1108
1179
 
1109
- # Built-in extensions from default_extensions/*/*.js — loaded before user
1110
- # extensions so user ~/.clacky/webui_ext/ can override by ext id.
1111
- private def builtin_ext_script_tags
1112
- return "" unless Dir.exist?(BUILTIN_EXT_ROOT)
1113
-
1114
- Dir.glob(File.join(BUILTIN_EXT_ROOT, "*", "**", "*.js")).sort.map do |abs|
1115
- rel = abs.delete_prefix(BUILTIN_EXT_ROOT + "/")
1116
- ext_id = "builtin/" + rel.delete_suffix(".js")
1117
- src = "/builtin_ext/#{rel}"
1118
- "<script>Clacky.ext._extBegin(#{ext_id.to_json}, #{nil.to_json}, #{nil.to_json})</script>" \
1119
- "<script src=#{src.to_json} data-ext-id=#{ext_id.to_json}></script>" \
1120
- "<script>Clacky.ext._extEnd()</script>"
1121
- end.join("\n")
1122
- end
1123
-
1124
- # Global extensions — visible for all agents (unchanged legacy behavior).
1125
- private def global_ext_script_tags
1126
- ext_script_block(WEBUI_EXT_ROOT, "/webui_ext")
1127
- end
1128
-
1129
1180
  # Agent-scoped UI: agents/<name>/webui/**/*.js. Each script is tagged with
1130
1181
  # its owning agent so the client only mounts it for that profile.
1131
1182
  private def agent_webui_script_tags
@@ -1137,21 +1188,6 @@ module Clacky
1137
1188
  end.reject(&:empty?).join("\n")
1138
1189
  end
1139
1190
 
1140
- # Official panels: default_agents/_panels/<id>/**/*.js. Scope comes from
1141
- # the panel→agents map (registerPanelAgents), so a panel only mounts for
1142
- # agents whose profile.yml references it.
1143
- private def official_panel_script_tags
1144
- root = File.join(Clacky::AgentProfile::DEFAULT_AGENTS_DIR, "_panels")
1145
- return "" unless Dir.exist?(root)
1146
-
1147
- Dir.children(root).sort.filter_map do |panel|
1148
- dir = File.join(root, panel)
1149
- next unless File.directory?(dir)
1150
-
1151
- ext_script_block(dir, "/panel_ui/#{panel}", id_prefix: "panel/#{panel}/", panel: panel)
1152
- end.reject(&:empty?).join("\n")
1153
- end
1154
-
1155
1191
  # Emit begin/script/end triples for every *.js under `root`, served from
1156
1192
  # `url_base`. `agents`/`panel` carry agent-scoping to _extBegin so the
1157
1193
  # client can decide visibility. Returns "" when the dir is absent.
@@ -1173,39 +1209,49 @@ module Clacky
1173
1209
  end.join("\n")
1174
1210
  end
1175
1211
 
1176
- # Serve a static file from ~/.clacky/webui_ext/. Read-only, JS/CSS/HTML only,
1177
- # with strict path containment so a crafted path cannot escape the dir.
1178
- private def serve_webui_ext(req, res)
1179
- rel = req.path.delete_prefix("/webui_ext/")
1180
- abs = File.expand_path(File.join(WEBUI_EXT_ROOT, rel))
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
1181
1237
 
1182
- unless abs.start_with?(WEBUI_EXT_ROOT + File::SEPARATOR) && File.file?(abs)
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("/")
1244
+
1245
+ unit = Clacky::ExtensionLoader.load_all.units.find { |u| u.ext_id == ext_id }
1246
+ unless unit
1183
1247
  res.status = 404
1184
1248
  res.body = "not found"
1185
1249
  return
1186
1250
  end
1187
1251
 
1188
- ext = File.extname(abs)
1189
- ctype = { ".js" => "application/javascript", ".css" => "text/css",
1190
- ".html" => "text/html; charset=utf-8" }[ext]
1191
- unless ctype
1192
- res.status = 415
1193
- res.body = "unsupported media type"
1194
- return
1195
- end
1196
-
1197
- res.status = 200
1198
- res["Content-Type"] = ctype
1199
- res["Cache-Control"] = "no-store"
1200
- res["Pragma"] = "no-cache"
1201
- res.body = File.read(abs)
1202
- end
1203
-
1204
- private def serve_builtin_ext(req, res)
1205
- rel = req.path.delete_prefix("/builtin_ext/")
1206
- abs = File.expand_path(File.join(BUILTIN_EXT_ROOT, rel))
1207
-
1208
- 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)
1209
1255
  res.status = 404
1210
1256
  res.body = "not found"
1211
1257
  return
@@ -1237,16 +1283,6 @@ module Clacky
1237
1283
  serve_static_under(File.join(dir, "webui"), rel, res)
1238
1284
  end
1239
1285
 
1240
- # Serve official panel assets: /panel_ui/<panel>/<rel>.
1241
- private def serve_panel_ui(req, res)
1242
- rest = req.path.delete_prefix("/panel_ui/")
1243
- panel, _, rel = rest.partition("/")
1244
- return (res.status = 404; res.body = "not found") if panel.empty? || rel.empty?
1245
-
1246
- root = File.join(Clacky::AgentProfile::DEFAULT_AGENTS_DIR, "_panels", panel)
1247
- serve_static_under(root, rel, res)
1248
- end
1249
-
1250
1286
  # Read-only static serve of `rel` under `root`, JS/CSS/HTML only, with
1251
1287
  # strict path containment so a crafted rel cannot escape `root`.
1252
1288
  private def serve_static_under(root, rel, res)
@@ -2189,6 +2225,124 @@ module Clacky
2189
2225
  end
2190
2226
  end
2191
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
+
2192
2346
  # POST /api/store/skills/:slug/install
2193
2347
  def api_brand_skills(res)
2194
2348
  brand = Clacky::BrandConfig.load
@@ -3708,6 +3862,16 @@ module Clacky
3708
3862
  json_response(res, 200, { skills: skills })
3709
3863
  end
3710
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
+
3711
3875
  # GET /api/sessions/:id/skills — list user-invocable skills for a session,
3712
3876
  # filtered by the session's agent profile. Used by the frontend slash-command
3713
3877
  # autocomplete so only skills valid for the current profile are suggested.
@@ -4112,14 +4276,12 @@ module Clacky
4112
4276
  # Returns two separate groups:
4113
4277
  # cloud_skills — published to the platform (with download_count)
4114
4278
  # local_skills — local user skills not yet published, or published but with local changes
4115
- # Requires user_licensed? returns 403 otherwise.
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.
4116
4282
  private def api_creator_skills(res)
4117
- brand = Clacky::BrandConfig.load
4118
-
4119
- unless brand.user_licensed?
4120
- json_response(res, 403, { ok: false, error: "User license required" })
4121
- return
4122
- end
4283
+ brand = Clacky::BrandConfig.load
4284
+ licensed = brand.user_licensed?
4123
4285
 
4124
4286
  @skill_loader.load_all
4125
4287
  upload_meta = Clacky::BrandConfig.load_upload_meta
@@ -4148,8 +4310,9 @@ module Clacky
4148
4310
  }
4149
4311
  end
4150
4312
 
4151
- # Fetch platform skills (may fail — we still return local skills)
4152
- platform_result = brand.fetch_my_skills!
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: [] }
4153
4316
  platform_skills = platform_result[:success] ? platform_result[:skills] : []
4154
4317
 
4155
4318
  # cloud_skills: everything that has been published to the platform
@@ -4185,6 +4348,7 @@ module Clacky
4185
4348
 
4186
4349
  json_response(res, 200, {
4187
4350
  ok: true,
4351
+ licensed: licensed,
4188
4352
  cloud_skills: cloud_skills,
4189
4353
  local_skills: local_skills,
4190
4354
  platform_fetch_error: platform_result[:success] ? nil : platform_result[:error]
@@ -4354,7 +4518,8 @@ module Clacky
4354
4518
  total_tasks: s.dig(:stats, :total_tasks) || 0,
4355
4519
  file_size: s[:file_size] || 0,
4356
4520
  model: s[:model],
4357
- working_dir: s[:working_dir]
4521
+ working_dir: s[:working_dir],
4522
+ source: s[:source]
4358
4523
  }
4359
4524
  end
4360
4525
 
@@ -4479,7 +4644,6 @@ module Clacky
4479
4644
  # endpoints let the Web UI read and edit those files.
4480
4645
 
4481
4646
  PROFILE_USER_AGENTS_DIR = File.expand_path("~/.clacky/agents").freeze
4482
- PROFILE_DEFAULT_AGENTS_DIR = File.expand_path("../../default_agents", __dir__).freeze
4483
4647
  PROFILE_MAX_BYTES = 50_000 # Hard limit; prevents runaway content.
4484
4648
 
4485
4649
  # GET /api/profile
@@ -4531,31 +4695,30 @@ module Clacky
4531
4695
  json_response(res, 500, { ok: false, error: e.message })
4532
4696
  end
4533
4697
 
4534
- # 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.
4535
4699
  # Returns { path:, content:, is_default:, writable: }.
4536
4700
  private def _profile_read_file(filename)
4537
- user_path = File.join(PROFILE_USER_AGENTS_DIR, filename)
4538
- default_path = File.join(PROFILE_DEFAULT_AGENTS_DIR, filename)
4701
+ user_path = File.join(PROFILE_USER_AGENTS_DIR, filename)
4539
4702
 
4540
4703
  if File.exist?(user_path) && !File.zero?(user_path)
4541
- {
4704
+ return {
4542
4705
  path: user_path,
4543
4706
  content: File.read(user_path),
4544
4707
  is_default: false
4545
4708
  }
4546
- elsif File.exist?(default_path)
4547
- {
4548
- path: default_path,
4549
- content: File.read(default_path),
4550
- is_default: true
4551
- }
4552
- else
4553
- {
4554
- path: user_path, # Where it WILL be written
4555
- content: "",
4556
- is_default: true
4557
- }
4558
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
+ }
4559
4722
  rescue StandardError => e
4560
4723
  { path: "", content: "", is_default: true, error: e.message }
4561
4724
  end
@@ -5386,7 +5549,7 @@ module Clacky
5386
5549
  end
5387
5550
 
5388
5551
  # PATCH /api/sessions/:id/reasoning_effort
5389
- # Body: { "reasoning_effort": "off" | "low" | "medium" | "high" }
5552
+ # Body: { "reasoning_effort": "off" | "low" | "medium" | "high" | "xhigh" }
5390
5553
  def api_switch_session_reasoning_effort(session_id, req, res)
5391
5554
  body = parse_json_body(req)
5392
5555
  raw = body["reasoning_effort"]
@@ -5918,9 +6081,15 @@ module Clacky
5918
6081
  return unless @registry.exist?(session_id)
5919
6082
 
5920
6083
  agent = nil
5921
- @registry.with_session(session_id) { |s| agent = s[:agent] }
6084
+ web_ui = nil
6085
+ @registry.with_session(session_id) do |s|
6086
+ agent = s[:agent]
6087
+ web_ui = s[:ui]
6088
+ end
5922
6089
  return unless agent
5923
6090
 
6091
+ web_ui&.show_user_message(display_message, source: :web) if display_message
6092
+
5924
6093
  run_agent_task(session_id, agent) { agent.run(prompt, display_text: display_message) }
5925
6094
  end
5926
6095
 
@@ -117,8 +117,15 @@ module Clacky
117
117
  src = (session_data[:source] || "manual").to_s
118
118
  next if counts[src] >= n
119
119
  next if exist?(session_data[:session_id])
120
- @session_restorer.call(session_data)
121
- counts[src] += 1
120
+ begin
121
+ @session_restorer.call(session_data)
122
+ counts[src] += 1
123
+ rescue => e
124
+ Clacky::Logger.warn(
125
+ "[SessionRegistry] skip session #{session_data[:session_id]}: " \
126
+ "#{e.class}: #{e.message}"
127
+ )
128
+ end
122
129
  end
123
130
  end
124
131