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.
Files changed (108) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +42 -0
  3. data/lib/clacky/agent/message_compressor.rb +32 -8
  4. data/lib/clacky/agent/message_compressor_helper.rb +113 -12
  5. data/lib/clacky/agent.rb +8 -4
  6. data/lib/clacky/agent_profile.rb +121 -53
  7. data/lib/clacky/brand_config.rb +309 -6
  8. data/lib/clacky/cli.rb +4 -58
  9. data/lib/clacky/default_extensions/coding/ext.yml +18 -0
  10. data/lib/clacky/default_extensions/ext-studio/agents/ext-developer/system_prompt.md +95 -0
  11. data/lib/clacky/default_extensions/ext-studio/api/handler.rb +254 -0
  12. data/lib/clacky/default_extensions/ext-studio/ext.yml +32 -0
  13. data/lib/clacky/default_extensions/ext-studio/panels/studio/view.js +1269 -0
  14. data/lib/clacky/default_extensions/ext-studio/skills/ext-debug/SKILL.md +71 -0
  15. data/lib/clacky/default_extensions/ext-studio/skills/ext-publish/SKILL.md +74 -0
  16. data/lib/clacky/default_extensions/ext-studio/skills/ext-scaffold/SKILL.md +65 -0
  17. data/lib/clacky/default_extensions/general/ext.yml +18 -0
  18. data/lib/clacky/default_extensions/git/ext.yml +12 -0
  19. data/lib/clacky/{default_agents/_panels/git/panel.js → default_extensions/git/panels/git/view.js} +1 -2
  20. data/lib/clacky/default_extensions/meeting/{handler.rb → api/handler.rb} +78 -26
  21. data/lib/clacky/default_extensions/meeting/ext.yml +15 -0
  22. data/lib/clacky/default_extensions/meeting/{meeting.js → panels/meeting/view.js} +266 -89
  23. data/lib/clacky/default_extensions/meeting/skills/meeting-summarizer/SKILL.md +2 -1
  24. data/lib/clacky/default_extensions/time_machine/ext.yml +12 -0
  25. data/lib/clacky/{default_agents/_panels/time_machine/panel.js → default_extensions/time_machine/panels/time_machine/view.js} +58 -47
  26. data/lib/clacky/default_skills/channel-manager/discord_setup.rb +1 -1
  27. data/lib/clacky/default_skills/extend-openclacky/SKILL.md +83 -18
  28. data/lib/clacky/{api_extension.rb → extension/api_extension.rb} +27 -11
  29. data/lib/clacky/extension/api_loader.rb +136 -0
  30. data/lib/clacky/extension/cli_commands.rb +226 -0
  31. data/lib/clacky/{server/api_extension_dispatcher.rb → extension/dispatcher.rb} +22 -9
  32. data/lib/clacky/extension/hook_loader.rb +77 -0
  33. data/lib/clacky/extension/loader.rb +483 -0
  34. data/lib/clacky/extension/packager.rb +226 -0
  35. data/lib/clacky/{patch_loader.rb → extension/patch_loader.rb} +45 -0
  36. data/lib/clacky/extension/scaffold/template_renderer.rb +64 -0
  37. data/lib/clacky/extension/scaffold/templates/full/README.md.erb +48 -0
  38. data/lib/clacky/extension/scaffold/templates/full/agents/designer.md +5 -0
  39. data/lib/clacky/extension/scaffold/templates/full/api/handler.rb.erb +16 -0
  40. data/lib/clacky/extension/scaffold/templates/full/channels/noop.rb.erb +40 -0
  41. data/lib/clacky/extension/scaffold/templates/full/ext.yml.erb +41 -0
  42. data/lib/clacky/extension/scaffold/templates/full/hooks/audit.rb +9 -0
  43. data/lib/clacky/extension/scaffold/templates/full/panels/dashboard/view.js.erb +194 -0
  44. data/lib/clacky/extension/scaffold/templates/full/patches/audit.rb +15 -0
  45. data/lib/clacky/extension/scaffold/templates/full/skills/__slug__-skill/SKILL.md.erb +8 -0
  46. data/lib/clacky/extension/scaffold/templates/hello/api/handler.rb.erb +9 -0
  47. data/lib/clacky/extension/scaffold/templates/hello/ext.yml.erb +12 -0
  48. data/lib/clacky/extension/scaffold/templates/hello/panels/hello/view.js.erb +31 -0
  49. data/lib/clacky/extension/scaffold.rb +55 -0
  50. data/lib/clacky/extension/verifier.rb +196 -0
  51. data/lib/clacky/identity.rb +70 -0
  52. data/lib/clacky/locales/en.rb +19 -1
  53. data/lib/clacky/locales/zh.rb +19 -1
  54. data/lib/clacky/platform_http_client.rb +18 -15
  55. data/lib/clacky/server/channel/extension_adapter_loader.rb +32 -0
  56. data/lib/clacky/server/channel.rb +6 -0
  57. data/lib/clacky/server/http_server.rb +435 -263
  58. data/lib/clacky/server/session_registry.rb +59 -3
  59. data/lib/clacky/session_manager.rb +35 -4
  60. data/lib/clacky/skill_loader.rb +28 -1
  61. data/lib/clacky/ui2/layout_manager.rb +0 -5
  62. data/lib/clacky/ui2/progress_handle.rb +0 -3
  63. data/lib/clacky/ui2/ui_controller.rb +9 -15
  64. data/lib/clacky/version.rb +1 -1
  65. data/lib/clacky/web/app.css +782 -554
  66. data/lib/clacky/web/app.js +63 -15
  67. data/lib/clacky/web/auth.js +2 -0
  68. data/lib/clacky/web/components/notify.js +2 -0
  69. data/lib/clacky/web/components/sidebar.js +2 -8
  70. data/lib/clacky/web/core/ext.js +316 -79
  71. data/lib/clacky/web/features/backup/store.js +24 -0
  72. data/lib/clacky/web/features/backup/view.js +49 -23
  73. data/lib/clacky/web/features/brand/view.js +13 -5
  74. data/lib/clacky/web/features/extensions/store.js +184 -0
  75. data/lib/clacky/web/features/extensions/view.js +389 -0
  76. data/lib/clacky/web/features/new-session/store.js +186 -0
  77. data/lib/clacky/web/features/new-session/view.js +319 -0
  78. data/lib/clacky/web/features/share/view.js +63 -14
  79. data/lib/clacky/web/features/skills/store.js +3 -1
  80. data/lib/clacky/web/features/skills/view.js +1 -1
  81. data/lib/clacky/web/features/tasks/view.js +88 -27
  82. data/lib/clacky/web/features/trash/store.js +24 -6
  83. data/lib/clacky/web/features/trash/view.js +46 -9
  84. data/lib/clacky/web/features/workspace/store.js +2 -0
  85. data/lib/clacky/web/features/workspace/view.js +0 -1
  86. data/lib/clacky/web/i18n.js +140 -108
  87. data/lib/clacky/web/index.html +190 -214
  88. data/lib/clacky/web/sessions.js +99 -304
  89. data/lib/clacky/web/settings.js +1 -1
  90. data/lib/clacky/web/skills.js +2 -0
  91. data/lib/clacky/web/theme.js +2 -0
  92. data/lib/clacky/web/ws-dispatcher.js +2 -1
  93. data/lib/clacky/web/ws.js +2 -0
  94. data/lib/clacky.rb +21 -10
  95. metadata +55 -24
  96. data/benchmark/runner.rb +0 -441
  97. data/lib/clacky/api_extension_loader.rb +0 -168
  98. data/lib/clacky/default_agents/SOUL.md +0 -3
  99. data/lib/clacky/default_agents/USER.md +0 -1
  100. data/lib/clacky/default_agents/coding/profile.yml +0 -5
  101. data/lib/clacky/default_agents/coding/webui/.gitkeep +0 -0
  102. data/lib/clacky/default_agents/general/profile.yml +0 -2
  103. data/lib/clacky/default_extensions/meeting/meta.yml +0 -3
  104. data/lib/clacky/web/features/creator/store.js +0 -81
  105. data/lib/clacky/web/features/creator/view.js +0 -380
  106. /data/lib/clacky/{default_agents → default_extensions/coding/agents}/coding/system_prompt.md +0 -0
  107. /data/lib/clacky/{default_agents → default_extensions/general/agents}/general/system_prompt.md +0 -0
  108. /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?("/panel_ui/")
376
- self.send(:serve_panel_ui, req, res)
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 user-defined HTTP API extensions from ~/.clacky/api_ext/.
388
- # Done here (not at gem load) so handlers can resolve session_manager
389
- # and other host helpers as soon as they are wired up.
390
- # 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.
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 (with session history) can take a while.
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
- # User-defined HTTP API extensions live under ~/.clacky/api_ext/<name>/
493
- # and mount at /api/ext/<name>/... Routed through a separate dispatcher
494
- # 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.
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 = URI.decode_www_form(req.query_string.to_s).to_h
740
- limit = [query["limit"].to_i.then { |n| n > 0 ? n : 20 }, 50].min
741
- before = query["before"].to_s.strip.then { |v| v.empty? ? nil : v }
742
- q = query["q"].to_s.strip.then { |v| v.empty? ? nil : v }
743
- q_scope = query["q_scope"].to_s.strip.then { |v| %w[name content].include?(v) ? v : "name" }
744
- date = query["date"].to_s.strip.then { |v| v.empty? ? nil : v }
745
- type = query["type"].to_s.strip.then { |v| v.empty? ? nil : v }
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
- # Fetch one extra NON-PINNED row to detect has_more without a separate count query.
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
- json_response(res, 200, { sessions: sessions, has_more: has_more, cron_count: @registry.cron_count })
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. global extensions — ~/.clacky/webui_ext/**/*.js (all agents)
1049
- # 2. agent-scoped UI — agents/<name>/webui/**/*.js (data-agent)
1050
- # 3. official panels default_agents/_panels/<id>/**/*.js (data-panel)
1051
- # Prefixed with a registerPanelAgents() call so the client knows which
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
- official_panel_script_tags,
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
- # { "git" => ["coding", ...], ... } scan every agent profile.yml for a
1073
- # `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.
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
- data = begin
1153
+ parsed = begin
1081
1154
  YAML.safe_load(File.read(yml)) || {}
1082
1155
  rescue StandardError
1083
1156
  {}
1084
1157
  end
1085
- Array(data["panels"]).each { |panel| result[panel.to_s] << name unless result[panel.to_s].include?(name) }
1158
+ data[name] = parsed
1086
1159
  end
1087
- result
1160
+ data
1088
1161
  end
1089
1162
 
1090
- # { agent_name => resolved_dir } across built-in and user agent dirs,
1091
- # 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.
1092
1165
  private def agent_profile_dirs
1093
1166
  dirs = {}
1094
- [Clacky::AgentProfile::DEFAULT_AGENTS_DIR, Clacky::AgentProfile::USER_AGENTS_DIR].each do |root|
1095
- next unless Dir.exist?(root)
1167
+ root = Clacky::AgentProfile::USER_AGENTS_DIR
1168
+ return dirs unless Dir.exist?(root)
1096
1169
 
1097
- Dir.children(root).each do |name|
1098
- next if name.start_with?("_") # _panels and other non-agent dirs
1099
- full = File.join(root, name)
1100
- 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"))
1101
1174
 
1102
- dirs[name] = full
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
- # Serve a static file from ~/.clacky/webui_ext/. Read-only, JS/CSS/HTML only,
1176
- # with strict path containment so a crafted path cannot escape the dir.
1177
- private def serve_webui_ext(req, res)
1178
- rel = req.path.delete_prefix("/webui_ext/")
1179
- 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
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
- unless abs.start_with?(WEBUI_EXT_ROOT + File::SEPARATOR) && File.file?(abs)
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
- ext = File.extname(abs)
1188
- ctype = { ".js" => "application/javascript", ".css" => "text/css",
1189
- ".html" => "text/html; charset=utf-8" }[ext]
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
- # 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.
4115
4282
  private def api_creator_skills(res)
4116
- brand = Clacky::BrandConfig.load
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 still return local skills)
4151
- 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: [] }
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 = File.join(PROFILE_USER_AGENTS_DIR, filename)
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
- # Initial load: newest 20 sessions regardless of source/profile.
5726
- # Single unified query frontend shows all in one time-sorted list.
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, cron_count: @registry.cron_count)
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 alone is not reliable enough in practice it's
5826
- # best-effort against blocked syscalls (socket writes, OpenSSL read,
5827
- # ConditionVariable#wait with a held mutex) and we've seen sessions
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
- # Tier 1 (t=0): Thread#raise(AgentInterrupted).
5834
- # Unblocks most pure-Ruby waits and Faraday reads.
5835
- # Handles the common case.
5836
- # Tier 2 (t=3): force-close this session's WebSocket connections
5837
- # so any send_raw stuck on socket write wakes up.
5838
- # Try Thread#raise again (idempotent).
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} tier=1 raise")
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] tier=1 raise failed: #{e.message}")
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
- @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
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
- # Mark running BEFORE evict_excess_idle! otherwise this session
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.update(session_id, status: :idle, error: nil)
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
- @registry.update(session_id, status: :idle)
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.update(session_id, status: :error, error: user_message, error_code: code, top_up_url: top_up_url, raw_message: raw_message)
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
- @registry.with_session(session_id) { |s| s[:thread] = thread }
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|