openclacky 1.3.4 → 1.3.6
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 +39 -0
- data/lib/clacky/agent/fake_tool_call_detector.rb +52 -0
- data/lib/clacky/agent/message_compressor.rb +32 -8
- data/lib/clacky/agent/message_compressor_helper.rb +113 -12
- data/lib/clacky/agent/session_serializer.rb +3 -2
- data/lib/clacky/agent/tool_executor.rb +0 -12
- data/lib/clacky/agent.rb +74 -12
- data/lib/clacky/api_extension.rb +81 -0
- data/lib/clacky/api_extension_loader.rb +13 -1
- data/lib/clacky/cli.rb +0 -1
- data/lib/clacky/client.rb +14 -17
- data/lib/clacky/default_agents/_panels/time_machine/panel.js +22 -0
- data/lib/clacky/default_agents/base_prompt.md +1 -0
- data/lib/clacky/default_extensions/meeting/handler.rb +331 -0
- data/lib/clacky/default_extensions/meeting/meeting.js +790 -0
- data/lib/clacky/default_extensions/meeting/meta.yml +3 -0
- data/lib/clacky/default_extensions/meeting/skills/meeting-summarizer/SKILL.md +44 -0
- data/lib/clacky/default_skills/media-gen/SKILL.md +63 -0
- data/lib/clacky/default_skills/media-gen/scripts/video_seq.sh +114 -0
- data/lib/clacky/json_ui_controller.rb +1 -1
- data/lib/clacky/media/base.rb +60 -0
- data/lib/clacky/media/dashscope.rb +385 -21
- data/lib/clacky/media/gemini.rb +9 -0
- data/lib/clacky/media/generator.rb +52 -0
- data/lib/clacky/media/openai_compat.rb +166 -0
- data/lib/clacky/null_ui_controller.rb +13 -0
- data/lib/clacky/plain_ui_controller.rb +1 -1
- data/lib/clacky/providers.rb +50 -2
- data/lib/clacky/rich_ui/rich_ui_controller.rb +1 -1
- data/lib/clacky/server/channel/channel_ui_controller.rb +1 -1
- data/lib/clacky/server/http_server.rb +260 -122
- data/lib/clacky/server/session_registry.rb +54 -3
- data/lib/clacky/server/web_ui_controller.rb +3 -2
- data/lib/clacky/session_manager.rb +35 -4
- data/lib/clacky/skill_loader.rb +14 -2
- data/lib/clacky/tools/terminal/output_cleaner.rb +1 -3
- data/lib/clacky/tools/terminal.rb +0 -43
- data/lib/clacky/ui2/components/modal_component.rb +1 -1
- data/lib/clacky/ui2/layout_manager.rb +0 -5
- data/lib/clacky/ui2/progress_handle.rb +0 -3
- data/lib/clacky/ui2/ui_controller.rb +140 -40
- data/lib/clacky/ui_interface.rb +10 -1
- data/lib/clacky/utils/encoding.rb +25 -0
- data/lib/clacky/version.rb +1 -1
- data/lib/clacky/web/app.css +199 -23
- data/lib/clacky/web/components/onboard.js +1 -14
- data/lib/clacky/web/components/sidebar.js +1 -3
- data/lib/clacky/web/features/backup/store.js +23 -0
- data/lib/clacky/web/features/backup/view.js +49 -22
- data/lib/clacky/web/features/brand/view.js +8 -5
- data/lib/clacky/web/features/channels/store.js +1 -20
- data/lib/clacky/web/features/mcp/store.js +1 -20
- data/lib/clacky/web/features/profile/store.js +1 -13
- data/lib/clacky/web/features/profile/view.js +16 -4
- data/lib/clacky/web/features/skills/store.js +6 -21
- data/lib/clacky/web/features/tasks/view.js +77 -28
- data/lib/clacky/web/features/version/store.js +2 -0
- data/lib/clacky/web/i18n.js +46 -3
- data/lib/clacky/web/index.html +67 -26
- data/lib/clacky/web/sessions.js +177 -87
- data/lib/clacky/web/settings.js +34 -2
- data/lib/clacky/web/ws-dispatcher.js +12 -4
- data/lib/clacky.rb +12 -5
- metadata +8 -1
|
@@ -15,6 +15,7 @@ require "securerandom"
|
|
|
15
15
|
require "timeout"
|
|
16
16
|
require "yaml"
|
|
17
17
|
require "date"
|
|
18
|
+
require "open3"
|
|
18
19
|
require_relative "session_registry"
|
|
19
20
|
require_relative "git_panel"
|
|
20
21
|
require_relative "web_ui_controller"
|
|
@@ -368,6 +369,8 @@ module Clacky
|
|
|
368
369
|
res.body = html
|
|
369
370
|
elsif req.path.start_with?("/webui_ext/")
|
|
370
371
|
self.send(:serve_webui_ext, req, res)
|
|
372
|
+
elsif req.path.start_with?("/builtin_ext/")
|
|
373
|
+
self.send(:serve_builtin_ext, req, res)
|
|
371
374
|
elsif req.path.start_with?("/agent_ui/")
|
|
372
375
|
self.send(:serve_agent_ui, req, res)
|
|
373
376
|
elsif req.path.start_with?("/panel_ui/")
|
|
@@ -462,8 +465,12 @@ module Clacky
|
|
|
462
465
|
600
|
|
463
466
|
elsif path == "/api/media/audio/speech"
|
|
464
467
|
120
|
|
465
|
-
elsif path
|
|
466
|
-
|
|
468
|
+
elsif path == "/api/media/audio/transcriptions"
|
|
469
|
+
30
|
|
470
|
+
elsif path == "/api/media/video/understand"
|
|
471
|
+
60
|
|
472
|
+
elsif path.start_with?("/api/backup/download") || path == "/api/backup/run" || path == "/api/backup/restore"
|
|
473
|
+
# Building/extracting a tar.gz of ~/.clacky can take a while.
|
|
467
474
|
120
|
|
468
475
|
else
|
|
469
476
|
10
|
|
@@ -519,6 +526,8 @@ module Clacky
|
|
|
519
526
|
when ["GET", "/api/backup/status"] then api_backup_status(res)
|
|
520
527
|
when ["POST", "/api/backup/run"] then api_backup_run(res)
|
|
521
528
|
when ["GET", "/api/backup/download"] then api_backup_download(res)
|
|
529
|
+
when ["POST", "/api/backup/restore"] then api_backup_restore(req, res)
|
|
530
|
+
when ["POST", "/api/backup/open-folder"] then api_backup_open_folder(res)
|
|
522
531
|
when ["PATCH", "/api/backup/config"] then api_backup_config(req, res)
|
|
523
532
|
when ["POST", "/api/telemetry"] then api_telemetry(req, res)
|
|
524
533
|
when ["POST", "/api/onboard/complete"] then api_onboard_complete(req, res)
|
|
@@ -549,6 +558,8 @@ module Clacky
|
|
|
549
558
|
when ["POST", "/api/media/image"] then api_media_image(req, res)
|
|
550
559
|
when ["POST", "/api/media/video"] then api_media_video(req, res)
|
|
551
560
|
when ["POST", "/api/media/audio/speech"] then api_media_audio_speech(req, res)
|
|
561
|
+
when ["POST", "/api/media/audio/transcriptions"] then api_media_audio_transcriptions(req, res)
|
|
562
|
+
when ["POST", "/api/media/video/understand"] then api_media_video_understand(req, res)
|
|
552
563
|
when ["GET", "/api/media/types"] then api_media_types(res)
|
|
553
564
|
when ["GET", "/api/version"] then api_get_version(res)
|
|
554
565
|
when ["POST", "/api/version/upgrade"] then api_upgrade_version(req, res)
|
|
@@ -677,7 +688,7 @@ module Clacky
|
|
|
677
688
|
elsif method == "DELETE" && path.match?(%r{^/api/config/models/[^/]+$})
|
|
678
689
|
id = path.sub("/api/config/models/", "")
|
|
679
690
|
api_delete_model(id, res)
|
|
680
|
-
elsif method == "PATCH" && path.match?(%r{^/api/config/media/(image|video|audio)$})
|
|
691
|
+
elsif method == "PATCH" && path.match?(%r{^/api/config/media/(image|video|audio|stt|video_understanding)$})
|
|
681
692
|
kind = path.sub("/api/config/media/", "")
|
|
682
693
|
api_update_media_config(kind, req, res)
|
|
683
694
|
elsif method == "POST" && path.match?(%r{^/api/cron-tasks/[^/]+/run$})
|
|
@@ -728,30 +739,28 @@ module Clacky
|
|
|
728
739
|
# ── REST API ──────────────────────────────────────────────────────────────
|
|
729
740
|
|
|
730
741
|
def api_list_sessions(req, res)
|
|
731
|
-
query
|
|
732
|
-
limit
|
|
733
|
-
before
|
|
734
|
-
q
|
|
735
|
-
q_scope
|
|
736
|
-
date
|
|
737
|
-
type
|
|
742
|
+
query = URI.decode_www_form(req.query_string.to_s).to_h
|
|
743
|
+
limit = [query["limit"].to_i.then { |n| n > 0 ? n : 20 }, 50].min
|
|
744
|
+
before = query["before"].to_s.strip.then { |v| v.empty? ? nil : v }
|
|
745
|
+
q = query["q"].to_s.strip.then { |v| v.empty? ? nil : v }
|
|
746
|
+
q_scope = query["q_scope"].to_s.strip.then { |v| %w[name content].include?(v) ? v : "name" }
|
|
747
|
+
date = query["date"].to_s.strip.then { |v| v.empty? ? nil : v }
|
|
748
|
+
type = query["type"].to_s.strip.then { |v| v.empty? ? nil : v }
|
|
749
|
+
exclude_type = query["exclude_type"].to_s.strip.then { |v| v.empty? ? nil : v }
|
|
738
750
|
# Backward-compat: ?source=<x> and ?profile=coding → type
|
|
739
751
|
type ||= query["profile"].to_s.strip.then { |v| v.empty? ? nil : v }
|
|
740
752
|
type ||= query["source"].to_s.strip.then { |v| v.empty? ? nil : v }
|
|
741
753
|
|
|
742
|
-
|
|
743
|
-
# `registry.list` always returns ALL matching pinned rows first (on the
|
|
744
|
-
# first page; `before` == nil), followed by non-pinned rows up to `limit+1`.
|
|
745
|
-
# So has_more is determined by whether the non-pinned section overflowed.
|
|
746
|
-
sessions = @registry.list(limit: limit + 1, before: before, q: q, q_scope: q_scope, date: date, type: type)
|
|
754
|
+
sessions = @registry.list(limit: limit + 1, before: before, q: q, q_scope: q_scope, date: date, type: type, exclude_type: exclude_type)
|
|
747
755
|
|
|
748
|
-
# Split pinned vs non-pinned to apply has_more only to the non-pinned tail.
|
|
749
756
|
pinned_part, non_pinned_part = sessions.partition { |s| s[:pinned] }
|
|
750
757
|
has_more = non_pinned_part.size > limit
|
|
751
758
|
non_pinned_part = non_pinned_part.first(limit)
|
|
752
759
|
sessions = pinned_part + non_pinned_part
|
|
753
760
|
|
|
754
|
-
|
|
761
|
+
stats = @registry.cron_stats
|
|
762
|
+
json_response(res, 200, { sessions: sessions, has_more: has_more,
|
|
763
|
+
cron_count: stats[:count], latest_cron_updated_at: stats[:latest_updated_at] })
|
|
755
764
|
end
|
|
756
765
|
|
|
757
766
|
# GET /api/sessions/:id — fetch a single session by id (memory + disk merged).
|
|
@@ -1033,6 +1042,9 @@ module Clacky
|
|
|
1033
1042
|
# Absolute path to the user's WebUI extension directory.
|
|
1034
1043
|
WEBUI_EXT_ROOT = File.expand_path("~/.clacky/webui_ext")
|
|
1035
1044
|
|
|
1045
|
+
# Built-in extensions bundled with the gem (default_extensions/*/**.js).
|
|
1046
|
+
BUILTIN_EXT_ROOT = File.expand_path("../default_extensions", __dir__)
|
|
1047
|
+
|
|
1036
1048
|
# Build the full <script> payload injected at {{EXT_SCRIPTS}}:
|
|
1037
1049
|
# 1. global extensions — ~/.clacky/webui_ext/**/*.js (all agents)
|
|
1038
1050
|
# 2. agent-scoped UI — agents/<name>/webui/**/*.js (data-agent)
|
|
@@ -1042,6 +1054,7 @@ module Clacky
|
|
|
1042
1054
|
private def webui_ext_script_tags
|
|
1043
1055
|
[
|
|
1044
1056
|
panel_agents_script,
|
|
1057
|
+
builtin_ext_script_tags,
|
|
1045
1058
|
global_ext_script_tags,
|
|
1046
1059
|
agent_webui_script_tags,
|
|
1047
1060
|
official_panel_script_tags,
|
|
@@ -1093,6 +1106,21 @@ module Clacky
|
|
|
1093
1106
|
dirs
|
|
1094
1107
|
end
|
|
1095
1108
|
|
|
1109
|
+
# Built-in extensions from default_extensions/*/*.js — loaded before user
|
|
1110
|
+
# extensions so user ~/.clacky/webui_ext/ can override by ext id.
|
|
1111
|
+
private def builtin_ext_script_tags
|
|
1112
|
+
return "" unless Dir.exist?(BUILTIN_EXT_ROOT)
|
|
1113
|
+
|
|
1114
|
+
Dir.glob(File.join(BUILTIN_EXT_ROOT, "*", "**", "*.js")).sort.map do |abs|
|
|
1115
|
+
rel = abs.delete_prefix(BUILTIN_EXT_ROOT + "/")
|
|
1116
|
+
ext_id = "builtin/" + rel.delete_suffix(".js")
|
|
1117
|
+
src = "/builtin_ext/#{rel}"
|
|
1118
|
+
"<script>Clacky.ext._extBegin(#{ext_id.to_json}, #{nil.to_json}, #{nil.to_json})</script>" \
|
|
1119
|
+
"<script src=#{src.to_json} data-ext-id=#{ext_id.to_json}></script>" \
|
|
1120
|
+
"<script>Clacky.ext._extEnd()</script>"
|
|
1121
|
+
end.join("\n")
|
|
1122
|
+
end
|
|
1123
|
+
|
|
1096
1124
|
# Global extensions — visible for all agents (unchanged legacy behavior).
|
|
1097
1125
|
private def global_ext_script_tags
|
|
1098
1126
|
ext_script_block(WEBUI_EXT_ROOT, "/webui_ext")
|
|
@@ -1173,6 +1201,31 @@ module Clacky
|
|
|
1173
1201
|
res.body = File.read(abs)
|
|
1174
1202
|
end
|
|
1175
1203
|
|
|
1204
|
+
private def serve_builtin_ext(req, res)
|
|
1205
|
+
rel = req.path.delete_prefix("/builtin_ext/")
|
|
1206
|
+
abs = File.expand_path(File.join(BUILTIN_EXT_ROOT, rel))
|
|
1207
|
+
|
|
1208
|
+
unless abs.start_with?(BUILTIN_EXT_ROOT + File::SEPARATOR) && File.file?(abs)
|
|
1209
|
+
res.status = 404
|
|
1210
|
+
res.body = "not found"
|
|
1211
|
+
return
|
|
1212
|
+
end
|
|
1213
|
+
|
|
1214
|
+
ext = File.extname(abs)
|
|
1215
|
+
ctype = { ".js" => "application/javascript", ".css" => "text/css" }[ext]
|
|
1216
|
+
unless ctype
|
|
1217
|
+
res.status = 415
|
|
1218
|
+
res.body = "unsupported media type"
|
|
1219
|
+
return
|
|
1220
|
+
end
|
|
1221
|
+
|
|
1222
|
+
res.status = 200
|
|
1223
|
+
res["Content-Type"] = ctype
|
|
1224
|
+
res["Cache-Control"] = "no-store"
|
|
1225
|
+
res["Pragma"] = "no-cache"
|
|
1226
|
+
res.body = File.read(abs)
|
|
1227
|
+
end
|
|
1228
|
+
|
|
1176
1229
|
# Serve agents/<name>/webui/<file> from built-in or user agent dir.
|
|
1177
1230
|
# Path: /agent_ui/<name>/<rel>. User dir wins on name collision.
|
|
1178
1231
|
private def serve_agent_ui(req, res)
|
|
@@ -1405,6 +1458,59 @@ module Clacky
|
|
|
1405
1458
|
json_response(res, 500, { error: e.message })
|
|
1406
1459
|
end
|
|
1407
1460
|
|
|
1461
|
+
def api_media_audio_transcriptions(req, res)
|
|
1462
|
+
body = parse_json_body(req)
|
|
1463
|
+
return json_response(res, 400, { error: "Invalid JSON" }) unless body
|
|
1464
|
+
|
|
1465
|
+
audio_b64 = body["audio_base64"].to_s
|
|
1466
|
+
if audio_b64.empty?
|
|
1467
|
+
return json_response(res, 422, { error: "audio_base64 is required" })
|
|
1468
|
+
end
|
|
1469
|
+
|
|
1470
|
+
mime_type = body["mime_type"].to_s
|
|
1471
|
+
mime_type = "audio/webm" if mime_type.empty?
|
|
1472
|
+
|
|
1473
|
+
result = Clacky::Media::Generator.new(@agent_config).generate_transcription(
|
|
1474
|
+
audio_base64: audio_b64,
|
|
1475
|
+
mime_type: mime_type
|
|
1476
|
+
)
|
|
1477
|
+
if result["success"]
|
|
1478
|
+
Clacky::Logger.info("[Media] stt generated model=#{result["model"]} provider=#{result["provider"]} cost_usd=#{result["cost_usd"].to_f}")
|
|
1479
|
+
end
|
|
1480
|
+
status = result["success"] ? 200 : 422
|
|
1481
|
+
json_response(res, status, result)
|
|
1482
|
+
rescue StandardError => e
|
|
1483
|
+
json_response(res, 500, { error: e.message })
|
|
1484
|
+
end
|
|
1485
|
+
|
|
1486
|
+
def api_media_video_understand(req, res)
|
|
1487
|
+
body = parse_json_body(req)
|
|
1488
|
+
return json_response(res, 400, { error: "Invalid JSON" }) unless body
|
|
1489
|
+
|
|
1490
|
+
video_b64 = body["video_base64"].to_s
|
|
1491
|
+
if video_b64.empty?
|
|
1492
|
+
return json_response(res, 422, { error: "video_base64 is required" })
|
|
1493
|
+
end
|
|
1494
|
+
|
|
1495
|
+
mime_type = body["mime_type"].to_s
|
|
1496
|
+
mime_type = "image/png" if mime_type.empty?
|
|
1497
|
+
|
|
1498
|
+
prompt = body["prompt"].to_s
|
|
1499
|
+
|
|
1500
|
+
result = Clacky::Media::Generator.new(@agent_config).understand_video(
|
|
1501
|
+
video_base64: video_b64,
|
|
1502
|
+
mime_type: mime_type,
|
|
1503
|
+
prompt: prompt
|
|
1504
|
+
)
|
|
1505
|
+
if result["success"]
|
|
1506
|
+
Clacky::Logger.info("[Media] video_understanding generated model=#{result["model"]} provider=#{result["provider"]} cost_usd=#{result["cost_usd"].to_f}")
|
|
1507
|
+
end
|
|
1508
|
+
status = result["success"] ? 200 : 422
|
|
1509
|
+
json_response(res, status, result)
|
|
1510
|
+
rescue StandardError => e
|
|
1511
|
+
json_response(res, 500, { error: e.message })
|
|
1512
|
+
end
|
|
1513
|
+
|
|
1408
1514
|
private def log_media_usage(result, prompt:)
|
|
1409
1515
|
usage = result["usage"]
|
|
1410
1516
|
cost = result["cost_usd"]
|
|
@@ -1967,6 +2073,7 @@ module Clacky
|
|
|
1967
2073
|
product_name: brand.product_name,
|
|
1968
2074
|
homepage_url: brand.homepage_url,
|
|
1969
2075
|
logo_url: brand.logo_url,
|
|
2076
|
+
theme_color: brand.theme_color,
|
|
1970
2077
|
test_mode: @brand_test,
|
|
1971
2078
|
distribution_refresh_pending: refresh_pending
|
|
1972
2079
|
})
|
|
@@ -2038,7 +2145,8 @@ module Clacky
|
|
|
2038
2145
|
ok: true,
|
|
2039
2146
|
product_name: result[:product_name] || brand.product_name,
|
|
2040
2147
|
user_id: result[:user_id] || brand.license_user_id,
|
|
2041
|
-
user_licensed: brand.user_licensed
|
|
2148
|
+
user_licensed: brand.user_licensed?,
|
|
2149
|
+
theme_color: brand.theme_color
|
|
2042
2150
|
})
|
|
2043
2151
|
else
|
|
2044
2152
|
json_response(res, 422, { ok: false, error: result[:message] })
|
|
@@ -4769,6 +4877,68 @@ module Clacky
|
|
|
4769
4877
|
})
|
|
4770
4878
|
end
|
|
4771
4879
|
|
|
4880
|
+
# POST /api/backup/restore — accept a tar.gz upload, extract over ~/.clacky, hot-restart
|
|
4881
|
+
def api_backup_restore(req, res)
|
|
4882
|
+
body = req.body.to_s
|
|
4883
|
+
return json_response(res, 400, { error: "Empty body" }) if body.empty?
|
|
4884
|
+
|
|
4885
|
+
clacky_dir = File.expand_path("~/.clacky")
|
|
4886
|
+
stamp = Time.now.strftime("%Y%m%d-%H%M%S")
|
|
4887
|
+
tmp_archive = File.join(Dir.tmpdir, "clacky-restore-#{stamp}.tar.gz")
|
|
4888
|
+
tmp_backup = File.join(Dir.tmpdir, "clacky-pre-restore-#{stamp}")
|
|
4889
|
+
|
|
4890
|
+
File.binwrite(tmp_archive, body)
|
|
4891
|
+
|
|
4892
|
+
FileUtils.cp_r(clacky_dir, tmp_backup)
|
|
4893
|
+
|
|
4894
|
+
result = system("tar", "-xzf", tmp_archive, "-C", clacky_dir)
|
|
4895
|
+
unless result
|
|
4896
|
+
FileUtils.rm_rf(clacky_dir)
|
|
4897
|
+
FileUtils.cp_r(tmp_backup, clacky_dir)
|
|
4898
|
+
return json_response(res, 500, { error: "Failed to extract archive" })
|
|
4899
|
+
end
|
|
4900
|
+
|
|
4901
|
+
json_response(res, 200, { ok: true })
|
|
4902
|
+
|
|
4903
|
+
Thread.new do
|
|
4904
|
+
sleep 0.5
|
|
4905
|
+
if @master_pid
|
|
4906
|
+
begin
|
|
4907
|
+
Process.kill("USR1", @master_pid)
|
|
4908
|
+
rescue Errno::ESRCH
|
|
4909
|
+
standalone_exec_restart
|
|
4910
|
+
end
|
|
4911
|
+
else
|
|
4912
|
+
standalone_exec_restart
|
|
4913
|
+
end
|
|
4914
|
+
end
|
|
4915
|
+
rescue => e
|
|
4916
|
+
json_response(res, 500, { ok: false, error: e.message })
|
|
4917
|
+
ensure
|
|
4918
|
+
FileUtils.rm_f(tmp_archive) if tmp_archive
|
|
4919
|
+
FileUtils.rm_rf(tmp_backup) if tmp_backup && Dir.exist?(tmp_backup)
|
|
4920
|
+
end
|
|
4921
|
+
|
|
4922
|
+
# POST /api/backup/open-folder — open the backup destination in Finder/Explorer
|
|
4923
|
+
def api_backup_open_folder(res)
|
|
4924
|
+
dest = BackupManager.status["dest_dir"]
|
|
4925
|
+
FileUtils.mkdir_p(dest)
|
|
4926
|
+
host_os = RbConfig::CONFIG["host_os"]
|
|
4927
|
+
if host_os =~ /darwin/
|
|
4928
|
+
system("open", dest)
|
|
4929
|
+
elsif host_os =~ /linux/
|
|
4930
|
+
if File.exist?("/proc/version") && File.read("/proc/version").downcase.include?("microsoft")
|
|
4931
|
+
windows_path, = Open3.capture2("wslpath", "-w", dest)
|
|
4932
|
+
system("explorer.exe", windows_path.strip) unless windows_path.strip.empty?
|
|
4933
|
+
else
|
|
4934
|
+
system("xdg-open", dest)
|
|
4935
|
+
end
|
|
4936
|
+
end
|
|
4937
|
+
json_response(res, 200, { ok: true, dest_dir: dest })
|
|
4938
|
+
rescue => e
|
|
4939
|
+
json_response(res, 500, { ok: false, error: e.message })
|
|
4940
|
+
end
|
|
4941
|
+
|
|
4772
4942
|
# Resolve the AgentConfig for a given session, falling back to nil when
|
|
4773
4943
|
# the session isn't live so callers can use the global config instead.
|
|
4774
4944
|
def config_for_session(session_id)
|
|
@@ -5615,12 +5785,12 @@ module Clacky
|
|
|
5615
5785
|
interrupt_session(session_id)
|
|
5616
5786
|
|
|
5617
5787
|
when "list_sessions"
|
|
5618
|
-
|
|
5619
|
-
|
|
5620
|
-
page = @registry.list(limit: 21)
|
|
5788
|
+
stats = @registry.cron_stats
|
|
5789
|
+
page = @registry.list(limit: 21, exclude_type: "cron")
|
|
5621
5790
|
has_more = page.size > 20
|
|
5622
5791
|
all_sessions = page.first(20)
|
|
5623
|
-
conn.send_json(type: "session_list", sessions: all_sessions, has_more: has_more,
|
|
5792
|
+
conn.send_json(type: "session_list", sessions: all_sessions, has_more: has_more,
|
|
5793
|
+
cron_count: stats[:count], latest_cron_updated_at: stats[:latest_updated_at])
|
|
5624
5794
|
|
|
5625
5795
|
when "run_task"
|
|
5626
5796
|
# Client sends this after subscribing to guarantee it's ready to receive
|
|
@@ -5715,109 +5885,43 @@ module Clacky
|
|
|
5715
5885
|
|
|
5716
5886
|
# Interrupt a running agent session.
|
|
5717
5887
|
#
|
|
5718
|
-
# Thread#raise
|
|
5719
|
-
#
|
|
5720
|
-
#
|
|
5721
|
-
# that stay "running" forever even after multiple interrupt attempts.
|
|
5722
|
-
#
|
|
5723
|
-
# Strategy: three-tier escalation in a background watchdog Thread so
|
|
5724
|
-
# the HTTP handler returns immediately.
|
|
5725
|
-
#
|
|
5726
|
-
# Tier 1 (t=0): Thread#raise(AgentInterrupted).
|
|
5727
|
-
# Unblocks most pure-Ruby waits and Faraday reads.
|
|
5728
|
-
# Handles the common case.
|
|
5729
|
-
# Tier 2 (t=3): force-close this session's WebSocket connections
|
|
5730
|
-
# so any send_raw stuck on socket write wakes up.
|
|
5731
|
-
# Try Thread#raise again (idempotent).
|
|
5732
|
-
# Tier 3 (t=8): Thread#kill — last resort. Leaks any held
|
|
5733
|
-
# resources but frees the session so the user can
|
|
5734
|
-
# move on.
|
|
5888
|
+
# Thread#raise is best-effort: it unblocks most pure-Ruby waits and
|
|
5889
|
+
# Faraday reads, but can't reach a thread stuck in a C-extension syscall
|
|
5890
|
+
# until that syscall returns. We raise once and return immediately.
|
|
5735
5891
|
#
|
|
5736
|
-
#
|
|
5737
|
-
#
|
|
5738
|
-
#
|
|
5892
|
+
# Correctness of the *takeover* does not depend on the old thread dying
|
|
5893
|
+
# promptly: each new task claims a fresh epoch (see run_agent_task), and
|
|
5894
|
+
# any status write or UI broadcast from a superseded thread is fenced off
|
|
5895
|
+
# by that epoch. A stale thread that lingers in a syscall is harmless — it
|
|
5896
|
+
# self-terminates at the next check_stale! checkpoint, or when the syscall
|
|
5897
|
+
# returns; either way it can no longer touch the live session.
|
|
5739
5898
|
def interrupt_session(session_id)
|
|
5740
|
-
thread = nil
|
|
5741
5899
|
@registry.with_session(session_id) do |s|
|
|
5742
5900
|
s[:idle_timer]&.cancel
|
|
5743
5901
|
thread = s[:thread]
|
|
5744
|
-
|
|
5745
5902
|
next unless thread&.alive?
|
|
5746
5903
|
|
|
5747
|
-
Clacky::Logger.info("[interrupt] session=#{session_id}
|
|
5904
|
+
Clacky::Logger.info("[interrupt] session=#{session_id} raise")
|
|
5748
5905
|
begin
|
|
5749
5906
|
thread.raise(Clacky::AgentInterrupted, "Interrupted by user")
|
|
5750
5907
|
rescue ThreadError => e
|
|
5751
|
-
Clacky::Logger.warn("[interrupt]
|
|
5908
|
+
Clacky::Logger.warn("[interrupt] raise failed: #{e.message}")
|
|
5752
5909
|
end
|
|
5753
5910
|
end
|
|
5911
|
+
end # Run a task in a session immediately in the background, without waiting
|
|
5912
|
+
# for the client to subscribe. The user bubble is persisted via
|
|
5913
|
+
# display_text (Agent#run → history → replay_history), so the frontend
|
|
5914
|
+
# only needs to navigate over and load history — no realtime broadcast,
|
|
5915
|
+
# no subscribe-timing race. This is the stable entry point for
|
|
5916
|
+
# programmatic "create session + run now" flows (spawn, extensions).
|
|
5917
|
+
def run_session_task(session_id, prompt, display_message: nil)
|
|
5918
|
+
return unless @registry.exist?(session_id)
|
|
5754
5919
|
|
|
5755
|
-
|
|
5756
|
-
|
|
5757
|
-
|
|
5758
|
-
end
|
|
5759
|
-
|
|
5760
|
-
# Background watchdog: escalates from WebSocket force-close (tier 2)
|
|
5761
|
-
# to Thread#kill (tier 3) if the agent thread refuses to die.
|
|
5762
|
-
private def start_interrupt_watchdog(session_id, thread)
|
|
5763
|
-
Thread.new do
|
|
5764
|
-
Thread.current.name = "interrupt-watchdog[#{session_id}]" rescue nil
|
|
5765
|
-
|
|
5766
|
-
# Give the first Thread#raise a few seconds to unwind.
|
|
5767
|
-
sleep 3
|
|
5768
|
-
next unless thread.alive?
|
|
5769
|
-
|
|
5770
|
-
Clacky::Logger.warn(
|
|
5771
|
-
"[interrupt] session=#{session_id} tier=2 raise failed after 3s, " \
|
|
5772
|
-
"force-closing session resources"
|
|
5773
|
-
)
|
|
5774
|
-
force_close_session_sockets(session_id)
|
|
5775
|
-
# Re-raise — sometimes the first raise was swallowed deep in a
|
|
5776
|
-
# C-extension syscall; after we force-close the socket the
|
|
5777
|
-
# syscall returns and the next raise sticks.
|
|
5778
|
-
begin
|
|
5779
|
-
thread.raise(Clacky::AgentInterrupted, "Interrupted by user (escalated)")
|
|
5780
|
-
rescue ThreadError
|
|
5781
|
-
# already dead between checks — fine
|
|
5782
|
-
end
|
|
5783
|
-
|
|
5784
|
-
sleep 5
|
|
5785
|
-
next unless thread.alive?
|
|
5786
|
-
|
|
5787
|
-
Clacky::Logger.error(
|
|
5788
|
-
"[interrupt] session=#{session_id} tier=3 still alive after 8s, Thread#kill"
|
|
5789
|
-
)
|
|
5790
|
-
begin
|
|
5791
|
-
thread.kill
|
|
5792
|
-
rescue StandardError => e
|
|
5793
|
-
Clacky::Logger.error("[interrupt] Thread#kill raised: #{e.class}: #{e.message}")
|
|
5794
|
-
end
|
|
5795
|
-
|
|
5796
|
-
# Record the forced-kill so the UI can show a warning and operators
|
|
5797
|
-
# can correlate with any backtrace dumps. The session is left in
|
|
5798
|
-
# :idle state by run_agent_task's rescue clause; if the kill
|
|
5799
|
-
# happened before the rescue could run, patch the state directly.
|
|
5800
|
-
begin
|
|
5801
|
-
@registry.update(session_id, status: :idle, error: "Force-killed (interrupt watchdog)")
|
|
5802
|
-
broadcast_session_update(session_id)
|
|
5803
|
-
rescue StandardError
|
|
5804
|
-
# best effort
|
|
5805
|
-
end
|
|
5806
|
-
end
|
|
5807
|
-
end
|
|
5920
|
+
agent = nil
|
|
5921
|
+
@registry.with_session(session_id) { |s| agent = s[:agent] }
|
|
5922
|
+
return unless agent
|
|
5808
5923
|
|
|
5809
|
-
|
|
5810
|
-
# the interrupt watchdog to unblock agent threads stuck in a WS write.
|
|
5811
|
-
private def force_close_session_sockets(session_id)
|
|
5812
|
-
conns = @ws_mutex.synchronize { (@ws_clients[session_id] || []).dup }
|
|
5813
|
-
conns.each do |conn|
|
|
5814
|
-
Clacky::Logger.warn(
|
|
5815
|
-
"[interrupt] session=#{session_id} force-closing WS conn"
|
|
5816
|
-
)
|
|
5817
|
-
conn.force_close!
|
|
5818
|
-
end
|
|
5819
|
-
rescue StandardError => e
|
|
5820
|
-
Clacky::Logger.error("[interrupt] force_close_session_sockets error: #{e.class}: #{e.message}")
|
|
5924
|
+
run_agent_task(session_id, agent) { agent.run(prompt, display_text: display_message) }
|
|
5821
5925
|
end
|
|
5822
5926
|
|
|
5823
5927
|
# Start the pending task for a session.
|
|
@@ -5827,18 +5931,29 @@ module Clacky
|
|
|
5827
5931
|
return unless @registry.exist?(session_id)
|
|
5828
5932
|
|
|
5829
5933
|
session = @registry.get(session_id)
|
|
5830
|
-
prompt
|
|
5831
|
-
working_dir
|
|
5934
|
+
prompt = session[:pending_task]
|
|
5935
|
+
working_dir = session[:pending_working_dir]
|
|
5936
|
+
display_message = session[:pending_display_message]
|
|
5832
5937
|
return unless prompt # nothing pending
|
|
5833
5938
|
|
|
5834
5939
|
# Clear the pending fields so a re-connect doesn't re-run
|
|
5835
|
-
@registry.update(session_id, pending_task: nil, pending_working_dir: nil)
|
|
5940
|
+
@registry.update(session_id, pending_task: nil, pending_working_dir: nil, pending_display_message: nil)
|
|
5836
5941
|
|
|
5837
5942
|
agent = nil
|
|
5838
5943
|
@registry.with_session(session_id) { |s| agent = s[:agent] }
|
|
5839
5944
|
return unless agent
|
|
5840
5945
|
|
|
5841
|
-
|
|
5946
|
+
# Surface a user message on screen before the agent starts thinking, so
|
|
5947
|
+
# programmatically-submitted tasks (e.g. meeting summarization) don't
|
|
5948
|
+
# appear as a thinking spinner with no preceding message. When a short
|
|
5949
|
+
# display_message is provided we show that instead of the full prompt.
|
|
5950
|
+
if display_message
|
|
5951
|
+
web_ui = nil
|
|
5952
|
+
@registry.with_session(session_id) { |s| web_ui = s[:ui] }
|
|
5953
|
+
web_ui&.show_user_message(display_message, source: :web)
|
|
5954
|
+
end
|
|
5955
|
+
|
|
5956
|
+
run_agent_task(session_id, agent) { agent.run(prompt, display_text: display_message) }
|
|
5842
5957
|
end
|
|
5843
5958
|
|
|
5844
5959
|
# Interrupt every running agent thread and persist its session state.
|
|
@@ -5874,11 +5989,19 @@ module Clacky
|
|
|
5874
5989
|
# Cancel any pending idle compression before starting a new task
|
|
5875
5990
|
idle_timer&.cancel
|
|
5876
5991
|
|
|
5877
|
-
#
|
|
5992
|
+
# Claim a fresh epoch and mark running atomically-ish. The epoch
|
|
5993
|
+
# fences this task against an interrupted-but-not-yet-dead predecessor:
|
|
5994
|
+
# the old thread compares its epoch before writing status or
|
|
5995
|
+
# broadcasting and silently drops anything once superseded. Without
|
|
5996
|
+
# this a slow old thread could overwrite :running back to :idle (or
|
|
5997
|
+
# close the new task's sockets), leaving the UI stuck "running".
|
|
5998
|
+
#
|
|
5999
|
+
# Marked running BEFORE evict_excess_idle! — otherwise this session
|
|
5878
6000
|
# (still :idle here) can be evicted from the registry along with
|
|
5879
6001
|
# other idle agents, breaking subsequent status updates and any
|
|
5880
6002
|
# follow-up handle_user_message (which would early-return on
|
|
5881
6003
|
# @registry.exist? == false).
|
|
6004
|
+
epoch = @registry.claim_epoch(session_id)
|
|
5882
6005
|
@registry.update(session_id, status: :running)
|
|
5883
6006
|
|
|
5884
6007
|
# evict_excess_idle! serializes + writes 1 file per evicted session
|
|
@@ -5894,8 +6017,9 @@ module Clacky
|
|
|
5894
6017
|
locale = Thread.current[:lang]
|
|
5895
6018
|
thread = Thread.new do
|
|
5896
6019
|
Thread.current[:lang] = locale
|
|
6020
|
+
Thread.current[:task_epoch] = epoch
|
|
5897
6021
|
task.call
|
|
5898
|
-
@registry.
|
|
6022
|
+
next unless @registry.update_if_epoch(session_id, epoch, status: :idle, error: nil)
|
|
5899
6023
|
broadcast_session_update(session_id)
|
|
5900
6024
|
# Transient global signal for the optional task-complete sound. Sent to
|
|
5901
6025
|
# all clients (broadcast_all) so a browser viewing another session — or
|
|
@@ -5906,7 +6030,9 @@ module Clacky
|
|
|
5906
6030
|
# Start idle compression timer now that the agent is idle
|
|
5907
6031
|
idle_timer&.start
|
|
5908
6032
|
rescue Clacky::AgentInterrupted
|
|
5909
|
-
|
|
6033
|
+
# A superseding task already owns the session — do not touch status
|
|
6034
|
+
# or push UI events, they belong to the new epoch now.
|
|
6035
|
+
next unless @registry.update_if_epoch(session_id, epoch, status: :idle)
|
|
5910
6036
|
broadcast_session_update(session_id)
|
|
5911
6037
|
broadcast(session_id, { type: "interrupted", session_id: session_id })
|
|
5912
6038
|
@session_manager.save(agent.to_session_data(status: :interrupted))
|
|
@@ -5921,12 +6047,15 @@ module Clacky
|
|
|
5921
6047
|
top_up_url = preset && preset["website_url"]
|
|
5922
6048
|
end
|
|
5923
6049
|
user_message = e.respond_to?(:display_message) && e.display_message ? e.display_message : e.message
|
|
5924
|
-
|
|
6050
|
+
raw_message = e.respond_to?(:raw_message) ? e.raw_message : nil
|
|
6051
|
+
next unless @registry.update_if_epoch(session_id, epoch, status: :error, error: user_message, error_code: code, top_up_url: top_up_url, raw_message: raw_message)
|
|
5925
6052
|
broadcast_session_update(session_id)
|
|
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))
|
|
6053
|
+
web_ui&.show_error(user_message, code: code, top_up_url: top_up_url, raw_message: raw_message)
|
|
6054
|
+
@session_manager.save(agent.to_session_data(status: :error, error_message: user_message, raw_message: raw_message))
|
|
5928
6055
|
end
|
|
5929
|
-
|
|
6056
|
+
# Register the thread only if we still own the epoch; a faster
|
|
6057
|
+
# superseding task may have already replaced it.
|
|
6058
|
+
@registry.with_session(session_id) { |s| s[:thread] = thread if s[:epoch].to_i == epoch.to_i }
|
|
5930
6059
|
end
|
|
5931
6060
|
|
|
5932
6061
|
# ── WebSocket subscription management ─────────────────────────────────────
|
|
@@ -5956,6 +6085,15 @@ module Clacky
|
|
|
5956
6085
|
# removed automatically. Connections already marked closed are skipped
|
|
5957
6086
|
# upfront so one sluggish client can't delay delivery to healthy ones.
|
|
5958
6087
|
def broadcast(session_id, event)
|
|
6088
|
+
# Drop events emitted by a superseded agent thread. A thread carrying a
|
|
6089
|
+
# :task_epoch only gets through while it still owns the session; an
|
|
6090
|
+
# interrupted-but-unwinding old thread (e.g. a late show_progress
|
|
6091
|
+
# "done" from an ensure block) is silently dropped so it can't disturb
|
|
6092
|
+
# the new task's UI. Threads without :task_epoch (HTTP handlers, the
|
|
6093
|
+
# task supervisor itself) are never affected.
|
|
6094
|
+
my_epoch = Thread.current[:task_epoch]
|
|
6095
|
+
return if my_epoch && @registry.current_epoch(session_id) != my_epoch
|
|
6096
|
+
|
|
5959
6097
|
clients = @ws_mutex.synchronize { (@ws_clients[session_id] || []).dup }
|
|
5960
6098
|
dead = []
|
|
5961
6099
|
clients.each do |conn|
|