openclacky 1.5.13 → 1.5.15

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 (87) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +96 -0
  3. data/lib/clacky/agent/chunk_index.rb +83 -0
  4. data/lib/clacky/agent/history_navigation.rb +239 -0
  5. data/lib/clacky/agent/session_serializer.rb +53 -98
  6. data/lib/clacky/agent.rb +477 -297
  7. data/lib/clacky/agent_config.rb +5 -3
  8. data/lib/clacky/billing/billing_store.rb +2 -2
  9. data/lib/clacky/billing/platform_billing.rb +7 -0
  10. data/lib/clacky/brand_config.rb +27 -19
  11. data/lib/clacky/cli.rb +92 -12
  12. data/lib/clacky/cli_guidance.rb +48 -0
  13. data/lib/clacky/client.rb +2 -2
  14. data/lib/clacky/default_extensions/ext-studio/agents/ext-developer/system_prompt.md +70 -134
  15. data/lib/clacky/default_extensions/ext-studio/api/handler.rb +4 -1
  16. data/lib/clacky/default_extensions/ext-studio/panels/studio/view.js +307 -98
  17. data/lib/clacky/default_extensions/ext-studio/skills/ext-develop/SKILL.md +179 -577
  18. data/lib/clacky/default_extensions/git/panels/git/view.js +34 -14
  19. data/lib/clacky/default_extensions/preview/ext.yml +20 -0
  20. data/lib/clacky/default_extensions/preview/panels/preview/view.js +475 -0
  21. data/lib/clacky/default_extensions/time_machine/panels/time_machine/view.js +1 -10
  22. data/lib/clacky/extension/api_extension.rb +18 -6
  23. data/lib/clacky/extension/verifier.rb +1 -1
  24. data/lib/clacky/json_ui_controller.rb +4 -2
  25. data/lib/clacky/media/openai_compat.rb +70 -30
  26. data/lib/clacky/message_format/bedrock.rb +6 -1
  27. data/lib/clacky/message_format/open_ai.rb +22 -5
  28. data/lib/clacky/message_format/open_ai_responses.rb +7 -3
  29. data/lib/clacky/plain_ui_controller.rb +1 -1
  30. data/lib/clacky/prompts/base.md +1 -1
  31. data/lib/clacky/providers.rb +44 -13
  32. data/lib/clacky/rich_ui/components/composer_guidance.rb +49 -0
  33. data/lib/clacky/rich_ui/rich_ui_controller.rb +24 -4
  34. data/lib/clacky/rich_ui/shell/rich_agent_shell.rb +13 -0
  35. data/lib/clacky/search_config.rb +3 -3
  36. data/lib/clacky/server/channel/channel_manager.rb +25 -7
  37. data/lib/clacky/server/channel/channel_ui_controller.rb +1 -1
  38. data/lib/clacky/server/dir_picker.rb +67 -4
  39. data/lib/clacky/server/git_panel.rb +10 -2
  40. data/lib/clacky/server/http_server.rb +391 -75
  41. data/lib/clacky/server/preview.rb +351 -0
  42. data/lib/clacky/server/session_registry.rb +4 -0
  43. data/lib/clacky/server/web_ui_controller.rb +10 -5
  44. data/lib/clacky/session_manager.rb +52 -1
  45. data/lib/clacky/skill.rb +48 -0
  46. data/lib/clacky/tools/browser.rb +176 -19
  47. data/lib/clacky/tools/web_search.rb +91 -8
  48. data/lib/clacky/ui2/components/command_suggestions.rb +3 -1
  49. data/lib/clacky/ui2/components/input_area.rb +37 -4
  50. data/lib/clacky/ui2/components/modal_component.rb +35 -6
  51. data/lib/clacky/ui2/screen_buffer.rb +1 -0
  52. data/lib/clacky/ui2/ui_controller.rb +72 -12
  53. data/lib/clacky/ui_interface.rb +1 -1
  54. data/lib/clacky/utils/file_processor.rb +19 -7
  55. data/lib/clacky/utils/mac_app_detector.rb +186 -0
  56. data/lib/clacky/utils/model_pricing.rb +118 -68
  57. data/lib/clacky/utils/windows_app_detector.rb +334 -0
  58. data/lib/clacky/version.rb +1 -1
  59. data/lib/clacky/web/app.css +998 -168
  60. data/lib/clacky/web/app.js +19 -0
  61. data/lib/clacky/web/components/chat-navigator.js +489 -202
  62. data/lib/clacky/web/components/code-editor.js +191 -5
  63. data/lib/clacky/web/components/composer.js +3 -1
  64. data/lib/clacky/web/components/datepicker.js +19 -19
  65. data/lib/clacky/web/components/mentions.js +11 -14
  66. data/lib/clacky/web/components/model-picker.js +66 -23
  67. data/lib/clacky/web/components/quote-select.js +341 -0
  68. data/lib/clacky/web/core/aside.js +157 -8
  69. data/lib/clacky/web/core/ext.js +16 -0
  70. data/lib/clacky/web/features/billing/view.js +75 -21
  71. data/lib/clacky/web/features/extensions/store.js +4 -1
  72. data/lib/clacky/web/features/model-tester/store.js +37 -4
  73. data/lib/clacky/web/features/skills/store.js +18 -1
  74. data/lib/clacky/web/features/skills/view.js +128 -7
  75. data/lib/clacky/web/features/workspace/store.js +80 -7
  76. data/lib/clacky/web/features/workspace/view.js +474 -43
  77. data/lib/clacky/web/i18n.js +158 -23
  78. data/lib/clacky/web/index.html +57 -29
  79. data/lib/clacky/web/sessions.js +517 -127
  80. data/lib/clacky/web/settings.js +155 -6
  81. data/lib/clacky/web/utils.js +34 -0
  82. data/lib/clacky/web/vendor/codemirror/codemirror.min.js +24 -19
  83. data/lib/clacky/web/vendor/codemirror/entry.js +130 -0
  84. data/lib/clacky/web/vendor/codemirror/package.json +29 -0
  85. data/lib/clacky/web/ws-dispatcher.js +161 -4
  86. data/lib/clacky.rb +2 -0
  87. metadata +13 -1
@@ -20,6 +20,7 @@ require_relative "session_registry"
20
20
  require_relative "project_manager"
21
21
  require_relative "git_panel"
22
22
  require_relative "dir_picker"
23
+ require_relative "preview"
23
24
  require_relative "web_ui_controller"
24
25
  require_relative "model_prices"
25
26
  require_relative "scheduler"
@@ -102,7 +103,7 @@ module Clacky
102
103
 
103
104
  # Rewrite local image paths to /api/local-image proxy URLs for browser rendering
104
105
  rewritten = Utils::FileProcessor.rewrite_local_image_urls(content.to_s)
105
- ev = { type: "assistant_message", session_id: @session_id, content: rewritten }
106
+ ev = { type: "assistant_message", session_id: @session_id, content: rewritten, interim: interim }
106
107
  ev[:created_at] = created_at if created_at
107
108
  @events << stamp(ev)
108
109
  end
@@ -124,8 +125,10 @@ module Clacky
124
125
  nil
125
126
  end
126
127
 
127
- def show_tool_result(result)
128
- @events << stamp({ type: "tool_result", session_id: @session_id, result: result })
128
+ def show_tool_result(result, ui: nil)
129
+ event = { type: "tool_result", session_id: @session_id, result: result }
130
+ event[:ui] = ui if ui
131
+ @events << stamp(event)
129
132
  end
130
133
 
131
134
  def show_token_usage(token_data)
@@ -168,6 +171,7 @@ module Clacky
168
171
  # GET /** → static files served from lib/clacky/web/ directory
169
172
  class HttpServer
170
173
  include DirPicker
174
+ include Preview
171
175
 
172
176
  WEB_ROOT = File.expand_path("../web", __dir__)
173
177
  # How long shutdown waits for each agent thread to unwind before falling
@@ -496,6 +500,12 @@ module Clacky
496
500
  self.send(:serve_agent_avatar, req, res)
497
501
  elsif req.path.start_with?("/ext_ui/")
498
502
  self.send(:serve_ext_ui, req, res)
503
+ elsif req.path.start_with?("/preview/p/")
504
+ self.send(:serve_preview_proxy, req, res)
505
+ elsif req.path.start_with?("/preview/f/")
506
+ self.send(:serve_preview_local, req, res)
507
+ elsif req.path == "/preview" || req.path.start_with?("/preview/")
508
+ self.send(:serve_preview, req, res)
499
509
  else
500
510
  file_handler.service(req, res)
501
511
  res["Cache-Control"] = "no-store"
@@ -661,6 +671,7 @@ module Clacky
661
671
  when ["PATCH", "/api/config/settings"] then api_update_settings(req, res)
662
672
  when ["POST", "/api/config/models"] then api_add_model(req, res)
663
673
  when ["POST", "/api/config/test"] then api_test_config(req, res)
674
+ when ["POST", "/api/config/models/list"] then api_list_models(req, res)
664
675
  when ["POST", "/api/config/reload"] then api_config_reload(req, res)
665
676
  when ["POST", "/api/access_key/reload"] then api_access_key_reload(req, res)
666
677
  when ["POST", "/api/config/media/test"] then api_test_media_config(req, res)
@@ -691,7 +702,7 @@ module Clacky
691
702
  when ["POST", "/api/onboard/skip-soul"] then api_onboard_skip_soul(req, res)
692
703
  when ["GET", "/api/store/skills"] then api_store_skills(res)
693
704
  when ["GET", "/api/store/extensions"] then api_store_extensions(req, res)
694
- when ["GET", "/api/store/extensions/brand"] then api_store_extensions_brand(res)
705
+ when ["GET", "/api/store/extensions/brand"] then api_store_extensions_brand(req, res)
695
706
  when ["GET", "/api/store/extensions/system"] then api_store_extensions_system(res)
696
707
  when ["GET", "/api/store/extensions/installed"] then api_store_extensions_installed(res)
697
708
  when ["GET", "/api/store/extension"] then api_store_extension_detail(req, res)
@@ -722,6 +733,9 @@ module Clacky
722
733
  when ["POST", "/api/tool/browser"] then api_tool_browser(req, res)
723
734
  when ["POST", "/api/upload"] then api_upload_file(req, res)
724
735
  when ["POST", "/api/file-action"] then api_file_action(req, res)
736
+ when ["GET", "/api/file/apps"] then api_file_apps(req, res)
737
+ when ["GET", "/api/file/default-app"] then api_file_default_app(req, res)
738
+ when ["GET", "/api/app-icon"] then api_app_icon(req, res)
725
739
  when ["GET", "/api/local-image"] then api_serve_local_image(req, res)
726
740
  when ["POST", "/api/media/image"] then api_media_image(req, res)
727
741
  when ["POST", "/api/media/video"] then api_media_video(req, res)
@@ -1034,7 +1048,7 @@ module Clacky
1034
1048
  end
1035
1049
  end
1036
1050
 
1037
- broadcast_session_update(session_id)
1051
+ broadcast_session_update(session_id, created: true)
1038
1052
  summary = @registry.session_summary(session_id)
1039
1053
  json_response(res, 201, { session: summary })
1040
1054
  end
@@ -2025,7 +2039,14 @@ module Clacky
2025
2039
  json_response(res, 200, { ok: false, message: e.message })
2026
2040
  end
2027
2041
 
2028
- private def preflight_media_endpoint(base_url:, api_key:, model:)
2042
+ # Shared model-list fetch: GET <base_url>/models with Bearer auth. Used
2043
+ # by the media preflight (POST /api/config/media/test) and model
2044
+ # discovery (POST /api/config/models/list).
2045
+ #
2046
+ # Returns { ok: true, ids: [...] } when the endpoint answered — ids is []
2047
+ # when the body exposes no model ids — or { ok: false, message: "..." }
2048
+ # on network / auth / HTTP failures.
2049
+ private def fetch_remote_model_ids(base_url:, api_key:)
2029
2050
  url = "#{base_url.chomp("/")}/models"
2030
2051
  conn = Faraday.new(url: url) do |f|
2031
2052
  f.options.timeout = 10
@@ -2054,15 +2075,27 @@ module Clacky
2054
2075
  end
2055
2076
 
2056
2077
  body = JSON.parse(response.body) rescue nil
2078
+ # Elements are usually {"id"=>...} hashes, but some gateways serve
2079
+ # plain string arrays; `String#[]` would be a substring search there,
2080
+ # so dispatch on type and drop entries without a usable id.
2057
2081
  ids =
2058
2082
  if body.is_a?(Hash) && body["data"].is_a?(Array)
2059
- body["data"].map { |m| m["id"].to_s }
2083
+ body["data"].map { |m| m.is_a?(Hash) ? m["id"].to_s : m.to_s }
2060
2084
  elsif body.is_a?(Array)
2061
- body.map { |m| m["id"].to_s }
2085
+ body.map { |m| m.is_a?(Hash) ? m["id"].to_s : m.to_s }
2062
2086
  else
2063
2087
  []
2064
2088
  end
2089
+ ids = ids.reject(&:empty?)
2090
+
2091
+ { ok: true, ids: ids }
2092
+ end
2093
+
2094
+ private def preflight_media_endpoint(base_url:, api_key:, model:)
2095
+ fetch = fetch_remote_model_ids(base_url: base_url, api_key: api_key)
2096
+ return { ok: false, message: fetch[:message] } unless fetch[:ok]
2065
2097
 
2098
+ ids = fetch[:ids]
2066
2099
  if ids.empty?
2067
2100
  return { ok: true, message: "Connected (model list unavailable; cannot verify model id)" }
2068
2101
  end
@@ -2608,9 +2641,9 @@ module Clacky
2608
2641
  # Refresh skill_loader with the now-activated brand config so brand
2609
2642
  # skills are loadable from this point forward (e.g. after sync).
2610
2643
  @skill_loader = Clacky::SkillLoader.new(working_dir: nil, brand_config: brand)
2611
- # Install all brand skills in the background on first activation so
2612
- # they are available immediately without manual user action.
2613
- brand.sync_brand_skills_async!(install_new: true)
2644
+ # Sync brand skills in the background so they are available without
2645
+ # manual user action.
2646
+ brand.sync_brand_skills_async!
2614
2647
  json_response(res, 200, {
2615
2648
  ok: true,
2616
2649
  product_name: result[:product_name] || brand.product_name,
@@ -2699,7 +2732,7 @@ module Clacky
2699
2732
  # activated brand license. Returns extensions belonging to this brand via
2700
2733
  # BrandConfig#fetch_brand_extensions!.
2701
2734
  # Returns 403 when the license is not activated.
2702
- def api_store_extensions_brand(res)
2735
+ def api_store_extensions_brand(req, res)
2703
2736
  brand = Clacky::BrandConfig.load
2704
2737
 
2705
2738
  unless brand.activated?
@@ -2707,7 +2740,9 @@ module Clacky
2707
2740
  return
2708
2741
  end
2709
2742
 
2710
- result = brand.fetch_brand_extensions!
2743
+ sort = req.query["sort"].to_s
2744
+ sort = "downloads" unless %w[downloads newest updated].include?(sort)
2745
+ result = brand.fetch_brand_extensions!(sort: sort)
2711
2746
 
2712
2747
  if result[:success]
2713
2748
  installed = installed_extension_containers
@@ -3471,7 +3506,7 @@ module Clacky
3471
3506
  keys = []
3472
3507
  @agent_config.models.each do |m|
3473
3508
  next unless m.is_a?(Hash)
3474
- next unless @agent_config.provider_id_for(m) == "openclacky"
3509
+ next unless @agent_config.provider_id_for(m) == Clacky::Providers::OPENCLACKY_ID
3475
3510
 
3476
3511
  key = m["api_key"].to_s.strip
3477
3512
  keys << key unless key.empty?
@@ -4423,12 +4458,87 @@ module Clacky
4423
4458
  json_response(res, 500, { ok: false, error: e.message })
4424
4459
  end
4425
4460
 
4461
+ # GET /api/file/apps?path=/path/to/file.pptx
4462
+ # Lists installed applications that declare support for the file's
4463
+ # extension, so the Web UI can offer "open with" choices for formats it
4464
+ # cannot preview inline. macOS uses MacAppDetector, WSL uses
4465
+ # WindowsAppDetector; other OSes answer with an empty list.
4466
+ def api_file_apps(req, res)
4467
+ raw_path = URI.decode_www_form(req.query_string.to_s).to_h["path"].to_s
4468
+ return json_response(res, 400, { error: "path is required" }) if raw_path.empty?
4469
+
4470
+ path = Utils::EnvironmentDetector.resolve_local_path(raw_path)
4471
+ ext = File.extname(path).downcase.sub(/\A\./, "")
4472
+
4473
+ apps = if ext.empty?
4474
+ []
4475
+ elsif Utils::EnvironmentDetector.os_type == :wsl
4476
+ Utils::WindowsAppDetector.apps_for_ext(ext)
4477
+ else
4478
+ Utils::MacAppDetector.apps_for_ext(ext)
4479
+ end
4480
+ json_response(res, 200, { ok: true, apps: apps })
4481
+ rescue => e
4482
+ json_response(res, 500, { ok: false, error: e.message })
4483
+ end
4484
+
4485
+ # GET /api/file/default-app?path=...
4486
+ # Returns the application the OS would use to open the file.
4487
+ def api_file_default_app(req, res)
4488
+ raw_path = URI.decode_www_form(req.query_string.to_s).to_h["path"].to_s
4489
+ return json_response(res, 400, { error: "path is required" }) if raw_path.empty?
4490
+
4491
+ path = Utils::EnvironmentDetector.resolve_local_path(raw_path)
4492
+ app = if File.extname(path).empty?
4493
+ nil
4494
+ elsif Utils::EnvironmentDetector.os_type == :wsl
4495
+ Utils::WindowsAppDetector.default_app_for(path)
4496
+ else
4497
+ Utils::MacAppDetector.default_app_for(path)
4498
+ end
4499
+ json_response(res, 200, { ok: true, app: app })
4500
+ rescue => e
4501
+ json_response(res, 500, { ok: false, error: e.message })
4502
+ end
4503
+
4504
+ # GET /api/app-icon?path=/Applications/Keynote.app (macOS)
4505
+ # GET /api/app-icon?path=C:\...\app.exe (WSL)
4506
+ # Serves an application's icon as a PNG. macOS converts the bundle .icns
4507
+ # via sips; WSL extracts the .exe associated icon via PowerShell. Both are
4508
+ # cached on disk.
4509
+ def api_app_icon(req, res)
4510
+ raw_path = URI.decode_www_form(req.query_string.to_s).to_h["path"].to_s
4511
+ return json_response(res, 400, { error: "path is required" }) if raw_path.empty?
4512
+
4513
+ png = if Utils::EnvironmentDetector.os_type == :wsl
4514
+ Utils::WindowsAppDetector.icon_png(raw_path)
4515
+ else
4516
+ Utils::MacAppDetector.icon_png(Utils::EnvironmentDetector.resolve_local_path(raw_path))
4517
+ end
4518
+ unless png
4519
+ # Never let a failed lookup stick in the browser cache — it would keep
4520
+ # serving a 404 even after the detector is fixed/updated.
4521
+ res["Cache-Control"] = "no-store"
4522
+ return json_response(res, 404, { error: "icon not found" })
4523
+ end
4524
+
4525
+ res.status = 200
4526
+ res["Content-Type"] = "image/png"
4527
+ res["Cache-Control"] = "public, max-age=604800"
4528
+ res["Content-Length"] = File.size(png).to_s
4529
+ res.body = File.binread(png)
4530
+ rescue => e
4531
+ json_response(res, 500, { error: e.message })
4532
+ end
4533
+
4426
4534
  # POST /api/file-action
4427
4535
  # Unified file action endpoint — open locally or download.
4428
- # Body: { path: String, action: "open" | "download" | "save" }
4429
- # open: opens the file with the OS default handler (local deployments).
4430
- # download: returns the file as a download (remote deployments).
4431
- # save: writes content back to the file. Body must include { content: String }.
4536
+ # Body: { path: String, action: "open" | "open_with" | "download" | "save" }
4537
+ # open: opens the file with the OS default handler (local deployments).
4538
+ # open_with: opens the file with the app named in body.app (the app must
4539
+ # be one returned by /api/file/apps).
4540
+ # download: returns the file as a download (remote deployments).
4541
+ # save: writes content back to the file. Body must include { content: String }.
4432
4542
  def api_file_action(req, res)
4433
4543
  body = parse_json_body(req)
4434
4544
  path = body["path"]
@@ -4452,6 +4562,15 @@ module Clacky
4452
4562
  result = Utils::EnvironmentDetector.open_file(linux_path)
4453
4563
  return json_response(res, 501, { error: "unsupported OS" }) if result.nil?
4454
4564
  json_response(res, 200, { ok: true })
4565
+ when "open_with"
4566
+ result = if Utils::EnvironmentDetector.os_type == :wsl
4567
+ Utils::WindowsAppDetector.open_with(linux_path, body["app"].to_s)
4568
+ else
4569
+ Utils::MacAppDetector.open_with(linux_path, body["app"].to_s)
4570
+ end
4571
+ return json_response(res, 400, { error: "unknown application" }) if result.nil?
4572
+ return json_response(res, 500, { error: "failed to open with application" }) unless result
4573
+ json_response(res, 200, { ok: true })
4455
4574
  when "reveal"
4456
4575
  Utils::EnvironmentDetector.reveal_file(linux_path)
4457
4576
  json_response(res, 200, { ok: true })
@@ -4467,7 +4586,7 @@ module Clacky
4467
4586
  File.write(linux_path, content, encoding: "UTF-8")
4468
4587
  json_response(res, 200, { ok: true })
4469
4588
  else
4470
- json_response(res, 400, { error: "invalid action. Must be 'open', 'reveal', 'download', 'display-path' or 'save'" })
4589
+ json_response(res, 400, { error: "invalid action. Must be 'open', 'open_with', 'reveal', 'download', 'display-path' or 'save'" })
4471
4590
  end
4472
4591
  rescue => e
4473
4592
  json_response(res, 500, { ok: false, error: e.message })
@@ -4870,6 +4989,7 @@ module Clacky
4870
4989
 
4871
4990
  session_id = build_session(name: session_name, working_dir: working_dir, permission_mode: :auto_approve)
4872
4991
  @registry.update(session_id, pending_task: prompt, pending_working_dir: working_dir)
4992
+ broadcast_session_update(session_id, created: true)
4873
4993
 
4874
4994
  json_response(res, 202, { ok: true, session: @registry.session_summary(session_id) })
4875
4995
  rescue => e
@@ -5226,7 +5346,10 @@ module Clacky
5226
5346
  next unless File.exist?(full)
5227
5347
  {
5228
5348
  name: name,
5229
- path: "#{rel}/#{name}".gsub(%r{/+}, "/"),
5349
+ # Root-level entries must NOT get a leading "/" — the client treats
5350
+ # slash-prefixed paths as absolute and passes them to file-action
5351
+ # verbatim, which then fails to find "<root>/<name>".
5352
+ path: rel.empty? ? name : "#{rel}/#{name}".gsub(%r{/+}, "/"),
5230
5353
  type: is_dir ? "dir" : "file",
5231
5354
  size: is_dir ? nil : (File.size(full) rescue nil)
5232
5355
  }
@@ -5358,11 +5481,22 @@ module Clacky
5358
5481
  return json_response(res, 404, { ok: false, error: "SKILL.md not found" })
5359
5482
  end
5360
5483
 
5484
+ raw = File.read(skill_md)
5485
+ frontmatter_match = raw.match(/\A---\n.*?\n---[ \t]*\n?/m)
5486
+ body = frontmatter_match ? raw[frontmatter_match.end(0)..].sub(/\A\n+/, "") : raw
5487
+
5361
5488
  json_response(res, 200, {
5362
5489
  ok: true,
5363
5490
  name: skill.identifier,
5364
- content: File.read(skill_md),
5365
- path: skill_md
5491
+ content: raw,
5492
+ path: skill_md,
5493
+ fields: {
5494
+ name: skill.identifier,
5495
+ name_zh: skill.name_zh.to_s,
5496
+ description: skill.description.to_s,
5497
+ description_zh: skill.description_zh.to_s,
5498
+ body: body
5499
+ }
5366
5500
  })
5367
5501
  end
5368
5502
 
@@ -5375,13 +5509,19 @@ module Clacky
5375
5509
  return json_response(res, 403, { ok: false, error: "System skills cannot be edited" })
5376
5510
  end
5377
5511
 
5378
- data = parse_json_body(req)
5379
- content = data["content"].to_s
5512
+ data = parse_json_body(req)
5380
5513
  skill_md = File.join(skill.directory.to_s, "SKILL.md")
5381
5514
  unless File.exist?(skill_md)
5382
5515
  return json_response(res, 404, { ok: false, error: "SKILL.md not found" })
5383
5516
  end
5384
5517
 
5518
+ fields = data["fields"]
5519
+ content = if fields.is_a?(Hash)
5520
+ Clacky::Skill.update_frontmatter_fields(File.read(skill_md), fields)
5521
+ else
5522
+ data["content"].to_s
5523
+ end
5524
+
5385
5525
  File.write(skill_md, content)
5386
5526
  @skill_loader.load_all
5387
5527
  json_response(res, 200, { ok: true, name: skill.identifier })
@@ -6157,18 +6297,25 @@ module Clacky
6157
6297
  # GET /api/config — return current model configurations
6158
6298
  def api_get_config(req, res)
6159
6299
  models = @agent_config.models.map.with_index do |m, i|
6300
+ # Provider is resolved (stored id → base_url → api_key hint) so the
6301
+ # model picker can label every row with the service it actually runs
6302
+ # through; provider_id alone is absent on most stored entries.
6303
+ provider_id = @agent_config.provider_id_for(m)
6304
+ provider = provider_id && Clacky::Providers.get(provider_id)
6160
6305
  {
6161
- id: m["id"], # Stable runtime id — use this for switching
6162
- index: i,
6163
- model: m["model"],
6164
- base_url: m["base_url"],
6165
- api_key_masked: mask_api_key(m["api_key"]),
6166
- anthropic_format: m["anthropic_format"] || false,
6167
- api_format: m["api_format"],
6168
- provider_id: m["provider_id"],
6169
- capabilities: m["capabilities"],
6170
- remark: m["remark"],
6171
- type: m["type"]
6306
+ id: m["id"], # Stable runtime id — use this for switching
6307
+ index: i,
6308
+ model: m["model"],
6309
+ base_url: m["base_url"],
6310
+ api_key_masked: mask_api_key(m["api_key"]),
6311
+ anthropic_format: m["anthropic_format"] || false,
6312
+ api_format: m["api_format"],
6313
+ provider_id: m["provider_id"],
6314
+ provider_name: provider && provider["name"],
6315
+ provider_name_key: provider && provider["name_key"],
6316
+ capabilities: m["capabilities"],
6317
+ remark: m["remark"],
6318
+ type: m["type"]
6172
6319
  }
6173
6320
  end
6174
6321
  # Filter out auto-injected models (lite, derived media) AND media
@@ -6652,25 +6799,28 @@ module Clacky
6652
6799
  json_response(res, 422, { error: e.message })
6653
6800
  end
6654
6801
 
6802
+ # The UI never sends a stored key back in cleartext: editing an existing
6803
+ # model sends the masked placeholder. Resolve the real key by model id,
6804
+ # then by index, then fall back to the current default entry.
6805
+ private def resolve_masked_api_key(body)
6806
+ api_key = body["api_key"].to_s
6807
+ return api_key unless api_key.include?("****")
6808
+
6809
+ model_id = body["id"].to_s
6810
+ entry = nil
6811
+ entry = @agent_config.models.find { |m| m["id"] == model_id } unless model_id.empty?
6812
+ entry = @agent_config.models[body["index"].to_i] if entry.nil? && body.key?("index")
6813
+ entry ||= @agent_config.models[@agent_config.current_model_index]
6814
+ entry ? entry["api_key"].to_s : ""
6815
+ end
6816
+
6655
6817
  # POST /api/config/test — test connection for a single model config
6656
6818
  # Body: { model, base_url, api_key, anthropic_format }
6657
6819
  def api_test_config(req, res)
6658
6820
  body = parse_json_body(req)
6659
6821
  return json_response(res, 400, { error: "Invalid JSON" }) unless body
6660
6822
 
6661
- api_key = body["api_key"].to_s
6662
- if api_key.include?("****")
6663
- model_id = body["id"].to_s
6664
- entry = nil
6665
- if !model_id.empty?
6666
- entry = @agent_config.models.find { |m| m["id"] == model_id }
6667
- end
6668
- if entry.nil? && body.key?("index")
6669
- entry = @agent_config.models[body["index"].to_i]
6670
- end
6671
- entry ||= @agent_config.models[@agent_config.current_model_index]
6672
- api_key = entry ? entry["api_key"].to_s : ""
6673
- end
6823
+ api_key = resolve_masked_api_key(body)
6674
6824
 
6675
6825
  model = body["model"].to_s
6676
6826
  base_url = body["base_url"].to_s
@@ -6695,6 +6845,37 @@ module Clacky
6695
6845
  json_response(res, 200, { ok: false, message: e.message })
6696
6846
  end
6697
6847
 
6848
+ # POST /api/config/models/list — discover the model ids a custom
6849
+ # endpoint serves. Body: { base_url, api_key, api_format }
6850
+ #
6851
+ # The Model field of the "Add Model" modal can't fall back to a preset
6852
+ # list for custom providers, so the frontend calls this to offer the ids
6853
+ # the endpoint actually exposes (issue #559). api_format is accepted for
6854
+ # parity with POST /api/config/test; the fetch itself is the OpenAI-style
6855
+ # Bearer GET /models shared with the media preflight.
6856
+ def api_list_models(req, res)
6857
+ body = parse_json_body(req)
6858
+ return json_response(res, 400, { error: "Invalid JSON" }) unless body
6859
+
6860
+ base_url = body["base_url"].to_s.strip
6861
+ return json_response(res, 422, { error: "base_url is required" }) if base_url.empty?
6862
+
6863
+ api_key = resolve_masked_api_key(body)
6864
+ return json_response(res, 422, { error: "api_key is required" }) if api_key.empty?
6865
+
6866
+ api_format = normalize_api_format(body["api_format"])
6867
+ return json_response(res, 422, { error: "invalid api_format" }) if api_format == :invalid
6868
+
6869
+ result = fetch_remote_model_ids(base_url: base_url, api_key: api_key)
6870
+ return json_response(res, 200, { ok: false, message: result[:message] }) unless result[:ok]
6871
+
6872
+ models = result[:ids]
6873
+ message = models.empty? ? "Endpoint returned no models" : "Found #{models.length} models"
6874
+ json_response(res, 200, { ok: true, models: models, message: message })
6875
+ rescue StandardError => e
6876
+ json_response(res, 200, { ok: false, message: e.message })
6877
+ end
6878
+
6698
6879
  private def try_test_with_base_url(api_key, base_url, model, anthropic_format, api_format)
6699
6880
  result = run_test_connection(api_key, base_url, model, anthropic_format, api_format)
6700
6881
  return [result, base_url] if result[:success]
@@ -6761,15 +6942,47 @@ module Clacky
6761
6942
 
6762
6943
  unless agent
6763
6944
  Clacky::Logger.warn("[messages] agent is nil", session_id: session_id)
6945
+ if query["navigation"] == "1"
6946
+ return json_response(res, 200, { sources: [], total: 0 })
6947
+ end
6948
+ if query["previews"]
6949
+ return json_response(res, 200, { previews: [] })
6950
+ end
6951
+ if query["preview"]
6952
+ return json_response(res, 409, { error: "History location is no longer available" })
6953
+ end
6764
6954
  return json_response(res, 200, { events: [], has_more: false })
6765
6955
  end
6766
6956
 
6767
6957
  # Collect events emitted by replay_history via a lightweight collector UI
6958
+ if query["navigation"] == "1"
6959
+ return json_response(res, 200, agent.history_navigation)
6960
+ end
6961
+ if query["previews"]
6962
+ ids = JSON.parse(query["previews"])
6963
+ raise ArgumentError, "History locations must be an array" unless ids.is_a?(Array)
6964
+
6965
+ return json_response(res, 200, { previews: agent.history_navigation_previews(ids: ids) })
6966
+ end
6967
+ if query["preview"]
6968
+ return json_response(res, 200, agent.history_navigation_preview(id: query["preview"]))
6969
+ end
6768
6970
  collected = []
6769
6971
  collector = HistoryCollector.new(session_id, collected)
6770
- result = agent.replay_history(collector, limit: limit, before: before)
6972
+ if query["window"] == "1"
6973
+ result = agent.replay_history_window(collector, limit: limit, around: query["around"],
6974
+ before_id: query["before_id"], after_id: query["after_id"])
6975
+ ids = result.delete(:round_ids)
6976
+ collected.select { |event| event[:type] == "history_user_message" }.each_with_index do |event, index|
6977
+ event[:round_id] = ids[index]
6978
+ end
6979
+ else
6980
+ result = agent.replay_history(collector, limit: limit, before: before)
6981
+ end
6771
6982
 
6772
- json_response(res, 200, { events: collected, has_more: result[:has_more] })
6983
+ json_response(res, 200, { events: collected }.merge(result))
6984
+ rescue ArgumentError, JSON::ParserError => e
6985
+ json_response(res, 409, { error: e.message })
6773
6986
  end
6774
6987
 
6775
6988
  # ── Project API ───────────────────────────────────────────────────────────
@@ -7168,7 +7381,7 @@ module Clacky
7168
7381
  return json_response(res, 404, { error: "Session not found" }) unless fork_data
7169
7382
 
7170
7383
  fork_id = fork_data[:session_id]
7171
- broadcast_session_update(fork_id)
7384
+ broadcast_session_update(fork_id, created: true)
7172
7385
  json_response(res, 201, { session: @registry.snapshot(fork_id) })
7173
7386
  end
7174
7387
 
@@ -7342,6 +7555,9 @@ module Clacky
7342
7555
  conn.session_id = session_id
7343
7556
  subscribe(session_id, conn)
7344
7557
  conn.send_json(type: "subscribed", session_id: session_id)
7558
+ conn.send_json(type: "input_behavior", value: @agent_config.input_behavior)
7559
+ queued_agent = @registry.get(session_id)&.dig(:agent)
7560
+ conn.send_json(type: "input_queue", session_id: session_id, entries: queued_agent.pending_inputs) if queued_agent
7345
7561
  # Push a fresh snapshot so a reconnecting tab sees the true current
7346
7562
  # status (it may have missed session_update events while offline).
7347
7563
  if (snap = @registry.snapshot(session_id))
@@ -7354,6 +7570,33 @@ module Clacky
7354
7570
  conn.send_json(type: "error", message: "Session not found: #{session_id}")
7355
7571
  end
7356
7572
 
7573
+ when "input_behavior"
7574
+ mode = msg["value"]
7575
+ raise ArgumentError, "Invalid input behavior" unless %w[queue interrupt steer].include?(mode)
7576
+ @agent_config.input_behavior = mode
7577
+ @agent_config.save
7578
+ broadcast_all(type: "input_behavior", value: mode)
7579
+
7580
+ when "edit_pending_input"
7581
+ session_id = msg["session_id"] || conn.session_id
7582
+ @registry.get(session_id)&.dig(:agent)&.edit_pending_input(msg["id"], msg["content"].to_s)
7583
+
7584
+ when "remove_pending_input"
7585
+ session_id = msg["session_id"] || conn.session_id
7586
+ @registry.get(session_id)&.dig(:agent)&.remove_pending_input(msg["id"])
7587
+
7588
+ when "steer_pending_input"
7589
+ session_id = msg["session_id"] || conn.session_id
7590
+ agent = @registry.get(session_id)&.dig(:agent)
7591
+ unless agent&.steer_pending_input(msg["id"], expected_task_id: msg["task_id"])
7592
+ broadcast(session_id, { type: "input_queue_notice", session_id: session_id, key: "chat.input.guidanceRejected" })
7593
+ end
7594
+
7595
+ when "send_pending_input"
7596
+ session_id = msg["session_id"] || conn.session_id
7597
+ Thread.current[:lang] = msg["lang"].to_s.strip.then { |l| l.empty? ? nil : l }
7598
+ send_pending_input(session_id, msg["id"])
7599
+
7357
7600
  when "edit_message"
7358
7601
  session_id = msg["session_id"] || conn.session_id
7359
7602
  handle_edit_message(session_id, msg["content"].to_s, msg["created_at"].to_s)
@@ -7441,24 +7684,60 @@ module Clacky
7441
7684
  handle_user_message(session_id, content)
7442
7685
  end
7443
7686
 
7687
+ private def send_pending_input(session_id, id)
7688
+ session = @registry.get(session_id)
7689
+ agent = session&.dig(:agent)
7690
+ # Claim before interrupting: an already consumed or double-clicked ID is a no-op.
7691
+ entry = agent&.remove_pending_input(id, for_execution: true)
7692
+ return unless entry
7693
+
7694
+ started = false
7695
+ begin
7696
+ if session[:status] == :running
7697
+ interrupt_session(session_id, reason: :replacement)
7698
+ worker = session[:thread]
7699
+ if worker && !worker.join(2)
7700
+ broadcast(session_id, { type: "input_queue_notice", session_id: session_id, key: "chat.input.stoppingDelayed" })
7701
+ return
7702
+ end
7703
+ end
7704
+ started = run_agent_task(session_id, agent) { agent.run_pending_input(entry) }
7705
+ ensure
7706
+ # A concurrency-limit rejection must not discard the user's input.
7707
+ agent.restore_pending_input(entry) unless started
7708
+ end
7709
+ end
7710
+
7444
7711
  def handle_user_message(session_id, content, files = [], references: [])
7445
7712
  return unless @registry.exist?(session_id)
7446
7713
 
7447
7714
  session = @registry.get(session_id)
7448
7715
 
7716
+ mode = @agent_config.input_behavior
7717
+ queued = false
7718
+ @registry.with_session(session_id) do |s|
7719
+ if s[:status] == :running && %w[queue steer].include?(mode)
7720
+ s[:agent].enqueue_input(content, delivery: mode.to_sym, files: files, references_display: references,
7721
+ reference_contexts: build_reference_contexts(references), created_at: Time.now.to_f)
7722
+ queued = true
7723
+ end
7724
+ end
7725
+ return if queued
7726
+
7449
7727
  # If session is running, interrupt it first (mimics CLI behavior)
7450
7728
  if session[:status] == :running
7451
- interrupt_session(session_id)
7729
+ interrupt_session(session_id, reason: :replacement)
7452
7730
 
7453
- # Give the old thread a short window to exit cleanly.
7454
- # In the common case it returns within milliseconds (Thread#raise
7455
- # lands on a tight loop or LLM read). If it can't be reached in
7456
- # time (e.g. blocked in a slow subagent syscall), we proceed anyway:
7457
- # the agent's check_stale! checkpoints will refuse to mutate
7458
- # history once the new thread takes over.
7731
+ # A replacement must not race the old worker on shared history or
7732
+ # tool execution. Retain the input if cooperative shutdown is slow.
7459
7733
  old_thread = nil
7460
7734
  @registry.with_session(session_id) { |s| old_thread = s[:thread] }
7461
- old_thread&.join(2)
7735
+ if old_thread && !old_thread.join(2)
7736
+ session[:agent].enqueue_input(content, files: files, references_display: references,
7737
+ reference_contexts: build_reference_contexts(references), created_at: Time.now.to_f)
7738
+ broadcast(session_id, { type: "input_queue_notice", session_id: session_id, key: "chat.input.stoppingDelayed" })
7739
+ return
7740
+ end
7462
7741
  end
7463
7742
 
7464
7743
  agent = nil
@@ -7495,9 +7774,11 @@ module Clacky
7495
7774
  # processing finishes, which can take seconds. Without the preview here, a
7496
7775
  # page refresh inside that window shows the message without its image (the
7497
7776
  # "need to refresh several times before the image appears" bug).
7777
+ # `steering` tells the frontend to render a bubble it did not add
7778
+ # optimistically in queue/steer mode; it does not select task routing.
7498
7779
  web_ui&.show_user_message(content, created_at: msg_created_at, source: :web, files: Array(files),
7499
7780
  skill_command: skill_command[:found] ? skill_command[:skill_name] : nil,
7500
- skill_command_display: skill_command_display)
7781
+ skill_command_display: skill_command_display, **(%w[queue steer].include?(mode) ? { steering: true } : {}))
7501
7782
 
7502
7783
  # File references are now handled inside agent.run — injected as a system_injected
7503
7784
  # message after the user message, so replay_history skips them automatically.
@@ -7513,6 +7794,7 @@ module Clacky
7513
7794
  next unless ref.is_a?(Hash)
7514
7795
  case ref["type"].to_s
7515
7796
  when "session" then build_session_reference_context(ref)
7797
+ when "quote" then build_quote_reference_context(ref)
7516
7798
  end
7517
7799
  end
7518
7800
  end
@@ -7533,6 +7815,19 @@ module Clacky
7533
7815
  lines.join("\n")
7534
7816
  end
7535
7817
 
7818
+ # A quote reference is a passage the user selected out of an earlier
7819
+ # message. The excerpt is the whole point of the reference, so inline it
7820
+ # verbatim — no file pointer, nothing for the model to go fetch.
7821
+ private def build_quote_reference_context(ref)
7822
+ text = ref["text"].to_s.strip
7823
+ return nil if text.empty?
7824
+
7825
+ label = ref["label"].to_s.strip
7826
+ header = label.empty? ? "[Quoted excerpt from this conversation]" :
7827
+ "[Quoted excerpt from this conversation: #{label}]"
7828
+ [header, text].join("\n")
7829
+ end
7830
+
7536
7831
  def deliver_confirmation(session_id, conf_id, result)
7537
7832
  ui = nil
7538
7833
  @registry.with_session(session_id) { |s| ui = s[:ui] }
@@ -7545,13 +7840,10 @@ module Clacky
7545
7840
  # Faraday reads, but can't reach a thread stuck in a C-extension syscall
7546
7841
  # until that syscall returns. We raise once and return immediately.
7547
7842
  #
7548
- # Correctness of the *takeover* does not depend on the old thread dying
7549
- # promptly: each new task claims a fresh epoch (see run_agent_task), and
7550
- # any status write or UI broadcast from a superseded thread is fenced off
7551
- # by that epoch. A stale thread that lingers in a syscall is harmless — it
7552
- # self-terminates at the next check_stale! checkpoint, or when the syscall
7553
- # returns; either way it can no longer touch the live session.
7554
- def interrupt_session(session_id)
7843
+ # Replacement callers must wait for the old worker to exit before
7844
+ # starting more work. Epoch fencing remains a secondary guard against
7845
+ # stale status updates; it cannot undo a tool's filesystem side effects.
7846
+ def interrupt_session(session_id, reason: :user)
7555
7847
  @registry.with_session(session_id) do |s|
7556
7848
  s[:idle_timer]&.cancel
7557
7849
  thread = s[:thread]
@@ -7559,6 +7851,7 @@ module Clacky
7559
7851
 
7560
7852
  Clacky::Logger.info("[interrupt] session=#{session_id} raise")
7561
7853
  begin
7854
+ thread[:interrupt_reason] = reason
7562
7855
  thread.raise(Clacky::AgentInterrupted, "Interrupted by user")
7563
7856
  rescue ThreadError => e
7564
7857
  Clacky::Logger.warn("[interrupt] raise failed: #{e.message}")
@@ -7695,10 +7988,27 @@ module Clacky
7695
7988
  Thread.current[:lang] = locale
7696
7989
  Thread.current[:task_epoch] = epoch
7697
7990
  run_result = task.call
7698
- awaiting = run_result.is_a?(Hash) && run_result[:awaiting_user_feedback]
7699
- next unless @registry.update_if_epoch(session_id, epoch,
7700
- status: awaiting ? :awaiting_feedback : :idle,
7701
- error: nil)
7991
+ awaiting = false
7992
+ owns_epoch = true
7993
+ loop do
7994
+ owns_epoch = @registry.current_epoch(session_id).to_i == epoch.to_i
7995
+ break unless owns_epoch
7996
+ @session_manager.save(agent.to_session_data(status: :success, updated_at: Time.now))
7997
+ pending = nil
7998
+ @registry.with_session(session_id) do |s|
7999
+ owns_epoch = s[:epoch].to_i == epoch.to_i
8000
+ next unless owns_epoch
8001
+ awaiting = run_result.is_a?(Hash) && run_result[:awaiting_user_feedback]
8002
+ pending = agent.take_pending_input if Agent.task_completed?(run_result)
8003
+ unless pending
8004
+ s[:status] = awaiting ? :awaiting_feedback : :idle
8005
+ s[:error] = nil
8006
+ end
8007
+ end
8008
+ break unless owns_epoch && pending
8009
+ run_result = agent.run_pending_input(pending)
8010
+ end
8011
+ next unless owns_epoch
7702
8012
  broadcast_session_update(session_id)
7703
8013
  # Transient global signal for the optional task-complete sound. Sent to
7704
8014
  # all clients (broadcast_all) so a browser viewing another session — or
@@ -7721,7 +8031,8 @@ module Clacky
7721
8031
  # or push UI events, they belong to the new epoch now.
7722
8032
  next unless @registry.update_if_epoch(session_id, epoch, status: :idle)
7723
8033
  broadcast_session_update(session_id)
7724
- broadcast(session_id, { type: "interrupted", session_id: session_id })
8034
+ broadcast(session_id, { type: "interrupted", session_id: session_id,
8035
+ reason: Thread.current[:interrupt_reason] || :user })
7725
8036
  rescue => e
7726
8037
  # Route error through web_ui so channel subscribers (飞书/企微) receive it too.
7727
8038
  web_ui = nil
@@ -7742,6 +8053,7 @@ module Clacky
7742
8053
  # Register the thread only if we still own the epoch; a faster
7743
8054
  # superseding task may have already replaced it.
7744
8055
  @registry.with_session(session_id) { |s| s[:thread] = thread if s[:epoch].to_i == epoch.to_i }
8056
+ thread
7745
8057
  end
7746
8058
 
7747
8059
  # ── WebSocket subscription management ─────────────────────────────────────
@@ -7818,12 +8130,16 @@ module Clacky
7818
8130
  end
7819
8131
 
7820
8132
  # Broadcast a session_update event to all clients so they can patch their
7821
- # local session list without needing a full session_list refresh.
7822
- def broadcast_session_update(session_id)
8133
+ # local session list without needing a full session_list refresh. Creation
8134
+ # paths set created: true after the initial snapshot is fully persisted so
8135
+ # clients can insert the session without treating later updates as new rows.
8136
+ def broadcast_session_update(session_id, created: false)
7823
8137
  session = @registry.snapshot(session_id)
7824
8138
  return unless session
7825
8139
 
7826
- broadcast_all(type: "session_update", session: session)
8140
+ event = { type: "session_update", session: session }
8141
+ event[:created] = true if created
8142
+ broadcast_all(event)
7827
8143
  end
7828
8144
 
7829
8145
  # ── Helpers ───────────────────────────────────────────────────────────────