openclacky 1.3.2 → 1.3.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (107) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +54 -0
  3. data/Dockerfile +3 -0
  4. data/README.md +1 -1
  5. data/README_JA.md +237 -0
  6. data/docs/rich_ui_guide.md +277 -0
  7. data/docs/rich_ui_refactor_plan.md +396 -0
  8. data/lib/clacky/agent/llm_caller.rb +10 -4
  9. data/lib/clacky/agent/session_serializer.rb +52 -7
  10. data/lib/clacky/agent/time_machine.rb +247 -26
  11. data/lib/clacky/agent.rb +15 -3
  12. data/lib/clacky/api_extension.rb +262 -0
  13. data/lib/clacky/api_extension_loader.rb +156 -0
  14. data/lib/clacky/cli.rb +93 -3
  15. data/lib/clacky/client.rb +38 -13
  16. data/lib/clacky/default_agents/_panels/git/panel.js +201 -0
  17. data/lib/clacky/default_agents/_panels/time_machine/panel.js +640 -0
  18. data/lib/clacky/default_agents/coding/profile.yml +3 -0
  19. data/lib/clacky/default_agents/coding/webui/.gitkeep +0 -0
  20. data/lib/clacky/default_skills/cron-task-creator/SKILL.md +1 -1
  21. data/lib/clacky/default_skills/extend-openclacky/SKILL.md +6 -4
  22. data/lib/clacky/default_skills/media-gen/SKILL.md +37 -10
  23. data/lib/clacky/idle_compression_timer.rb +3 -1
  24. data/lib/clacky/locales/en.rb +26 -0
  25. data/lib/clacky/locales/i18n.rb +26 -0
  26. data/lib/clacky/locales/zh.rb +26 -0
  27. data/lib/clacky/media/openai_compat.rb +64 -1
  28. data/lib/clacky/message_history.rb +9 -0
  29. data/lib/clacky/rich_ui/components/base_component.rb +50 -0
  30. data/lib/clacky/rich_ui/components/dialogs/approval_dialog.rb +142 -0
  31. data/lib/clacky/rich_ui/components/dialogs/config_menu_dialog.rb +106 -0
  32. data/lib/clacky/rich_ui/components/dialogs/form_dialog.rb +128 -0
  33. data/lib/clacky/rich_ui/components/sidebar.rb +119 -0
  34. data/lib/clacky/rich_ui/components/sidebar_panels.rb +134 -0
  35. data/lib/clacky/rich_ui/components/status_view.rb +58 -0
  36. data/lib/clacky/rich_ui/components/thinking_live_view.rb +79 -0
  37. data/lib/clacky/rich_ui/entry_tracker.rb +56 -0
  38. data/lib/clacky/rich_ui/layout_adapter.rb +16 -0
  39. data/lib/clacky/rich_ui/progress_handle_adapter.rb +24 -0
  40. data/lib/clacky/rich_ui/rich_ui_controller.rb +868 -0
  41. data/lib/clacky/rich_ui/shell/rich_agent_shell.rb +184 -0
  42. data/lib/clacky/rich_ui/view_renderer.rb +291 -0
  43. data/lib/clacky/rich_ui.rb +57 -0
  44. data/lib/clacky/rich_ui_controller.rb +3 -1549
  45. data/lib/clacky/server/api_extension_dispatcher.rb +120 -0
  46. data/lib/clacky/server/channel/channel_manager.rb +26 -0
  47. data/lib/clacky/server/git_panel.rb +115 -0
  48. data/lib/clacky/server/http_server.rb +547 -15
  49. data/lib/clacky/server/server_master.rb +6 -4
  50. data/lib/clacky/server/session_registry.rb +1 -1
  51. data/lib/clacky/shell_hook_loader.rb +1 -1
  52. data/lib/clacky/tools/edit.rb +14 -2
  53. data/lib/clacky/ui2/ui_controller.rb +7 -0
  54. data/lib/clacky/version.rb +1 -1
  55. data/lib/clacky/web/app.css +511 -101
  56. data/lib/clacky/web/app.js +95 -14
  57. data/lib/clacky/web/components/code-editor.js +197 -0
  58. data/lib/clacky/web/{notify.js → components/notify.js} +1 -1
  59. data/lib/clacky/web/{onboard.js → components/onboard.js} +18 -2
  60. data/lib/clacky/web/core/aside.js +117 -0
  61. data/lib/clacky/web/core/ext.js +387 -0
  62. data/lib/clacky/web/features/backup/store.js +92 -0
  63. data/lib/clacky/web/features/backup/view.js +94 -0
  64. data/lib/clacky/web/features/billing/store.js +163 -0
  65. data/lib/clacky/web/{billing.js → features/billing/view.js} +132 -240
  66. data/lib/clacky/web/features/brand/store.js +110 -0
  67. data/lib/clacky/web/{brand.js → features/brand/view.js} +49 -199
  68. data/lib/clacky/web/features/channels/store.js +103 -0
  69. data/lib/clacky/web/{channels.js → features/channels/view.js} +50 -127
  70. data/lib/clacky/web/features/creator/store.js +81 -0
  71. data/lib/clacky/web/{creator.js → features/creator/view.js} +53 -102
  72. data/lib/clacky/web/features/mcp/store.js +158 -0
  73. data/lib/clacky/web/{mcp.js → features/mcp/view.js} +57 -134
  74. data/lib/clacky/web/features/model-tester/store.js +77 -0
  75. data/lib/clacky/web/features/model-tester/view.js +7 -0
  76. data/lib/clacky/web/features/profile/store.js +170 -0
  77. data/lib/clacky/web/{profile.js → features/profile/view.js} +94 -144
  78. data/lib/clacky/web/features/share/store.js +145 -0
  79. data/lib/clacky/web/{share.js → features/share/view.js} +66 -202
  80. data/lib/clacky/web/features/skills/store.js +331 -0
  81. data/lib/clacky/web/features/skills/view.js +581 -0
  82. data/lib/clacky/web/features/tasks/store.js +135 -0
  83. data/lib/clacky/web/features/tasks/view.js +241 -0
  84. data/lib/clacky/web/features/trash/store.js +242 -0
  85. data/lib/clacky/web/{trash.js → features/trash/view.js} +102 -293
  86. data/lib/clacky/web/features/version/store.js +165 -0
  87. data/lib/clacky/web/features/version/view.js +323 -0
  88. data/lib/clacky/web/features/workspace/store.js +99 -0
  89. data/lib/clacky/web/features/workspace/view.js +305 -0
  90. data/lib/clacky/web/i18n.js +68 -6
  91. data/lib/clacky/web/index.html +113 -62
  92. data/lib/clacky/web/sessions.js +493 -39
  93. data/lib/clacky/web/settings.js +143 -49
  94. data/lib/clacky/web/skills.js +3 -863
  95. data/lib/clacky/web/vendor/codemirror/codemirror.min.js +29 -0
  96. data/lib/clacky/web/ws-dispatcher.js +7 -3
  97. data/lib/clacky.rb +17 -1
  98. metadata +81 -20
  99. data/lib/clacky/web/backup.js +0 -119
  100. data/lib/clacky/web/model-tester.js +0 -66
  101. data/lib/clacky/web/tasks.js +0 -373
  102. data/lib/clacky/web/version.js +0 -449
  103. data/lib/clacky/web/workspace.js +0 -316
  104. /data/lib/clacky/web/{notify.mp3 → assets/notify.mp3} +0 -0
  105. /data/lib/clacky/web/{datepicker.js → components/datepicker.js} +0 -0
  106. /data/lib/clacky/web/{sidebar.js → components/sidebar.js} +0 -0
  107. /data/lib/clacky/web/{marked.min.js → vendor/marked/marked.min.js} +0 -0
@@ -16,6 +16,7 @@ require "timeout"
16
16
  require "yaml"
17
17
  require "date"
18
18
  require_relative "session_registry"
19
+ require_relative "git_panel"
19
20
  require_relative "web_ui_controller"
20
21
  require_relative "scheduler"
21
22
  require_relative "../brand_config"
@@ -217,6 +218,7 @@ module Clacky
217
218
  end
218
219
 
219
220
  def start
221
+ @start_time = Time.now
220
222
  # One-time migration: move legacy trash contents into file-trash/ subdirectory.
221
223
  Clacky::TrashDirectory.migrate_legacy_if_needed
222
224
 
@@ -333,6 +335,14 @@ module Clacky
333
335
  server.mount("/api", servlet_class)
334
336
  server.mount("/ws", servlet_class)
335
337
 
338
+ # Health check endpoint — no auth, minimal overhead.
339
+ # Docker / orchestrators can probe this to decide container health.
340
+ server.mount_proc("/health") do |_req, res|
341
+ res.status = 200
342
+ res["Content-Type"] = "application/json"
343
+ res.body = '{"status":"ok"}'
344
+ end
345
+
336
346
  # Mount static file handler for the entire web directory.
337
347
  # Use mount_proc so we can inject no-cache headers on every response,
338
348
  # preventing stale JS/CSS from being served after a gem update.
@@ -347,12 +357,21 @@ module Clacky
347
357
  server.mount_proc("/") do |req, res|
348
358
  if req.path == "/" || req.path == "/index.html"
349
359
  product_name = Clacky::BrandConfig.load.product_name || "OpenClacky"
350
- html = File.read(index_html_path).gsub("{{BRAND_NAME}}", product_name)
360
+ pure = req.query["pure"] == "true"
361
+ html = File.read(index_html_path)
362
+ .gsub("{{BRAND_NAME}}", product_name)
363
+ .gsub("{{EXT_SCRIPTS}}", pure ? "" : self.send(:webui_ext_script_tags))
351
364
  res.status = 200
352
365
  res["Content-Type"] = "text/html; charset=utf-8"
353
366
  res["Cache-Control"] = "no-store"
354
367
  res["Pragma"] = "no-cache"
355
368
  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?("/agent_ui/")
372
+ self.send(:serve_agent_ui, req, res)
373
+ elsif req.path.start_with?("/panel_ui/")
374
+ self.send(:serve_panel_ui, req, res)
356
375
  else
357
376
  file_handler.service(req, res)
358
377
  res["Cache-Control"] = "no-store"
@@ -363,6 +382,12 @@ module Clacky
363
382
  # Auto-create a default session on startup
364
383
  create_default_session
365
384
 
385
+ # Load user-defined HTTP API extensions from ~/.clacky/api_ext/.
386
+ # Done here (not at gem load) so handlers can resolve session_manager
387
+ # and other host helpers as soon as they are wired up.
388
+ # The loader logs its own summary via Clacky::Logger.
389
+ Clacky::ApiExtensionLoader.load_all
390
+
366
391
  # Start the background scheduler
367
392
  @scheduler.start
368
393
  puts " Scheduler: #{@scheduler.schedules.size} task(s) loaded"
@@ -394,6 +419,8 @@ module Clacky
394
419
  path = req.path
395
420
  method = req.request_method
396
421
 
422
+ Thread.current[:lang] = req["X-Lang"].to_s.strip.then { |l| l.empty? ? nil : l }
423
+
397
424
  # Access key guard (skip for WebSocket upgrades)
398
425
  return unless check_access_key(req, res)
399
426
 
@@ -412,6 +439,11 @@ module Clacky
412
439
  # generous 90s so retry + failover can complete without being cut short.
413
440
  timeout_sec = if path.start_with?("/api/brand")
414
441
  90
442
+ elsif path.start_with?(Clacky::Server::ApiExtensionDispatcher::MOUNT_PREFIX)
443
+ # api_ext dispatcher applies its own per-route timeout (capped at
444
+ # ApiExtension::MAX_TIMEOUT). Use the upper bound here so the outer
445
+ # guard never cuts a long-running custom handler short.
446
+ Clacky::ApiExtension::MAX_TIMEOUT + 30
415
447
  elsif path == "/api/tool/browser"
416
448
  30
417
449
  elsif path == "/api/exchange-rate"
@@ -451,6 +483,13 @@ module Clacky
451
483
  path = req.path
452
484
  method = req.request_method
453
485
 
486
+ # User-defined HTTP API extensions live under ~/.clacky/api_ext/<name>/
487
+ # and mount at /api/ext/<name>/... Routed through a separate dispatcher
488
+ # so the host's giant case table stays focused on built-in endpoints.
489
+ if path.start_with?(Clacky::Server::ApiExtensionDispatcher::MOUNT_PREFIX)
490
+ return if Clacky::Server::ApiExtensionDispatcher.handle(req, res, http_server: self)
491
+ end
492
+
454
493
  case [method, path]
455
494
  when ["GET", "/api/sessions"] then api_list_sessions(req, res)
456
495
  when ["POST", "/api/sessions"] then api_create_session(req, res)
@@ -565,8 +604,31 @@ module Clacky
565
604
  elsif method == "GET" && path.match?(%r{^/api/sessions/[^/]+/skills$})
566
605
  session_id = path.sub("/api/sessions/", "").sub("/skills", "")
567
606
  api_session_skills(session_id, res)
607
+ elsif method == "GET" && path.match?(%r{^/api/sessions/[^/]+/git/[a-z]+$})
608
+ session_id = path[%r{^/api/sessions/([^/]+)/git/}, 1]
609
+ action = path[%r{/git/([a-z]+)$}, 1]
610
+ api_session_git(session_id, action, req, res)
611
+ elsif method == "POST" && path.match?(%r{^/api/sessions/[^/]+/git/commit$})
612
+ session_id = path[%r{^/api/sessions/([^/]+)/git/}, 1]
613
+ api_session_git_commit(session_id, req, res)
614
+ elsif method == "GET" && path.match?(%r{^/api/sessions/[^/]+/time_machine$})
615
+ session_id = path.sub("/api/sessions/", "").sub("/time_machine", "")
616
+ api_session_time_machine(session_id, res)
617
+ elsif method == "POST" && path.match?(%r{^/api/sessions/[^/]+/time_machine/switch$})
618
+ session_id = path[%r{^/api/sessions/([^/]+)/time_machine/}, 1]
619
+ api_session_time_machine_switch(session_id, req, res)
620
+ elsif method == "GET" && path.match?(%r{^/api/sessions/[^/]+/time_machine/\d+/diff$})
621
+ session_id = path[%r{^/api/sessions/([^/]+)/time_machine/}, 1]
622
+ task_id = path[%r{/time_machine/(\d+)/diff$}, 1].to_i
623
+ api_session_time_machine_diff(session_id, task_id, req, res)
624
+ elsif method == "GET" && path.match?(%r{^/api/sessions/[^/]+/time_machine/\d+/restore_preview$})
625
+ session_id = path[%r{^/api/sessions/([^/]+)/time_machine/}, 1]
626
+ task_id = path[%r{/time_machine/(\d+)/restore_preview$}, 1].to_i
627
+ api_session_time_machine_restore_preview(session_id, task_id, res)
568
628
  elsif method == "GET" && path == "/api/dirs"
569
629
  api_browse_dirs(req, res)
630
+ elsif method == "POST" && path == "/api/dirs/mkdir"
631
+ api_dirs_mkdir(req, res)
570
632
  elsif method == "GET" && path.match?(%r{^/api/sessions/[^/]+/files$})
571
633
  session_id = path.sub("/api/sessions/", "").sub("/files", "")
572
634
  api_session_files(session_id, req, res)
@@ -576,6 +638,9 @@ module Clacky
576
638
  elsif method == "GET" && path.match?(%r{^/api/sessions/[^/]+/messages$})
577
639
  session_id = path.sub("/api/sessions/", "").sub("/messages", "")
578
640
  api_session_messages(session_id, req, res)
641
+ elsif method == "GET" && path.match?(%r{^/api/sessions/[^/]+$})
642
+ session_id = path.sub("/api/sessions/", "")
643
+ api_get_session(session_id, res)
579
644
  elsif method == "PATCH" && path.match?(%r{^/api/sessions/[^/]+$})
580
645
  session_id = path.sub("/api/sessions/", "")
581
646
  api_rename_session(session_id, req, res)
@@ -627,6 +692,12 @@ module Clacky
627
692
  elsif method == "PATCH" && path.match?(%r{^/api/skills/[^/]+/toggle$})
628
693
  name = URI.decode_www_form_component(path.sub("/api/skills/", "").sub("/toggle", ""))
629
694
  api_toggle_skill(name, req, res)
695
+ elsif method == "GET" && path.match?(%r{^/api/skills/[^/]+/content$})
696
+ name = URI.decode_www_form_component(path.sub("/api/skills/", "").sub("/content", ""))
697
+ api_skill_content_get(name, res)
698
+ elsif method == "PUT" && path.match?(%r{^/api/skills/[^/]+/content$})
699
+ name = URI.decode_www_form_component(path.sub("/api/skills/", "").sub("/content", ""))
700
+ api_skill_content_update(name, req, res)
630
701
  elsif method == "DELETE" && path.match?(%r{^/api/skills/[^/]+$})
631
702
  name = URI.decode_www_form_component(path.sub("/api/skills/", ""))
632
703
  api_delete_skill(name, res)
@@ -683,6 +754,16 @@ module Clacky
683
754
  json_response(res, 200, { sessions: sessions, has_more: has_more, cron_count: @registry.cron_count })
684
755
  end
685
756
 
757
+ # GET /api/sessions/:id — fetch a single session by id (memory + disk merged).
758
+ # Used by the frontend Router when navigating to a session that isn't in
759
+ # the paged sidebar list (search results, URL deep links, share links,
760
+ # browser back/forward, external notifications, etc.).
761
+ def api_get_session(session_id, res)
762
+ row = @registry.snapshot(session_id)
763
+ return json_response(res, 404, { error: "Session not found" }) unless row
764
+ json_response(res, 200, { session: row })
765
+ end
766
+
686
767
  def api_create_session(req, res)
687
768
  body = parse_json_body(req)
688
769
  name = body["name"]
@@ -949,9 +1030,195 @@ module Clacky
949
1030
  @agent_config.save
950
1031
  end
951
1032
 
1033
+ # Absolute path to the user's WebUI extension directory.
1034
+ WEBUI_EXT_ROOT = File.expand_path("~/.clacky/webui_ext")
1035
+
1036
+ # Build the full <script> payload injected at {{EXT_SCRIPTS}}:
1037
+ # 1. global extensions — ~/.clacky/webui_ext/**/*.js (all agents)
1038
+ # 2. agent-scoped UI — agents/<name>/webui/**/*.js (data-agent)
1039
+ # 3. official panels — default_agents/_panels/<id>/**/*.js (data-panel)
1040
+ # Prefixed with a registerPanelAgents() call so the client knows which
1041
+ # agent profiles reference each official panel. Never raises.
1042
+ private def webui_ext_script_tags
1043
+ [
1044
+ panel_agents_script,
1045
+ global_ext_script_tags,
1046
+ agent_webui_script_tags,
1047
+ official_panel_script_tags,
1048
+ ].reject(&:empty?).join("\n")
1049
+ end
1050
+
1051
+ # Inline script registering the panel→agents map (which agent profiles
1052
+ # reference each official panel, from their profile.yml `panels:`).
1053
+ private def panel_agents_script
1054
+ map = panel_agents_map
1055
+ return "" if map.empty?
1056
+
1057
+ "<script>Clacky.ext.registerPanelAgents(#{map.to_json})</script>"
1058
+ end
1059
+
1060
+ # { "git" => ["coding", ...], ... } — scan every agent profile.yml for a
1061
+ # `panels:` list and invert it into panel → referencing agents.
1062
+ private def panel_agents_map
1063
+ result = Hash.new { |h, k| h[k] = [] }
1064
+ agent_profile_dirs.each do |name, dir|
1065
+ yml = File.join(dir, "profile.yml")
1066
+ next unless File.file?(yml)
1067
+
1068
+ data = begin
1069
+ YAML.safe_load(File.read(yml)) || {}
1070
+ rescue StandardError
1071
+ {}
1072
+ end
1073
+ Array(data["panels"]).each { |panel| result[panel.to_s] << name unless result[panel.to_s].include?(name) }
1074
+ end
1075
+ result
1076
+ end
1077
+
1078
+ # { agent_name => resolved_dir } across built-in and user agent dirs,
1079
+ # user dir winning on name collision (matches AgentProfile lookup order).
1080
+ private def agent_profile_dirs
1081
+ dirs = {}
1082
+ [Clacky::AgentProfile::DEFAULT_AGENTS_DIR, Clacky::AgentProfile::USER_AGENTS_DIR].each do |root|
1083
+ next unless Dir.exist?(root)
1084
+
1085
+ Dir.children(root).each do |name|
1086
+ next if name.start_with?("_") # _panels and other non-agent dirs
1087
+ full = File.join(root, name)
1088
+ next unless File.directory?(full) && File.file?(File.join(full, "profile.yml"))
1089
+
1090
+ dirs[name] = full
1091
+ end
1092
+ end
1093
+ dirs
1094
+ end
1095
+
1096
+ # Global extensions — visible for all agents (unchanged legacy behavior).
1097
+ private def global_ext_script_tags
1098
+ ext_script_block(WEBUI_EXT_ROOT, "/webui_ext")
1099
+ end
1100
+
1101
+ # Agent-scoped UI: agents/<name>/webui/**/*.js. Each script is tagged with
1102
+ # its owning agent so the client only mounts it for that profile.
1103
+ private def agent_webui_script_tags
1104
+ agent_profile_dirs.filter_map do |name, dir|
1105
+ webui = File.join(dir, "webui")
1106
+ next unless Dir.exist?(webui)
1107
+
1108
+ ext_script_block(webui, "/agent_ui/#{name}", id_prefix: "agent/#{name}/", agents: [name])
1109
+ end.reject(&:empty?).join("\n")
1110
+ end
1111
+
1112
+ # Official panels: default_agents/_panels/<id>/**/*.js. Scope comes from
1113
+ # the panel→agents map (registerPanelAgents), so a panel only mounts for
1114
+ # agents whose profile.yml references it.
1115
+ private def official_panel_script_tags
1116
+ root = File.join(Clacky::AgentProfile::DEFAULT_AGENTS_DIR, "_panels")
1117
+ return "" unless Dir.exist?(root)
1118
+
1119
+ Dir.children(root).sort.filter_map do |panel|
1120
+ dir = File.join(root, panel)
1121
+ next unless File.directory?(dir)
1122
+
1123
+ ext_script_block(dir, "/panel_ui/#{panel}", id_prefix: "panel/#{panel}/", panel: panel)
1124
+ end.reject(&:empty?).join("\n")
1125
+ end
952
1126
 
953
- # GET /api/browser/status
954
- # Returns real daemon liveness from BrowserManager (not just yml read).
1127
+ # Emit begin/script/end triples for every *.js under `root`, served from
1128
+ # `url_base`. `agents`/`panel` carry agent-scoping to _extBegin so the
1129
+ # client can decide visibility. Returns "" when the dir is absent.
1130
+ private def ext_script_block(root, url_base, id_prefix: "", agents: nil, panel: nil)
1131
+ return "" unless Dir.exist?(root)
1132
+
1133
+ Dir.glob(File.join(root, "**", "*.js")).sort.map do |abs|
1134
+ rel = abs.delete_prefix(root + "/")
1135
+ ext_id = id_prefix + rel.delete_suffix(".js")
1136
+ src = "#{url_base}/#{rel}"
1137
+ # Bracket the extension's own <script> with begin/end markers so that
1138
+ # registrations made during its synchronous evaluation are attributed
1139
+ # to it (for crash attribution / disable). Synchronous src scripts run
1140
+ # in document order, so the surrounding inline scripts run immediately
1141
+ # before and after it.
1142
+ "<script>Clacky.ext._extBegin(#{ext_id.to_json}, #{agents.to_json}, #{panel.to_json})</script>" \
1143
+ "<script src=#{src.to_json} data-ext-id=#{ext_id.to_json}></script>" \
1144
+ "<script>Clacky.ext._extEnd()</script>"
1145
+ end.join("\n")
1146
+ end
1147
+
1148
+ # Serve a static file from ~/.clacky/webui_ext/. Read-only, JS/CSS/HTML only,
1149
+ # with strict path containment so a crafted path cannot escape the dir.
1150
+ private def serve_webui_ext(req, res)
1151
+ rel = req.path.delete_prefix("/webui_ext/")
1152
+ abs = File.expand_path(File.join(WEBUI_EXT_ROOT, rel))
1153
+
1154
+ unless abs.start_with?(WEBUI_EXT_ROOT + File::SEPARATOR) && File.file?(abs)
1155
+ res.status = 404
1156
+ res.body = "not found"
1157
+ return
1158
+ end
1159
+
1160
+ ext = File.extname(abs)
1161
+ ctype = { ".js" => "application/javascript", ".css" => "text/css",
1162
+ ".html" => "text/html; charset=utf-8" }[ext]
1163
+ unless ctype
1164
+ res.status = 415
1165
+ res.body = "unsupported media type"
1166
+ return
1167
+ end
1168
+
1169
+ res.status = 200
1170
+ res["Content-Type"] = ctype
1171
+ res["Cache-Control"] = "no-store"
1172
+ res["Pragma"] = "no-cache"
1173
+ res.body = File.read(abs)
1174
+ end
1175
+
1176
+ # Serve agents/<name>/webui/<file> from built-in or user agent dir.
1177
+ # Path: /agent_ui/<name>/<rel>. User dir wins on name collision.
1178
+ private def serve_agent_ui(req, res)
1179
+ rest = req.path.delete_prefix("/agent_ui/")
1180
+ name, _, rel = rest.partition("/")
1181
+ dir = agent_profile_dirs[name]
1182
+ return (res.status = 404; res.body = "not found") unless dir && !rel.empty?
1183
+
1184
+ serve_static_under(File.join(dir, "webui"), rel, res)
1185
+ end
1186
+
1187
+ # Serve official panel assets: /panel_ui/<panel>/<rel>.
1188
+ private def serve_panel_ui(req, res)
1189
+ rest = req.path.delete_prefix("/panel_ui/")
1190
+ panel, _, rel = rest.partition("/")
1191
+ return (res.status = 404; res.body = "not found") if panel.empty? || rel.empty?
1192
+
1193
+ root = File.join(Clacky::AgentProfile::DEFAULT_AGENTS_DIR, "_panels", panel)
1194
+ serve_static_under(root, rel, res)
1195
+ end
1196
+
1197
+ # Read-only static serve of `rel` under `root`, JS/CSS/HTML only, with
1198
+ # strict path containment so a crafted rel cannot escape `root`.
1199
+ private def serve_static_under(root, rel, res)
1200
+ root = File.expand_path(root)
1201
+ abs = File.expand_path(File.join(root, rel))
1202
+ unless abs.start_with?(root + File::SEPARATOR) && File.file?(abs)
1203
+ res.status = 404
1204
+ res.body = "not found"
1205
+ return
1206
+ end
1207
+
1208
+ ctype = { ".js" => "application/javascript", ".css" => "text/css",
1209
+ ".html" => "text/html; charset=utf-8" }[File.extname(abs)]
1210
+ unless ctype
1211
+ res.status = 415
1212
+ res.body = "unsupported media type"
1213
+ return
1214
+ end
1215
+
1216
+ res.status = 200
1217
+ res["Content-Type"] = ctype
1218
+ res["Cache-Control"] = "no-store"
1219
+ res["Pragma"] = "no-cache"
1220
+ res.body = File.read(abs)
1221
+ end
955
1222
  def api_browser_status(res)
956
1223
  json_response(res, 200, @browser_manager.status)
957
1224
  end
@@ -1066,7 +1333,9 @@ module Clacky
1066
1333
  result = Clacky::Media::Generator.new(@agent_config).generate_image(
1067
1334
  prompt: prompt,
1068
1335
  aspect_ratio: aspect_ratio,
1069
- output_dir: output_dir
1336
+ output_dir: output_dir,
1337
+ image: body["image"],
1338
+ images: body["images"]
1070
1339
  )
1071
1340
  if result["success"]
1072
1341
  log_media_usage(result, prompt: prompt)
@@ -2164,6 +2433,13 @@ module Clacky
2164
2433
  # skills/curl talk to the server without an access key.
2165
2434
  return true if loopback_ip?(ip)
2166
2435
 
2436
+ # Public API extension endpoints (declared via public_endpoint + meta.yml
2437
+ # public:true) are intentionally exposed without auth — used for
2438
+ # third-party webhooks where the extension does its own signature check.
2439
+ if Clacky::Server::ApiExtensionDispatcher.public_path?(req.path, req.request_method)
2440
+ return true
2441
+ end
2442
+
2167
2443
  candidate = extract_key(req)
2168
2444
 
2169
2445
  # Lazily evict expired lockout entries to prevent unbounded memory growth.
@@ -3369,7 +3645,147 @@ module Clacky
3369
3645
  json_response(res, 200, { skills: skill_data })
3370
3646
  end
3371
3647
 
3372
- # GET /api/sessions/:id/files?path=<relative dir>
3648
+ # GET /api/sessions/:id/git/<action> — read-only git info for the session's
3649
+ # working_dir. action ∈ status|diff|log|branches. diff accepts ?file=,
3650
+ # log accepts ?limit=.
3651
+ def api_session_git(session_id, action, req, res)
3652
+ dir = git_session_dir(session_id, res)
3653
+ return unless dir
3654
+
3655
+ unless Clacky::Server::GitPanel.repo?(dir)
3656
+ return json_response(res, 200, { repo: false })
3657
+ end
3658
+
3659
+ query = URI.decode_www_form(req.query_string.to_s).to_h
3660
+ case action
3661
+ when "status"
3662
+ json_response(res, 200, { repo: true }.merge(Clacky::Server::GitPanel.status(dir)))
3663
+ when "diff"
3664
+ json_response(res, 200, { repo: true, diff: Clacky::Server::GitPanel.diff(dir, file: query["file"]) })
3665
+ when "log"
3666
+ json_response(res, 200, { repo: true, commits: Clacky::Server::GitPanel.log(dir, limit: query["limit"] || 50) })
3667
+ when "branches"
3668
+ json_response(res, 200, { repo: true, branches: Clacky::Server::GitPanel.branches(dir) })
3669
+ else
3670
+ json_response(res, 404, { error: "Unknown git action" })
3671
+ end
3672
+ end
3673
+
3674
+ # POST /api/sessions/:id/git/commit — body: { message:, files: [..] }.
3675
+ def api_session_git_commit(session_id, req, res)
3676
+ dir = git_session_dir(session_id, res)
3677
+ return unless dir
3678
+
3679
+ unless Clacky::Server::GitPanel.repo?(dir)
3680
+ return json_response(res, 400, { error: "Not a git repository" })
3681
+ end
3682
+
3683
+ body = parse_json_body(req)
3684
+ result = Clacky::Server::GitPanel.commit(dir, message: body["message"], files: body["files"])
3685
+ if result[:ok]
3686
+ json_response(res, 200, result)
3687
+ else
3688
+ json_response(res, 422, { error: result[:error] })
3689
+ end
3690
+ end
3691
+
3692
+ # GET /api/sessions/:id/time_machine — task history for the Time Machine
3693
+ # panel. Mirrors the CLI menu: each entry carries id, summary, status
3694
+ # (current/past/undone) and whether it branches.
3695
+ def api_session_time_machine(session_id, res)
3696
+ agent = time_machine_agent(session_id, res)
3697
+ return unless agent
3698
+
3699
+ history = agent.get_task_history(limit: 20)
3700
+ json_response(res, 200, { tasks: history })
3701
+ end
3702
+
3703
+ # POST /api/sessions/:id/time_machine/switch — body: { task_id: }.
3704
+ # Restores the working tree to the end-of-task state of task_id.
3705
+ def api_session_time_machine_switch(session_id, req, res)
3706
+ agent = time_machine_agent(session_id, res)
3707
+ return unless agent
3708
+
3709
+ body = parse_json_body(req)
3710
+ task_id = body["task_id"].to_i
3711
+ result = agent.switch_to_task(task_id)
3712
+ if result[:success]
3713
+ @session_manager.save(agent.to_session_data(status: :success))
3714
+ broadcast_session_update(session_id)
3715
+ json_response(res, 200, { ok: true, message: result[:message], task_id: result[:task_id] })
3716
+ else
3717
+ json_response(res, 422, { ok: false, error: result[:message] })
3718
+ end
3719
+ end
3720
+
3721
+ # GET /api/sessions/:id/time_machine/:task_id/diff
3722
+ # Without ?path: returns the file list this task touched.
3723
+ # With ?path=<rel>: returns the unified diff of that file.
3724
+ def api_session_time_machine_diff(session_id, task_id, req, res)
3725
+ agent = time_machine_agent(session_id, res)
3726
+ return unless agent
3727
+
3728
+ rel = req.query["path"].to_s
3729
+ if rel.empty?
3730
+ json_response(res, 200, { ok: true, task_id: task_id, files: agent.task_diff_files(task_id) })
3731
+ else
3732
+ diff = agent.task_file_diff(task_id, rel)
3733
+ if diff.nil?
3734
+ json_response(res, 404, { ok: false, error: "No diff for #{rel}" })
3735
+ else
3736
+ json_response(res, 200, { ok: true, task_id: task_id }.merge(diff))
3737
+ end
3738
+ end
3739
+ end
3740
+
3741
+ # GET /api/sessions/:id/time_machine/:task_id/restore_preview
3742
+ # Returns the file-level effect of switching back to this task without
3743
+ # actually performing the switch. Lets the UI render an honest
3744
+ # confirmation listing the files that would be overwritten/created/deleted.
3745
+ def api_session_time_machine_restore_preview(session_id, task_id, res)
3746
+ agent = time_machine_agent(session_id, res)
3747
+ return unless agent
3748
+
3749
+ changes = agent.preview_restore_to_task(task_id)
3750
+ json_response(res, 200, { ok: true, task_id: task_id, changes: changes })
3751
+ end
3752
+
3753
+ # Resolve a session's agent for time-machine ops; writes the error
3754
+ # response and returns nil on failure.
3755
+ private def time_machine_agent(session_id, res)
3756
+ unless @registry.ensure(session_id)
3757
+ json_response(res, 404, { error: "Session not found" })
3758
+ return nil
3759
+ end
3760
+ session = @registry.get(session_id)
3761
+ agent = session && session[:agent]
3762
+ unless agent
3763
+ json_response(res, 404, { error: "Session not found" })
3764
+ return nil
3765
+ end
3766
+ agent
3767
+ end
3768
+
3769
+ # Resolve a session's working_dir for git ops; writes the error response
3770
+ # and returns nil on any failure.
3771
+ private def git_session_dir(session_id, res)
3772
+ unless @registry.ensure(session_id)
3773
+ json_response(res, 404, { error: "Session not found" })
3774
+ return nil
3775
+ end
3776
+ session = @registry.get(session_id)
3777
+ agent = session && session[:agent]
3778
+ unless agent
3779
+ json_response(res, 404, { error: "Session not found" })
3780
+ return nil
3781
+ end
3782
+ dir = File.expand_path(agent.working_dir.to_s)
3783
+ unless Dir.exist?(dir)
3784
+ json_response(res, 404, { error: "Working directory not found" })
3785
+ return nil
3786
+ end
3787
+ dir
3788
+ end
3373
3789
  # Lists one directory level inside the session's working_dir (lazy, per-layer).
3374
3790
  # Path traversal outside working_dir is rejected. Noisy dirs are hidden.
3375
3791
  IGNORED_FILE_ENTRIES = %w[.git .svn .hg node_modules .DS_Store .bundle vendor/bundle tmp .sass-cache].freeze
@@ -3430,7 +3846,7 @@ module Clacky
3430
3846
  # Directories first, then files; both case-insensitive alphabetical.
3431
3847
  items.sort_by! { |it| [it[:type] == "dir" ? 0 : 1, it[:name].downcase] }
3432
3848
 
3433
- json_response(res, 200, { root: display_root, path: rel, entries: items })
3849
+ json_response(res, 200, { root: display_root, path: rel, home: Dir.home, default: default_working_dir, entries: items })
3434
3850
  rescue StandardError => e
3435
3851
  json_response(res, 500, { error: e.message })
3436
3852
  end
@@ -3468,11 +3884,56 @@ module Clacky
3468
3884
  end
3469
3885
  items.sort_by! { |it| it[:name].downcase }
3470
3886
 
3471
- json_response(res, 200, { root: target, path: target, parent: File.dirname(target), home: Dir.home, entries: items })
3887
+ json_response(res, 200, { root: target, path: target, parent: File.dirname(target), home: Dir.home, default: default_working_dir, entries: items })
3888
+ rescue StandardError => e
3889
+ json_response(res, 500, { error: e.message })
3890
+ end
3891
+
3892
+ # ── Directory mutation API used by the path picker ─────────────────
3893
+ # Validate a folder name supplied by the picker UI:
3894
+ # non-empty, no path separators, not "."/"..", short-ish.
3895
+ private def picker_valid_name?(name)
3896
+ return false if name.nil?
3897
+ return false if name.empty? || name.length > 255
3898
+ return false if name == "." || name == ".."
3899
+ # Reject path separators (forward slash and backslash).
3900
+ return false if name.match?(%r{[/\\]})
3901
+ true
3902
+ end
3903
+
3904
+ # POST /api/dirs/mkdir
3905
+ # Body: { parent: "/abs/parent", name: "New Folder" }
3906
+ def api_dirs_mkdir(req, res)
3907
+ body = parse_json_body(req)
3908
+ parent = body["parent"].to_s
3909
+ name = body["name"].to_s.strip
3910
+
3911
+ return json_response(res, 422, { error: "parent must be an absolute path" }) unless parent.start_with?("/")
3912
+ return json_response(res, 422, { error: "name is invalid" }) unless picker_valid_name?(name)
3913
+
3914
+ parent = File.expand_path(parent)
3915
+ return json_response(res, 404, { error: "Parent directory not found" }) unless Dir.exist?(parent)
3916
+
3917
+ target = File.join(parent, name)
3918
+ return json_response(res, 422, { error: "Already exists" }) if File.exist?(target)
3919
+
3920
+ FileUtils.mkdir_p(target)
3921
+ json_response(res, 200, { ok: true, path: target, name: name })
3472
3922
  rescue StandardError => e
3473
3923
  json_response(res, 500, { error: e.message })
3474
3924
  end
3475
3925
 
3926
+ # NOTE: there is NO PATCH /api/dirs/rename endpoint.
3927
+ # Directory rename was intentionally removed from the picker —
3928
+ # too dangerous for a one-click UI affordance (renaming an in-use
3929
+ # workspace mid-session can break tasks, sessions, MCP configs, …).
3930
+ # Use the terminal for that.
3931
+
3932
+ # NOTE: there is NO DELETE /api/dirs/delete endpoint.
3933
+ # Directory deletion was intentionally removed from the picker —
3934
+ # too dangerous for a one-click UI affordance, even with a trash
3935
+ # bucket fallback. Use the terminal (safe_rm) for that.
3936
+
3476
3937
  # Body: { enabled: true/false }
3477
3938
  def api_toggle_skill(name, req, res)
3478
3939
  body = parse_json_body(req)
@@ -3489,6 +3950,47 @@ module Clacky
3489
3950
  json_response(res, 422, { error: e.message })
3490
3951
  end
3491
3952
 
3953
+ private def api_skill_content_get(name, res)
3954
+ @skill_loader.load_all
3955
+ skill = @skill_loader[name]
3956
+ return json_response(res, 404, { ok: false, error: "Skill not found: #{name}" }) unless skill
3957
+
3958
+ skill_md = File.join(skill.directory.to_s, "SKILL.md")
3959
+ unless File.exist?(skill_md)
3960
+ return json_response(res, 404, { ok: false, error: "SKILL.md not found" })
3961
+ end
3962
+
3963
+ json_response(res, 200, {
3964
+ ok: true,
3965
+ name: skill.identifier,
3966
+ content: File.read(skill_md),
3967
+ path: skill_md
3968
+ })
3969
+ end
3970
+
3971
+ private def api_skill_content_update(name, req, res)
3972
+ @skill_loader.load_all
3973
+ skill = @skill_loader[name]
3974
+ return json_response(res, 404, { ok: false, error: "Skill not found: #{name}" }) unless skill
3975
+
3976
+ if skill.source_path.nil? || @skill_loader.loaded_from[skill.identifier] == :default
3977
+ return json_response(res, 403, { ok: false, error: "System skills cannot be edited" })
3978
+ end
3979
+
3980
+ data = parse_json_body(req)
3981
+ content = data["content"].to_s
3982
+ skill_md = File.join(skill.directory.to_s, "SKILL.md")
3983
+ unless File.exist?(skill_md)
3984
+ return json_response(res, 404, { ok: false, error: "SKILL.md not found" })
3985
+ end
3986
+
3987
+ File.write(skill_md, content)
3988
+ @skill_loader.load_all
3989
+ json_response(res, 200, { ok: true, name: skill.identifier })
3990
+ rescue StandardError => e
3991
+ json_response(res, 500, { ok: false, error: e.message })
3992
+ end
3993
+
3492
3994
  private def api_delete_skill(name, res)
3493
3995
  skill = @skill_loader[name]
3494
3996
  return json_response(res, 404, { error: "Skill not found: #{name}" }) unless skill
@@ -4347,7 +4849,6 @@ module Clacky
4347
4849
  json_response(res, 422, { error: e.message })
4348
4850
  end
4349
4851
 
4350
- # POST /api/config — save updated model list
4351
4852
  # DEPRECATED: this endpoint previously accepted the entire models array
4352
4853
  # and replaced @models in place. That design was fragile — any missing
4353
4854
  # or stale field on ANY row could wipe other rows' api_keys. It has
@@ -4372,8 +4873,15 @@ module Clacky
4372
4873
  model = body["model"].to_s.strip
4373
4874
  base_url = body["base_url"].to_s.strip
4374
4875
  api_key = body["api_key"].to_s
4375
- # Masked placeholders are never a valid api_key on creation
4376
- # a brand-new model MUST come with a real key.
4876
+ # When duplicating, the frontend sends source_id so we can inherit the
4877
+ # real key without ever transmitting it back to the client.
4878
+ if api_key.empty? || api_key.include?("****")
4879
+ source_id = body["source_id"].to_s
4880
+ unless source_id.empty?
4881
+ source = @agent_config.models.find { |m| m["id"] == source_id }
4882
+ api_key = source["api_key"].to_s if source
4883
+ end
4884
+ end
4377
4885
  if api_key.empty? || api_key.include?("****")
4378
4886
  return json_response(res, 422, { error: "api_key is required" })
4379
4887
  end
@@ -5085,8 +5593,13 @@ module Clacky
5085
5593
  conn.send_json(type: "error", message: "Session not found: #{session_id}")
5086
5594
  end
5087
5595
 
5596
+ when "edit_message"
5597
+ session_id = msg["session_id"] || conn.session_id
5598
+ handle_edit_message(session_id, msg["content"].to_s, msg["created_at"].to_s)
5599
+
5088
5600
  when "message"
5089
5601
  session_id = msg["session_id"] || conn.session_id
5602
+ Thread.current[:lang] = msg["lang"].to_s.strip.then { |l| l.empty? ? nil : l }
5090
5603
  # Merge legacy images array into files as { data_url:, name:, mime_type: } entries
5091
5604
  raw_images = (msg["images"] || []).map do |data_url|
5092
5605
  { "data_url" => data_url, "name" => "image.jpg", "mime_type" => "image/jpeg" }
@@ -5135,6 +5648,20 @@ module Clacky
5135
5648
 
5136
5649
  # ── Session actions ───────────────────────────────────────────────────────
5137
5650
 
5651
+ def handle_edit_message(session_id, content, created_at)
5652
+ return unless @registry.exist?(session_id)
5653
+
5654
+ agent = nil
5655
+ @registry.with_session(session_id) { |s| agent = s[:agent] }
5656
+ return unless agent
5657
+
5658
+ if agent.history.respond_to?(:truncate_from_created_at) && !created_at.to_s.empty?
5659
+ agent.history.truncate_from_created_at(created_at)
5660
+ end
5661
+
5662
+ handle_user_message(session_id, content)
5663
+ end
5664
+
5138
5665
  def handle_user_message(session_id, content, files = [])
5139
5666
  return unless @registry.exist?(session_id)
5140
5667
 
@@ -5169,13 +5696,15 @@ module Clacky
5169
5696
  end
5170
5697
 
5171
5698
  # Broadcast user message through web_ui so channel subscribers (飞书/企微) receive it.
5699
+ # created_at is shared with agent.run so the history entry and the bubble use the same value.
5700
+ msg_created_at = Time.now.to_f
5172
5701
  web_ui = nil
5173
5702
  @registry.with_session(session_id) { |s| web_ui = s[:ui] }
5174
- web_ui&.show_user_message(content, source: :web)
5703
+ web_ui&.show_user_message(content, created_at: msg_created_at, source: :web)
5175
5704
 
5176
5705
  # File references are now handled inside agent.run — injected as a system_injected
5177
5706
  # message after the user message, so replay_history skips them automatically.
5178
- run_agent_task(session_id, agent) { agent.run(content, files: files) }
5707
+ run_agent_task(session_id, agent) { agent.run(content, files: files, created_at: msg_created_at) }
5179
5708
  end
5180
5709
 
5181
5710
  def deliver_confirmation(session_id, conf_id, result)
@@ -5362,7 +5891,9 @@ module Clacky
5362
5891
 
5363
5892
  broadcast_session_update(session_id)
5364
5893
 
5894
+ locale = Thread.current[:lang]
5365
5895
  thread = Thread.new do
5896
+ Thread.current[:lang] = locale
5366
5897
  task.call
5367
5898
  @registry.update(session_id, status: :idle, error: nil)
5368
5899
  broadcast_session_update(session_id)
@@ -5389,10 +5920,11 @@ module Clacky
5389
5920
  preset = Clacky::Providers::PRESETS[e.provider_id]
5390
5921
  top_up_url = preset && preset["website_url"]
5391
5922
  end
5392
- @registry.update(session_id, status: :error, error: e.message, error_code: code, top_up_url: top_up_url)
5923
+ user_message = e.respond_to?(:display_message) && e.display_message ? e.display_message : e.message
5924
+ @registry.update(session_id, status: :error, error: user_message, error_code: code, top_up_url: top_up_url)
5393
5925
  broadcast_session_update(session_id)
5394
- web_ui&.show_error(e.message, code: code, top_up_url: top_up_url)
5395
- @session_manager.save(agent.to_session_data(status: :error, error_message: e.message))
5926
+ web_ui&.show_error(user_message, code: code, top_up_url: top_up_url)
5927
+ @session_manager.save(agent.to_session_data(status: :error, error_message: user_message))
5396
5928
  end
5397
5929
  @registry.with_session(session_id) { |s| s[:thread] = thread }
5398
5930
  end