openclacky 1.3.6 → 1.3.8

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 (112) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +54 -0
  3. data/lib/clacky/agent/hook_manager.rb +10 -1
  4. data/lib/clacky/agent/system_prompt_builder.rb +1 -1
  5. data/lib/clacky/agent.rb +17 -3
  6. data/lib/clacky/agent_profile.rb +124 -53
  7. data/lib/clacky/billing/billing_store.rb +3 -2
  8. data/lib/clacky/brand_config.rb +309 -6
  9. data/lib/clacky/cli.rb +4 -57
  10. data/lib/clacky/default_extensions/coding/agents/coding/avatar.png +0 -0
  11. data/lib/clacky/default_extensions/coding/ext.yml +19 -0
  12. data/lib/clacky/default_extensions/ext-studio/agents/ext-developer/avatar.png +0 -0
  13. data/lib/clacky/default_extensions/ext-studio/agents/ext-developer/system_prompt.md +95 -0
  14. data/lib/clacky/default_extensions/ext-studio/api/handler.rb +254 -0
  15. data/lib/clacky/default_extensions/ext-studio/ext.yml +33 -0
  16. data/lib/clacky/default_extensions/ext-studio/panels/studio/view.js +1288 -0
  17. data/lib/clacky/default_extensions/ext-studio/skills/ext-debug/SKILL.md +71 -0
  18. data/lib/clacky/default_extensions/ext-studio/skills/ext-publish/SKILL.md +74 -0
  19. data/lib/clacky/default_extensions/ext-studio/skills/ext-scaffold/SKILL.md +65 -0
  20. data/lib/clacky/default_extensions/general/agents/general/avatar.png +0 -0
  21. data/lib/clacky/default_extensions/general/ext.yml +19 -0
  22. data/lib/clacky/default_extensions/git/ext.yml +12 -0
  23. data/lib/clacky/{default_agents/_panels/git/panel.js → default_extensions/git/panels/git/view.js} +4 -5
  24. data/lib/clacky/default_extensions/meeting/{handler.rb → api/handler.rb} +78 -26
  25. data/lib/clacky/default_extensions/meeting/ext.yml +15 -0
  26. data/lib/clacky/default_extensions/meeting/{meeting.js → panels/meeting/view.js} +266 -90
  27. data/lib/clacky/default_extensions/meeting/skills/meeting-summarizer/SKILL.md +2 -1
  28. data/lib/clacky/default_extensions/time_machine/ext.yml +12 -0
  29. data/lib/clacky/{default_agents/_panels/time_machine/panel.js → default_extensions/time_machine/panels/time_machine/view.js} +58 -47
  30. data/lib/clacky/default_skills/channel-manager/discord_setup.rb +1 -1
  31. data/lib/clacky/default_skills/extend-openclacky/SKILL.md +83 -18
  32. data/lib/clacky/{api_extension.rb → extension/api_extension.rb} +27 -11
  33. data/lib/clacky/extension/api_loader.rb +136 -0
  34. data/lib/clacky/extension/cli_commands.rb +226 -0
  35. data/lib/clacky/{server/api_extension_dispatcher.rb → extension/dispatcher.rb} +22 -9
  36. data/lib/clacky/extension/hook_loader.rb +77 -0
  37. data/lib/clacky/extension/loader.rb +489 -0
  38. data/lib/clacky/extension/packager.rb +226 -0
  39. data/lib/clacky/{patch_loader.rb → extension/patch_loader.rb} +45 -0
  40. data/lib/clacky/extension/scaffold/template_renderer.rb +64 -0
  41. data/lib/clacky/extension/scaffold/templates/full/README.md.erb +48 -0
  42. data/lib/clacky/extension/scaffold/templates/full/agents/designer.md +5 -0
  43. data/lib/clacky/extension/scaffold/templates/full/api/handler.rb.erb +16 -0
  44. data/lib/clacky/extension/scaffold/templates/full/channels/noop.rb.erb +40 -0
  45. data/lib/clacky/extension/scaffold/templates/full/ext.yml.erb +41 -0
  46. data/lib/clacky/extension/scaffold/templates/full/hooks/audit.rb +9 -0
  47. data/lib/clacky/extension/scaffold/templates/full/panels/dashboard/view.js.erb +194 -0
  48. data/lib/clacky/extension/scaffold/templates/full/patches/audit.rb +15 -0
  49. data/lib/clacky/extension/scaffold/templates/full/skills/__slug__-skill/SKILL.md.erb +8 -0
  50. data/lib/clacky/extension/scaffold/templates/hello/api/handler.rb.erb +9 -0
  51. data/lib/clacky/extension/scaffold/templates/hello/ext.yml.erb +12 -0
  52. data/lib/clacky/extension/scaffold/templates/hello/panels/hello/view.js.erb +31 -0
  53. data/lib/clacky/extension/scaffold.rb +55 -0
  54. data/lib/clacky/extension/verifier.rb +196 -0
  55. data/lib/clacky/identity.rb +70 -0
  56. data/lib/clacky/locales/en.rb +19 -1
  57. data/lib/clacky/locales/zh.rb +19 -1
  58. data/lib/clacky/platform_http_client.rb +18 -15
  59. data/lib/clacky/server/channel/extension_adapter_loader.rb +32 -0
  60. data/lib/clacky/server/channel.rb +6 -0
  61. data/lib/clacky/server/http_server.rb +439 -146
  62. data/lib/clacky/server/session_registry.rb +9 -2
  63. data/lib/clacky/shell_hook_loader.rb +266 -22
  64. data/lib/clacky/skill_loader.rb +28 -1
  65. data/lib/clacky/ui2/components/welcome_banner.rb +1 -1
  66. data/lib/clacky/ui2/ui_controller.rb +9 -6
  67. data/lib/clacky/utils/workspace_rules.rb +2 -2
  68. data/lib/clacky/version.rb +1 -1
  69. data/lib/clacky/web/app.css +1034 -675
  70. data/lib/clacky/web/app.js +63 -15
  71. data/lib/clacky/web/auth.js +2 -0
  72. data/lib/clacky/web/components/notify.js +2 -0
  73. data/lib/clacky/web/components/sidebar.js +1 -5
  74. data/lib/clacky/web/core/ext.js +432 -89
  75. data/lib/clacky/web/features/backup/store.js +1 -0
  76. data/lib/clacky/web/features/backup/view.js +0 -1
  77. data/lib/clacky/web/features/brand/view.js +13 -5
  78. data/lib/clacky/web/features/extensions/store.js +184 -0
  79. data/lib/clacky/web/features/extensions/view.js +390 -0
  80. data/lib/clacky/web/features/new-session/store.js +199 -0
  81. data/lib/clacky/web/features/new-session/view.js +619 -0
  82. data/lib/clacky/web/features/share/view.js +63 -14
  83. data/lib/clacky/web/features/skills/store.js +3 -1
  84. data/lib/clacky/web/features/skills/view.js +1 -1
  85. data/lib/clacky/web/features/tasks/view.js +12 -0
  86. data/lib/clacky/web/features/trash/store.js +24 -6
  87. data/lib/clacky/web/features/trash/view.js +48 -12
  88. data/lib/clacky/web/features/workspace/store.js +2 -0
  89. data/lib/clacky/web/features/workspace/view.js +0 -1
  90. data/lib/clacky/web/i18n.js +152 -106
  91. data/lib/clacky/web/index.html +172 -190
  92. data/lib/clacky/web/sessions.js +67 -272
  93. data/lib/clacky/web/settings.js +1 -1
  94. data/lib/clacky/web/skills.js +88 -48
  95. data/lib/clacky/web/theme.js +5 -0
  96. data/lib/clacky/web/ws-dispatcher.js +12 -2
  97. data/lib/clacky/web/ws.js +2 -0
  98. data/lib/clacky.rb +21 -10
  99. metadata +52 -21
  100. data/benchmark/runner.rb +0 -441
  101. data/lib/clacky/api_extension_loader.rb +0 -168
  102. data/lib/clacky/default_agents/SOUL.md +0 -3
  103. data/lib/clacky/default_agents/USER.md +0 -1
  104. data/lib/clacky/default_agents/coding/profile.yml +0 -5
  105. data/lib/clacky/default_agents/coding/webui/.gitkeep +0 -0
  106. data/lib/clacky/default_agents/general/profile.yml +0 -2
  107. data/lib/clacky/default_extensions/meeting/meta.yml +0 -3
  108. data/lib/clacky/web/features/creator/store.js +0 -81
  109. data/lib/clacky/web/features/creator/view.js +0 -380
  110. /data/lib/clacky/{default_agents → default_extensions/coding/agents}/coding/system_prompt.md +0 -0
  111. /data/lib/clacky/{default_agents → default_extensions/general/agents}/general/system_prompt.md +0 -0
  112. /data/lib/clacky/{default_agents/base_prompt.md → prompts/base.md} +0 -0
@@ -367,14 +367,12 @@ 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?("/agent_avatar/")
373
+ self.send(:serve_agent_avatar, req, res)
374
+ elsif req.path.start_with?("/ext_ui/")
375
+ self.send(:serve_ext_ui, req, res)
378
376
  else
379
377
  file_handler.service(req, res)
380
378
  res["Cache-Control"] = "no-store"
@@ -385,12 +383,18 @@ module Clacky
385
383
  # Auto-create a default session on startup
386
384
  create_default_session
387
385
 
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.
386
+ # Load api backends contributed by ext.yml containers into the shared
387
+ # ApiExtension registry. Each unit mounts at /api/ext/<ext_id>/. Done
388
+ # here (not at gem load) so handlers can resolve session_manager and
389
+ # other host helpers as soon as they are wired up.
392
390
  Clacky::ApiExtensionLoader.load_all
393
391
 
392
+ # Static verification of every ext.yml container: unknown keys, bad
393
+ # scopes, dangling agent→panel references, layer-shadow warnings.
394
+ # Errors from the loader itself are already surfaced elsewhere; this
395
+ # pass covers the whole-program checks an author needs to see at boot.
396
+ report_extension_issues
397
+
394
398
  # Start the background scheduler
395
399
  @scheduler.start
396
400
  puts " Scheduler: #{@scheduler.schedules.size} task(s) loaded"
@@ -490,9 +494,9 @@ module Clacky
490
494
  path = req.path
491
495
  method = req.request_method
492
496
 
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.
497
+ # HTTP API extensions declared in an ext.yml container mount at
498
+ # /api/ext/<ext_id>/... Routed through a separate dispatcher so the
499
+ # host's giant case table stays focused on built-in endpoints.
496
500
  if path.start_with?(Clacky::Server::ApiExtensionDispatcher::MOUNT_PREFIX)
497
501
  return if Clacky::Server::ApiExtensionDispatcher.handle(req, res, http_server: self)
498
502
  end
@@ -503,6 +507,7 @@ module Clacky
503
507
  when ["GET", "/api/cron-tasks"] then api_list_cron_tasks(res)
504
508
  when ["POST", "/api/cron-tasks"] then api_create_cron_task(req, res)
505
509
  when ["GET", "/api/skills"] then api_list_skills(res)
510
+ when ["GET", "/api/agents"] then api_list_agents(res)
506
511
  when ["GET", "/api/config"] then api_get_config(req, res)
507
512
  when ["GET", "/api/config/settings"] then api_get_settings(res)
508
513
  when ["GET", "/api/exchange-rate"] then api_exchange_rate(req, res)
@@ -533,6 +538,11 @@ module Clacky
533
538
  when ["POST", "/api/onboard/complete"] then api_onboard_complete(req, res)
534
539
  when ["POST", "/api/onboard/skip-soul"] then api_onboard_skip_soul(req, res)
535
540
  when ["GET", "/api/store/skills"] then api_store_skills(res)
541
+ when ["GET", "/api/store/extensions"] then api_store_extensions(req, res)
542
+ when ["GET", "/api/store/extension"] then api_store_extension_detail(req, res)
543
+ when ["POST", "/api/store/extension/disable"] then api_store_extension_disable(req, res)
544
+ when ["POST", "/api/store/extension/enable"] then api_store_extension_enable(req, res)
545
+ when ["DELETE", "/api/store/extension"] then api_store_extension_uninstall(req, res)
536
546
  when ["GET", "/api/brand/status"] then api_brand_status(res)
537
547
  when ["POST", "/api/brand/activate"] then api_brand_activate(req, res)
538
548
  when ["DELETE", "/api/brand/license"] then api_brand_deactivate(res)
@@ -615,6 +625,9 @@ module Clacky
615
625
  elsif method == "GET" && path.match?(%r{^/api/sessions/[^/]+/skills$})
616
626
  session_id = path.sub("/api/sessions/", "").sub("/skills", "")
617
627
  api_session_skills(session_id, res)
628
+ elsif method == "GET" && path.match?(%r{^/api/agents/[^/]+/skills$})
629
+ agent_id = path[%r{^/api/agents/([^/]+)/skills$}, 1]
630
+ api_agent_skills(agent_id, res)
618
631
  elsif method == "GET" && path.match?(%r{^/api/sessions/[^/]+/git/[a-z]+$})
619
632
  session_id = path[%r{^/api/sessions/([^/]+)/git/}, 1]
620
633
  action = path[%r{/git/([a-z]+)$}, 1]
@@ -997,7 +1010,13 @@ module Clacky
997
1010
  api_key: data["api_key"],
998
1011
  base_url: data["base_url"],
999
1012
  model: data["default_model"]
1000
- )
1013
+ ) if data["api_key"]
1014
+ if data["device_token"]
1015
+ Clacky::Identity.load.bind!(
1016
+ device_token: data["device_token"],
1017
+ user_id: data["user_id"]
1018
+ )
1019
+ end
1001
1020
  json_response(res, 200, {
1002
1021
  ok: true,
1003
1022
  status: "approved",
@@ -1039,28 +1058,42 @@ module Clacky
1039
1058
  @agent_config.save
1040
1059
  end
1041
1060
 
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
1061
  # 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.
1062
+ # 1. panel→agents registry (which agent profiles reference each panel)
1063
+ # 2. agent-scoped UI — user ~/.clacky/agents/<name>/webui/**/*.js (data-agent)
1064
+ # 3. ext.yml containers panel view.js served from /ext_ui/
1065
+ # Never raises.
1054
1066
  private def webui_ext_script_tags
1055
1067
  [
1056
1068
  panel_agents_script,
1057
- builtin_ext_script_tags,
1058
- global_ext_script_tags,
1069
+ ext_contributions_script,
1059
1070
  agent_webui_script_tags,
1060
- official_panel_script_tags,
1071
+ container_ext_script_tags,
1061
1072
  ].reject(&:empty?).join("\n")
1062
1073
  end
1063
1074
 
1075
+ # Container extensions (ext.yml): inject each declared panel's view.js.
1076
+ # Visibility is entirely driven by `agent.panels: [id]` references in
1077
+ # profile.yml / ext.yml agents (see `panel_agents_map`). A panel that no
1078
+ # agent references stays hidden — panels never mount themselves.
1079
+ # Served from /ext_ui/<ext_id>/<rel> with no-store so edits go live on
1080
+ # page refresh. Never raises.
1081
+ private def container_ext_script_tags
1082
+ result = Clacky::ExtensionLoader.load_all
1083
+ result.panels.map do |unit|
1084
+ rel = unit.spec["view"]
1085
+ ext_id = "ext/#{unit.ext_id}/#{unit.id}"
1086
+ panel_id = "#{unit.ext_id}/#{unit.id}"
1087
+ src = "/ext_ui/#{unit.ext_id}/#{rel}"
1088
+ "<script>Clacky.ext._extBegin(#{ext_id.to_json}, #{nil.to_json}, #{panel_id.to_json})</script>" \
1089
+ "<script src=#{src.to_json} data-ext-id=#{ext_id.to_json}></script>" \
1090
+ "<script>Clacky.ext._extEnd()</script>"
1091
+ end.join("\n")
1092
+ rescue StandardError => e
1093
+ Clacky::Logger.warn("[ExtensionLoader] panel injection failed: #{e.message}")
1094
+ ""
1095
+ end
1096
+
1064
1097
  # Inline script registering the panel→agents map (which agent profiles
1065
1098
  # reference each official panel, from their profile.yml `panels:`).
1066
1099
  private def panel_agents_script
@@ -1070,62 +1103,116 @@ module Clacky
1070
1103
  "<script>Clacky.ext.registerPanelAgents(#{map.to_json})</script>"
1071
1104
  end
1072
1105
 
1073
- # { "git" => ["coding", ...], ... } scan every agent profile.yml for a
1074
- # `panels:` list and invert it into panel referencing agents.
1106
+ # Inline script mapping agent id => the panels & skills its extension
1107
+ # contributes (straight from ext.yml). Built-in extensions are excluded so
1108
+ # the new-session page only advertises what a third-party agent adds.
1109
+ private def ext_contributions_script
1110
+ map = ext_contributions_map
1111
+ return "" if map.empty?
1112
+
1113
+ "<script>Clacky.ext.registerAgentContributions(#{map.to_json})</script>"
1114
+ end
1115
+
1116
+ private def ext_contributions_map
1117
+ result = Clacky::ExtensionLoader.load_all
1118
+ by_ext = Hash.new { |h, k| h[k] = { "panels" => [], "skills" => [] } }
1119
+ label = lambda do |unit|
1120
+ { "id" => unit.id, "title" => unit.spec["title"] || unit.id, "title_zh" => unit.spec["title_zh"] }
1121
+ end
1122
+
1123
+ result.panels.each { |u| by_ext[u.ext_id]["panels"] << label.call(u) unless u.layer == :builtin }
1124
+ result.skills.each { |u| by_ext[u.ext_id]["skills"] << label.call(u) unless u.layer == :builtin }
1125
+
1126
+ out = {}
1127
+ result.agents.each do |u|
1128
+ next if u.layer == :builtin
1129
+ contrib = by_ext[u.ext_id]
1130
+ next if contrib["panels"].empty? && contrib["skills"].empty?
1131
+ out[u.id] = contrib
1132
+ end
1133
+ out
1134
+ end
1135
+
1136
+ # panel id => list of agent names allowed to see it. Two sources merge:
1137
+ # 1. `agent.panels: [id]` — agent-side explicit reference (opt-in mount)
1138
+ # 2. `panel.attach: [...]` — panel-author's default suggestion
1139
+ # - `attach: [foo]` → visible to agent `foo`
1140
+ # - `attach: ["*"]` → visible to every known agent
1141
+ # - omitted → hidden unless (1) references it
1142
+ # A panel with no source stays hidden.
1075
1143
  private def panel_agents_map
1076
1144
  result = Hash.new { |h, k| h[k] = [] }
1145
+ add = ->(pid, agent) { result[pid] << agent unless result[pid].include?(agent) }
1146
+
1147
+ agent_data = agent_profile_data
1148
+ agent_data.each do |name, data|
1149
+ ext_id = data["_ext_id"]
1150
+ Array(data["panels"]).each do |panel|
1151
+ pid = panel.to_s
1152
+ full = pid.include?("/") ? pid : (ext_id ? "#{ext_id}/#{pid}" : pid)
1153
+ add.call(full, name)
1154
+ end
1155
+ end
1156
+
1157
+ all_agent_ids = agent_data.keys
1158
+ Array(Clacky::ExtensionLoader.last_result&.panels).each do |unit|
1159
+ full_id = "#{unit.ext_id}/#{unit.id}"
1160
+ attach = Array(unit.spec["attach"])
1161
+ next if attach.empty?
1162
+ if attach.include?("*")
1163
+ all_agent_ids.each { |a| add.call(full_id, a) }
1164
+ else
1165
+ attach.each { |a| add.call(full_id, a) if all_agent_ids.include?(a) }
1166
+ end
1167
+ end
1168
+ result
1169
+ end
1170
+
1171
+ # { agent_name => parsed_profile_data } merged across built-in, user, and
1172
+ # ext-contributed agents. Override order: ext < user (ext provides defaults
1173
+ # for ids not in user dirs; user dirs win on collision).
1174
+ private def agent_profile_data
1175
+ data = {}
1176
+ Array(Clacky::ExtensionLoader.last_result&.agents).each do |unit|
1177
+ data[unit.id] = {
1178
+ "name" => unit.id,
1179
+ "description" => unit.spec["description"],
1180
+ "panels" => unit.spec["panels"],
1181
+ "skills" => unit.spec["skills"],
1182
+ "_ext_id" => unit.ext_id,
1183
+ }
1184
+ end
1077
1185
  agent_profile_dirs.each do |name, dir|
1078
1186
  yml = File.join(dir, "profile.yml")
1079
1187
  next unless File.file?(yml)
1080
1188
 
1081
- data = begin
1189
+ parsed = begin
1082
1190
  YAML.safe_load(File.read(yml)) || {}
1083
1191
  rescue StandardError
1084
1192
  {}
1085
1193
  end
1086
- Array(data["panels"]).each { |panel| result[panel.to_s] << name unless result[panel.to_s].include?(name) }
1194
+ data[name] = parsed
1087
1195
  end
1088
- result
1196
+ data
1089
1197
  end
1090
1198
 
1091
- # { agent_name => resolved_dir } across built-in and user agent dirs,
1092
- # user dir winning on name collision (matches AgentProfile lookup order).
1199
+ # { agent_name => resolved_dir } for user-side agent overrides only.
1200
+ # Extension-provided agents are served through the extension pipeline.
1093
1201
  private def agent_profile_dirs
1094
1202
  dirs = {}
1095
- [Clacky::AgentProfile::DEFAULT_AGENTS_DIR, Clacky::AgentProfile::USER_AGENTS_DIR].each do |root|
1096
- next unless Dir.exist?(root)
1203
+ root = Clacky::AgentProfile::USER_AGENTS_DIR
1204
+ return dirs unless Dir.exist?(root)
1097
1205
 
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"))
1206
+ Dir.children(root).each do |name|
1207
+ next if name.start_with?("_")
1208
+ full = File.join(root, name)
1209
+ next unless File.directory?(full) && File.file?(File.join(full, "profile.yml"))
1102
1210
 
1103
- dirs[name] = full
1104
- end
1211
+ dirs[name] = full
1105
1212
  end
1106
1213
  dirs
1107
1214
  end
1108
1215
 
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
1216
  # Agent-scoped UI: agents/<name>/webui/**/*.js. Each script is tagged with
1130
1217
  # its owning agent so the client only mounts it for that profile.
1131
1218
  private def agent_webui_script_tags
@@ -1137,21 +1224,6 @@ module Clacky
1137
1224
  end.reject(&:empty?).join("\n")
1138
1225
  end
1139
1226
 
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
1227
  # Emit begin/script/end triples for every *.js under `root`, served from
1156
1228
  # `url_base`. `agents`/`panel` carry agent-scoping to _extBegin so the
1157
1229
  # client can decide visibility. Returns "" when the dir is absent.
@@ -1173,39 +1245,49 @@ module Clacky
1173
1245
  end.join("\n")
1174
1246
  end
1175
1247
 
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))
1248
+ # Whole-program static checks over ext.yml — unknown keys, bad scopes,
1249
+ # dangling agent→panel refs, layer-shadow overrides. Uses the already-
1250
+ # loaded ExtensionLoader result so this is essentially free at boot.
1251
+ private def report_extension_issues
1252
+ result = Clacky::ExtensionLoader.load_all
1253
+ issues = Clacky::ExtensionVerifier.verify(result)
1254
+ return if issues.empty?
1255
+
1256
+ errors = issues.count { |i| i.level == :error }
1257
+ warns = issues.size - errors
1258
+ Clacky::Logger.info("[ExtensionVerifier] #{errors} error(s), #{warns} warning(s)")
1259
+
1260
+ issues.each do |issue|
1261
+ location = issue.file ? " [#{issue.file}]" : ""
1262
+ unit = issue.unit ? " #{issue.unit}" : ""
1263
+ message = "[ExtensionVerifier] #{issue.ext}#{unit} (#{issue.code}) — #{issue.message}#{location}"
1264
+ if issue.level == :error
1265
+ Clacky::Logger.error(message)
1266
+ else
1267
+ Clacky::Logger.warn(message)
1268
+ end
1269
+ end
1270
+ rescue StandardError => e
1271
+ Clacky::Logger.warn("[ExtensionVerifier] verify failed: #{e.message}")
1272
+ end
1273
+
1274
+ # Serve a file from a resolved extension container: /ext_ui/<ext_id>/<rel>.
1275
+ # The container dir is looked up via the loader (it may live in any layer),
1276
+ # then strict path containment prevents escaping it. JS/CSS only, no-store.
1277
+ private def serve_ext_ui(req, res)
1278
+ rel = req.path.delete_prefix("/ext_ui/")
1279
+ ext_id, _, sub = rel.partition("/")
1181
1280
 
1182
- unless abs.start_with?(WEBUI_EXT_ROOT + File::SEPARATOR) && File.file?(abs)
1281
+ unit = Clacky::ExtensionLoader.load_all.units.find { |u| u.ext_id == ext_id }
1282
+ unless unit
1183
1283
  res.status = 404
1184
1284
  res.body = "not found"
1185
1285
  return
1186
1286
  end
1187
1287
 
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)
1288
+ root = File.expand_path(unit.dir)
1289
+ abs = File.expand_path(File.join(root, sub))
1290
+ unless abs.start_with?(root + File::SEPARATOR) && File.file?(abs)
1209
1291
  res.status = 404
1210
1292
  res.body = "not found"
1211
1293
  return
@@ -1237,14 +1319,40 @@ module Clacky
1237
1319
  serve_static_under(File.join(dir, "webui"), rel, res)
1238
1320
  end
1239
1321
 
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?
1322
+ # GET /agent_avatar/<id> serve an agent's avatar image (extension
1323
+ # `avatar` file, or a user agent's avatar.png). Read-only, images only.
1324
+ private def serve_agent_avatar(req, res)
1325
+ id = req.path.delete_prefix("/agent_avatar/").split("/").first.to_s
1326
+ return (res.status = 404; res.body = "not found") if id.empty?
1245
1327
 
1246
- root = File.join(Clacky::AgentProfile::DEFAULT_AGENTS_DIR, "_panels", panel)
1247
- serve_static_under(root, rel, res)
1328
+ abs = agent_avatar_path(id)
1329
+ unless abs && File.file?(abs)
1330
+ res.status = 404
1331
+ res.body = "not found"
1332
+ return
1333
+ end
1334
+
1335
+ ctype = { ".png" => "image/png", ".jpg" => "image/jpeg", ".jpeg" => "image/jpeg",
1336
+ ".webp" => "image/webp", ".svg" => "image/svg+xml", ".gif" => "image/gif" }[File.extname(abs).downcase]
1337
+ unless ctype
1338
+ res.status = 415
1339
+ res.body = "unsupported media type"
1340
+ return
1341
+ end
1342
+
1343
+ res.status = 200
1344
+ res["Content-Type"] = ctype
1345
+ res["Cache-Control"] = "public, max-age=86400"
1346
+ res.body = File.binread(abs)
1347
+ end
1348
+
1349
+ private def agent_avatar_path(id)
1350
+ unit = Clacky::ExtensionLoader.last_result&.agents&.find { |u| u.id == id }
1351
+ avatar_abs = unit&.spec&.[]("avatar_abs").to_s
1352
+ return avatar_abs unless avatar_abs.empty?
1353
+
1354
+ user_png = File.expand_path("~/.clacky/agents/#{id}/avatar.png")
1355
+ File.file?(user_png) ? user_png : nil
1248
1356
  end
1249
1357
 
1250
1358
  # Read-only static serve of `rel` under `root`, JS/CSS/HTML only, with
@@ -1383,6 +1491,9 @@ module Clacky
1383
1491
  output_dir = body["output_dir"].to_s
1384
1492
  output_dir = @agent_config.default_working_dir || Dir.pwd if output_dir.empty?
1385
1493
 
1494
+ session_id = body["session_id"].to_s
1495
+ session_id = nil if session_id.empty?
1496
+
1386
1497
  result = Clacky::Media::Generator.new(@agent_config).generate_image(
1387
1498
  prompt: prompt,
1388
1499
  aspect_ratio: aspect_ratio,
@@ -1391,7 +1502,7 @@ module Clacky
1391
1502
  images: body["images"]
1392
1503
  )
1393
1504
  if result["success"]
1394
- log_media_usage(result, prompt: prompt)
1505
+ log_media_usage(result, prompt: prompt, session_id: session_id)
1395
1506
  end
1396
1507
  status = result["success"] ? 200 : 422
1397
1508
  json_response(res, status, result)
@@ -1415,6 +1526,9 @@ module Clacky
1415
1526
  output_dir = body["output_dir"].to_s
1416
1527
  output_dir = @agent_config.default_working_dir || Dir.pwd if output_dir.empty?
1417
1528
 
1529
+ session_id = body["session_id"].to_s
1530
+ session_id = nil if session_id.empty?
1531
+
1418
1532
  result = Clacky::Media::Generator.new(@agent_config).generate_video(
1419
1533
  prompt: prompt,
1420
1534
  aspect_ratio: aspect_ratio,
@@ -1423,7 +1537,7 @@ module Clacky
1423
1537
  image: image
1424
1538
  )
1425
1539
  if result["success"]
1426
- log_media_usage(result, prompt: prompt)
1540
+ log_media_usage(result, prompt: prompt, session_id: session_id)
1427
1541
  end
1428
1542
  status = result["success"] ? 200 : 422
1429
1543
  json_response(res, status, result)
@@ -1444,13 +1558,16 @@ module Clacky
1444
1558
  output_dir = body["output_dir"].to_s
1445
1559
  output_dir = @agent_config.default_working_dir || Dir.pwd if output_dir.empty?
1446
1560
 
1561
+ session_id = body["session_id"].to_s
1562
+ session_id = nil if session_id.empty?
1563
+
1447
1564
  result = Clacky::Media::Generator.new(@agent_config).generate_speech(
1448
1565
  input: input,
1449
1566
  voice: voice,
1450
1567
  output_dir: output_dir
1451
1568
  )
1452
1569
  if result["success"]
1453
- log_media_usage(result, prompt: input)
1570
+ log_media_usage(result, prompt: input, session_id: session_id)
1454
1571
  end
1455
1572
  status = result["success"] ? 200 : 422
1456
1573
  json_response(res, status, result)
@@ -1511,7 +1628,7 @@ module Clacky
1511
1628
  json_response(res, 500, { error: e.message })
1512
1629
  end
1513
1630
 
1514
- private def log_media_usage(result, prompt:)
1631
+ private def log_media_usage(result, prompt:, session_id: nil)
1515
1632
  usage = result["usage"]
1516
1633
  cost = result["cost_usd"]
1517
1634
  return if usage.nil? && cost.nil?
@@ -1532,6 +1649,20 @@ module Clacky
1532
1649
  else "image"
1533
1650
  end
1534
1651
  Clacky::Logger.info("[Media] #{kind} generated #{parts.join(" ")}")
1652
+
1653
+ require_relative "../billing/billing_store"
1654
+ require_relative "../billing/billing_record"
1655
+ record = Clacky::Billing::BillingRecord.new(
1656
+ session_id: session_id,
1657
+ model: result["model"].to_s,
1658
+ prompt_tokens: usage.is_a?(Hash) ? usage["prompt_tokens"].to_i : 0,
1659
+ completion_tokens: usage.is_a?(Hash) ? usage["completion_tokens"].to_i : 0,
1660
+ cache_read_tokens: usage.is_a?(Hash) ? usage["cache_read_tokens"].to_i : 0,
1661
+ cache_write_tokens: usage.is_a?(Hash) ? usage["cache_write_tokens"].to_i : 0,
1662
+ cost_usd: cost.to_f,
1663
+ cost_source: :api
1664
+ )
1665
+ Clacky::Billing::BillingStore.new.append(record)
1535
1666
  end
1536
1667
 
1537
1668
  # GET /api/media/types
@@ -2189,6 +2320,124 @@ module Clacky
2189
2320
  end
2190
2321
  end
2191
2322
 
2323
+ # GET /api/store/extensions?q=&sort=
2324
+ #
2325
+ # Public extension marketplace catalog. Anonymous (no license needed).
2326
+ # Proxies BrandConfig#search_extensions! which hits the platform's public
2327
+ # /api/v1/extensions endpoint. Extension archives are NOT downloadable here
2328
+ # — they ship inside license-gated brand packages (path B distribution).
2329
+ def api_store_extensions(req, res)
2330
+ brand = Clacky::BrandConfig.load
2331
+ result = brand.search_extensions!(query: req.query["q"], sort: req.query["sort"])
2332
+
2333
+ if result[:success]
2334
+ installed = installed_extension_slugs
2335
+ extensions = Array(result[:extensions]).map do |ext|
2336
+ slug = ext["name"] || ext[:name] || ext["slug"] || ext[:slug]
2337
+ ext.merge("installed" => installed.include?(slug))
2338
+ end
2339
+ json_response(res, 200, { ok: true, extensions: extensions })
2340
+ else
2341
+ json_response(res, 200, {
2342
+ ok: true,
2343
+ extensions: [],
2344
+ warning: result[:error] || "Could not reach the extension store."
2345
+ })
2346
+ end
2347
+ end
2348
+
2349
+ # Slugs of every extension container currently loaded (any layer), used to
2350
+ # flag "installed" on the public marketplace catalog.
2351
+ def installed_extension_slugs
2352
+ result = Clacky::ExtensionLoader.load_all
2353
+ Array(result&.containers).map { |id, _c| id }.to_set
2354
+ rescue StandardError
2355
+ Set.new
2356
+ end
2357
+
2358
+ # GET /api/store/extension?id=<slug-or-id>
2359
+ #
2360
+ # Public detail for a single marketplace extension (contributes + version
2361
+ # history). Anonymous, proxies BrandConfig#extension_detail!.
2362
+ def api_store_extension_detail(req, res)
2363
+ id = req.query["id"].to_s
2364
+ if id.strip.empty?
2365
+ json_response(res, 400, { ok: false, error: "Missing id." })
2366
+ return
2367
+ end
2368
+
2369
+ brand = Clacky::BrandConfig.load
2370
+ result = brand.extension_detail!(id)
2371
+
2372
+ if result[:success] && result[:extension]
2373
+ ext = result[:extension]
2374
+ slug = ext["name"] || ext[:name] || ext["slug"] || ext[:slug]
2375
+ container = extension_container(slug)
2376
+ ext = ext.merge(
2377
+ "installed" => !container.nil?,
2378
+ "removable" => container && container[:layer] == :installed,
2379
+ "disabled" => container ? container[:disabled] == true : false
2380
+ )
2381
+ json_response(res, 200, { ok: true, extension: ext })
2382
+ else
2383
+ json_response(res, 404, { ok: false, error: result[:error] || "Not found" })
2384
+ end
2385
+ end
2386
+
2387
+ # Locate an installed container by slug/id (nil when not installed).
2388
+ def extension_container(slug)
2389
+ return nil if slug.to_s.strip.empty?
2390
+
2391
+ result = Clacky::ExtensionLoader.load_all
2392
+ Array(result&.containers).to_h[slug.to_s]
2393
+ rescue StandardError
2394
+ nil
2395
+ end
2396
+
2397
+ # POST /api/store/extension/disable body: { id: <slug> }
2398
+ def api_store_extension_disable(req, res)
2399
+ toggle_extension(req, res, :disable)
2400
+ end
2401
+
2402
+ # POST /api/store/extension/enable body: { id: <slug> }
2403
+ def api_store_extension_enable(req, res)
2404
+ toggle_extension(req, res, :enable)
2405
+ end
2406
+
2407
+ def toggle_extension(req, res, action)
2408
+ id = parse_json_body(req)["id"].to_s
2409
+ container = extension_container(id)
2410
+ if container.nil?
2411
+ json_response(res, 404, { ok: false, error: "Not installed." })
2412
+ return
2413
+ end
2414
+
2415
+ action == :disable ? Clacky::ExtensionLoader.disable!(id) : Clacky::ExtensionLoader.enable!(id)
2416
+ json_response(res, 200, { ok: true, id: id, disabled: action == :disable })
2417
+ rescue StandardError => e
2418
+ json_response(res, 500, { ok: false, error: e.message })
2419
+ end
2420
+
2421
+ # DELETE /api/store/extension body: { id: <slug> }
2422
+ def api_store_extension_uninstall(req, res)
2423
+ id = parse_json_body(req)["id"].to_s
2424
+ container = extension_container(id)
2425
+ if container.nil?
2426
+ json_response(res, 404, { ok: false, error: "Not installed." })
2427
+ return
2428
+ end
2429
+ unless container[:layer] == :installed
2430
+ json_response(res, 422, { ok: false, error: "Only marketplace-installed extensions can be removed." })
2431
+ return
2432
+ end
2433
+
2434
+ if Clacky::ExtensionLoader.uninstall!(id)
2435
+ json_response(res, 200, { ok: true, id: id })
2436
+ else
2437
+ json_response(res, 404, { ok: false, error: "Not installed." })
2438
+ end
2439
+ end
2440
+
2192
2441
  # POST /api/store/skills/:slug/install
2193
2442
  def api_brand_skills(res)
2194
2443
  brand = Clacky::BrandConfig.load
@@ -3708,6 +3957,16 @@ module Clacky
3708
3957
  json_response(res, 200, { skills: skills })
3709
3958
  end
3710
3959
 
3960
+ # GET /api/agents — list all available agent profiles (default + user override + ext).
3961
+ # Each entry carries { id, title, title_zh, description, description_zh,
3962
+ # source, order, layer, author }. Extensions can supply title_zh /
3963
+ # description_zh / author in their ext.yml so the New Session cards read
3964
+ # naturally and credit their author.
3965
+ def api_list_agents(res)
3966
+ agents = Clacky::AgentProfile.all
3967
+ json_response(res, 200, { agents: agents })
3968
+ end
3969
+
3711
3970
  # GET /api/sessions/:id/skills — list user-invocable skills for a session,
3712
3971
  # filtered by the session's agent profile. Used by the frontend slash-command
3713
3972
  # autocomplete so only skills valid for the current profile are suggested.
@@ -3753,7 +4012,36 @@ module Clacky
3753
4012
  json_response(res, 200, { skills: skill_data })
3754
4013
  end
3755
4014
 
3756
- # GET /api/sessions/:id/git/<action>read-only git info for the session's
4015
+ # GET /api/agents/:id/skillslike api_session_skills but keyed on an
4016
+ # agent id rather than a live session. Used by the New Session page so the
4017
+ # first-message composer can offer slash-command autocomplete before a
4018
+ # session exists.
4019
+ def api_agent_skills(agent_id, res)
4020
+ profile = begin
4021
+ Clacky::AgentProfile.load(agent_id)
4022
+ rescue StandardError
4023
+ nil
4024
+ end
4025
+
4026
+ @skill_loader.load_all
4027
+ skills = @skill_loader.user_invocable_skills
4028
+ skills = skills.select { |s| s.allowed_for_agent?(profile.name) } if profile
4029
+
4030
+ loaded_from = @skill_loader.loaded_from
4031
+ skill_data = skills.map do |skill|
4032
+ {
4033
+ name: skill.identifier,
4034
+ name_zh: skill.name_zh,
4035
+ description: skill.description || skill.context_description,
4036
+ description_zh: skill.description_zh,
4037
+ encrypted: skill.encrypted?,
4038
+ source_type: loaded_from[skill.identifier],
4039
+ always_show: skill.always_show
4040
+ }
4041
+ end
4042
+
4043
+ json_response(res, 200, { skills: skill_data })
4044
+ end
3757
4045
  # working_dir. action ∈ status|diff|log|branches. diff accepts ?file=,
3758
4046
  # log accepts ?limit=.
3759
4047
  def api_session_git(session_id, action, req, res)
@@ -4112,14 +4400,12 @@ module Clacky
4112
4400
  # Returns two separate groups:
4113
4401
  # cloud_skills — published to the platform (with download_count)
4114
4402
  # local_skills — local user skills not yet published, or published but with local changes
4115
- # Requires user_licensed? returns 403 otherwise.
4403
+ # Local skills are always returned. Cloud listing needs a user license;
4404
+ # when absent we return licensed:false so the UI can explain that becoming
4405
+ # a creator is required to upload.
4116
4406
  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
4407
+ brand = Clacky::BrandConfig.load
4408
+ licensed = brand.user_licensed?
4123
4409
 
4124
4410
  @skill_loader.load_all
4125
4411
  upload_meta = Clacky::BrandConfig.load_upload_meta
@@ -4148,8 +4434,9 @@ module Clacky
4148
4434
  }
4149
4435
  end
4150
4436
 
4151
- # Fetch platform skills (may fail — we still return local skills)
4152
- platform_result = brand.fetch_my_skills!
4437
+ # Fetch platform skills only when licensed (may still fail — we always
4438
+ # return local skills regardless).
4439
+ platform_result = licensed ? brand.fetch_my_skills! : { success: false, skills: [] }
4153
4440
  platform_skills = platform_result[:success] ? platform_result[:skills] : []
4154
4441
 
4155
4442
  # cloud_skills: everything that has been published to the platform
@@ -4185,6 +4472,7 @@ module Clacky
4185
4472
 
4186
4473
  json_response(res, 200, {
4187
4474
  ok: true,
4475
+ licensed: licensed,
4188
4476
  cloud_skills: cloud_skills,
4189
4477
  local_skills: local_skills,
4190
4478
  platform_fetch_error: platform_result[:success] ? nil : platform_result[:error]
@@ -4354,7 +4642,8 @@ module Clacky
4354
4642
  total_tasks: s.dig(:stats, :total_tasks) || 0,
4355
4643
  file_size: s[:file_size] || 0,
4356
4644
  model: s[:model],
4357
- working_dir: s[:working_dir]
4645
+ working_dir: s[:working_dir],
4646
+ source: s[:source]
4358
4647
  }
4359
4648
  end
4360
4649
 
@@ -4479,7 +4768,6 @@ module Clacky
4479
4768
  # endpoints let the Web UI read and edit those files.
4480
4769
 
4481
4770
  PROFILE_USER_AGENTS_DIR = File.expand_path("~/.clacky/agents").freeze
4482
- PROFILE_DEFAULT_AGENTS_DIR = File.expand_path("../../default_agents", __dir__).freeze
4483
4771
  PROFILE_MAX_BYTES = 50_000 # Hard limit; prevents runaway content.
4484
4772
 
4485
4773
  # GET /api/profile
@@ -4531,31 +4819,30 @@ module Clacky
4531
4819
  json_response(res, 500, { ok: false, error: e.message })
4532
4820
  end
4533
4821
 
4534
- # Read a profile file — user override if present, else built-in default.
4822
+ # Read a profile file — user override if present, else built-in default constant.
4535
4823
  # Returns { path:, content:, is_default:, writable: }.
4536
4824
  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)
4825
+ user_path = File.join(PROFILE_USER_AGENTS_DIR, filename)
4539
4826
 
4540
4827
  if File.exist?(user_path) && !File.zero?(user_path)
4541
- {
4828
+ return {
4542
4829
  path: user_path,
4543
4830
  content: File.read(user_path),
4544
4831
  is_default: false
4545
4832
  }
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
4833
  end
4834
+
4835
+ default_content = case filename
4836
+ when "SOUL.md" then Clacky::AgentProfile::DEFAULT_SOUL
4837
+ when "USER.md" then Clacky::AgentProfile::DEFAULT_USER
4838
+ else ""
4839
+ end
4840
+
4841
+ {
4842
+ path: user_path,
4843
+ content: default_content,
4844
+ is_default: true
4845
+ }
4559
4846
  rescue StandardError => e
4560
4847
  { path: "", content: "", is_default: true, error: e.message }
4561
4848
  end
@@ -5386,7 +5673,7 @@ module Clacky
5386
5673
  end
5387
5674
 
5388
5675
  # PATCH /api/sessions/:id/reasoning_effort
5389
- # Body: { "reasoning_effort": "off" | "low" | "medium" | "high" }
5676
+ # Body: { "reasoning_effort": "off" | "low" | "medium" | "high" | "xhigh" }
5390
5677
  def api_switch_session_reasoning_effort(session_id, req, res)
5391
5678
  body = parse_json_body(req)
5392
5679
  raw = body["reasoning_effort"]
@@ -5918,9 +6205,15 @@ module Clacky
5918
6205
  return unless @registry.exist?(session_id)
5919
6206
 
5920
6207
  agent = nil
5921
- @registry.with_session(session_id) { |s| agent = s[:agent] }
6208
+ web_ui = nil
6209
+ @registry.with_session(session_id) do |s|
6210
+ agent = s[:agent]
6211
+ web_ui = s[:ui]
6212
+ end
5922
6213
  return unless agent
5923
6214
 
6215
+ web_ui&.show_user_message(display_message, source: :web) if display_message
6216
+
5924
6217
  run_agent_task(session_id, agent) { agent.run(prompt, display_text: display_message) }
5925
6218
  end
5926
6219