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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +96 -0
- data/lib/clacky/agent/chunk_index.rb +83 -0
- data/lib/clacky/agent/history_navigation.rb +239 -0
- data/lib/clacky/agent/session_serializer.rb +53 -98
- data/lib/clacky/agent.rb +477 -297
- data/lib/clacky/agent_config.rb +5 -3
- data/lib/clacky/billing/billing_store.rb +2 -2
- data/lib/clacky/billing/platform_billing.rb +7 -0
- data/lib/clacky/brand_config.rb +27 -19
- data/lib/clacky/cli.rb +92 -12
- data/lib/clacky/cli_guidance.rb +48 -0
- data/lib/clacky/client.rb +2 -2
- data/lib/clacky/default_extensions/ext-studio/agents/ext-developer/system_prompt.md +70 -134
- data/lib/clacky/default_extensions/ext-studio/api/handler.rb +4 -1
- data/lib/clacky/default_extensions/ext-studio/panels/studio/view.js +307 -98
- data/lib/clacky/default_extensions/ext-studio/skills/ext-develop/SKILL.md +179 -577
- data/lib/clacky/default_extensions/git/panels/git/view.js +34 -14
- data/lib/clacky/default_extensions/preview/ext.yml +20 -0
- data/lib/clacky/default_extensions/preview/panels/preview/view.js +475 -0
- data/lib/clacky/default_extensions/time_machine/panels/time_machine/view.js +1 -10
- data/lib/clacky/extension/api_extension.rb +18 -6
- data/lib/clacky/extension/verifier.rb +1 -1
- data/lib/clacky/json_ui_controller.rb +4 -2
- data/lib/clacky/media/openai_compat.rb +70 -30
- data/lib/clacky/message_format/bedrock.rb +6 -1
- data/lib/clacky/message_format/open_ai.rb +22 -5
- data/lib/clacky/message_format/open_ai_responses.rb +7 -3
- data/lib/clacky/plain_ui_controller.rb +1 -1
- data/lib/clacky/prompts/base.md +1 -1
- data/lib/clacky/providers.rb +44 -13
- data/lib/clacky/rich_ui/components/composer_guidance.rb +49 -0
- data/lib/clacky/rich_ui/rich_ui_controller.rb +24 -4
- data/lib/clacky/rich_ui/shell/rich_agent_shell.rb +13 -0
- data/lib/clacky/search_config.rb +3 -3
- data/lib/clacky/server/channel/channel_manager.rb +25 -7
- data/lib/clacky/server/channel/channel_ui_controller.rb +1 -1
- data/lib/clacky/server/dir_picker.rb +67 -4
- data/lib/clacky/server/git_panel.rb +10 -2
- data/lib/clacky/server/http_server.rb +391 -75
- data/lib/clacky/server/preview.rb +351 -0
- data/lib/clacky/server/session_registry.rb +4 -0
- data/lib/clacky/server/web_ui_controller.rb +10 -5
- data/lib/clacky/session_manager.rb +52 -1
- data/lib/clacky/skill.rb +48 -0
- data/lib/clacky/tools/browser.rb +176 -19
- data/lib/clacky/tools/web_search.rb +91 -8
- data/lib/clacky/ui2/components/command_suggestions.rb +3 -1
- data/lib/clacky/ui2/components/input_area.rb +37 -4
- data/lib/clacky/ui2/components/modal_component.rb +35 -6
- data/lib/clacky/ui2/screen_buffer.rb +1 -0
- data/lib/clacky/ui2/ui_controller.rb +72 -12
- data/lib/clacky/ui_interface.rb +1 -1
- data/lib/clacky/utils/file_processor.rb +19 -7
- data/lib/clacky/utils/mac_app_detector.rb +186 -0
- data/lib/clacky/utils/model_pricing.rb +118 -68
- data/lib/clacky/utils/windows_app_detector.rb +334 -0
- data/lib/clacky/version.rb +1 -1
- data/lib/clacky/web/app.css +998 -168
- data/lib/clacky/web/app.js +19 -0
- data/lib/clacky/web/components/chat-navigator.js +489 -202
- data/lib/clacky/web/components/code-editor.js +191 -5
- data/lib/clacky/web/components/composer.js +3 -1
- data/lib/clacky/web/components/datepicker.js +19 -19
- data/lib/clacky/web/components/mentions.js +11 -14
- data/lib/clacky/web/components/model-picker.js +66 -23
- data/lib/clacky/web/components/quote-select.js +341 -0
- data/lib/clacky/web/core/aside.js +157 -8
- data/lib/clacky/web/core/ext.js +16 -0
- data/lib/clacky/web/features/billing/view.js +75 -21
- data/lib/clacky/web/features/extensions/store.js +4 -1
- data/lib/clacky/web/features/model-tester/store.js +37 -4
- data/lib/clacky/web/features/skills/store.js +18 -1
- data/lib/clacky/web/features/skills/view.js +128 -7
- data/lib/clacky/web/features/workspace/store.js +80 -7
- data/lib/clacky/web/features/workspace/view.js +474 -43
- data/lib/clacky/web/i18n.js +158 -23
- data/lib/clacky/web/index.html +57 -29
- data/lib/clacky/web/sessions.js +517 -127
- data/lib/clacky/web/settings.js +155 -6
- data/lib/clacky/web/utils.js +34 -0
- data/lib/clacky/web/vendor/codemirror/codemirror.min.js +24 -19
- data/lib/clacky/web/vendor/codemirror/entry.js +130 -0
- data/lib/clacky/web/vendor/codemirror/package.json +29 -0
- data/lib/clacky/web/ws-dispatcher.js +161 -4
- data/lib/clacky.rb +2 -0
- 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
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
2612
|
-
#
|
|
2613
|
-
brand.sync_brand_skills_async!
|
|
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
|
-
|
|
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) ==
|
|
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:
|
|
4430
|
-
#
|
|
4431
|
-
#
|
|
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
|
-
|
|
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:
|
|
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
|
|
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:
|
|
6162
|
-
index:
|
|
6163
|
-
model:
|
|
6164
|
-
base_url:
|
|
6165
|
-
api_key_masked:
|
|
6166
|
-
anthropic_format:
|
|
6167
|
-
api_format:
|
|
6168
|
-
provider_id:
|
|
6169
|
-
|
|
6170
|
-
|
|
6171
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
#
|
|
7454
|
-
#
|
|
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
|
|
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
|
-
#
|
|
7549
|
-
#
|
|
7550
|
-
#
|
|
7551
|
-
|
|
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 =
|
|
7699
|
-
|
|
7700
|
-
|
|
7701
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 ───────────────────────────────────────────────────────────────
|